Backend changes:

1. bambu_mqtt.py: Added hms_errors to the completion callback data, which includes the HMS error codes when a print fails
  2. archive.py: Updated update_archive_status() to accept an optional failure_reason parameter
  3. main.py: Added auto-detection of failure reasons:
    - status == "aborted" → failure_reason = "User cancelled"
    - status == "failed" with HMS errors → maps module codes to reasons:
        - Module 0x07 (Filament) → "Filament runout"
      - Module 0x0C (Motion Controller) → "Layer shift"
      - Module 0x05 (Nozzle) → "Clogged nozzle"

  Frontend changes:

  1. ArchivesPage.tsx:
    - Badge now shows "cancelled" for aborted prints, "failed" for failed prints
    - "Failed Prints" collection and "Hide Failed" filter now include both failed and aborted statuses
  2. EditArchiveModal.tsx: Failure reason field now shows for both failed and aborted prints

  New tests added:
  - test_hms_errors_included_in_failed_completion_callback
  - test_aborted_status_when_cancelled

  The HMS error module mapping is basic and can be expanded as you observe more failure types. The actual Bambu HMS error codes are documented in their wiki - we can add
  more mappings as needed.
This commit is contained in:
maziggy
2025-12-13 10:13:54 +01:00
parent f5cec45dbf
commit e85e5fa33e
9 changed files with 727 additions and 495 deletions
+163 -49
View File
@@ -13,10 +13,10 @@ Full end-to-end tests require the actual database setup.
"""
import asyncio
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
@@ -26,11 +26,12 @@ class TestPrintStartLogic:
@pytest.mark.asyncio
async def test_print_start_calls_notification_service(self, capture_logs):
"""Verify on_print_start triggers notification service."""
with patch('backend.app.main.async_session') as mock_session_maker, \
patch('backend.app.main.notification_service') as mock_notif, \
patch('backend.app.main.smart_plug_manager') as mock_plug, \
patch('backend.app.main.ws_manager') as mock_ws:
with (
patch("backend.app.main.async_session") as mock_session_maker,
patch("backend.app.main.notification_service") as mock_notif,
patch("backend.app.main.smart_plug_manager") as mock_plug,
patch("backend.app.main.ws_manager") as mock_ws,
):
mock_notif.on_print_start = AsyncMock()
mock_plug.on_print_start = AsyncMock()
mock_ws.send_print_start = AsyncMock()
@@ -44,17 +45,19 @@ class TestPrintStartLogic:
from backend.app.main import on_print_start
await on_print_start(1, {
"filename": "/data/Metadata/test.gcode",
"subtask_name": "Test",
})
await on_print_start(
1,
{
"filename": "/data/Metadata/test.gcode",
"subtask_name": "Test",
},
)
# Verify WebSocket notification was sent
mock_ws.send_print_start.assert_called_once()
# Verify no import shadowing errors
errors = [r for r in capture_logs.get_errors()
if "cannot access local variable" in str(r.message)]
errors = [r for r in capture_logs.get_errors() if "cannot access local variable" in str(r.message)]
assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
@@ -64,11 +67,12 @@ class TestPrintCompleteLogic:
@pytest.mark.asyncio
async def test_print_complete_no_import_errors(self, capture_logs):
"""Verify on_print_complete doesn't have import shadowing issues."""
with patch('backend.app.main.async_session') as mock_session_maker, \
patch('backend.app.main.notification_service') as mock_notif, \
patch('backend.app.main.smart_plug_manager') as mock_plug, \
patch('backend.app.main.ws_manager') as mock_ws:
with (
patch("backend.app.main.async_session") as mock_session_maker,
patch("backend.app.main.notification_service") as mock_notif,
patch("backend.app.main.smart_plug_manager") as mock_plug,
patch("backend.app.main.ws_manager") as mock_ws,
):
mock_notif.on_print_complete = AsyncMock()
mock_plug.on_print_complete = AsyncMock()
mock_ws.send_print_complete = AsyncMock()
@@ -82,16 +86,18 @@ class TestPrintCompleteLogic:
from backend.app.main import on_print_complete
await on_print_complete(1, {
"status": "completed",
"filename": "/data/Metadata/test.gcode",
"subtask_name": "Test",
"timelapse_was_active": False,
})
await on_print_complete(
1,
{
"status": "completed",
"filename": "/data/Metadata/test.gcode",
"subtask_name": "Test",
"timelapse_was_active": False,
},
)
# Verify no import shadowing errors - this would have caught the ArchiveService bug
errors = [r for r in capture_logs.get_errors()
if "cannot access local variable" in str(r.message)]
errors = [r for r in capture_logs.get_errors() if "cannot access local variable" in str(r.message)]
assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
@@ -115,18 +121,21 @@ class TestTimelapseTracking:
client._timelapse_during_print = False
# Message with both state and timelapse
client._process_message({
"print": {
"gcode_state": "RUNNING",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
"xcam": {"timelapse": "enable"},
client._process_message(
{
"print": {
"gcode_state": "RUNNING",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
"xcam": {"timelapse": "enable"},
}
}
})
)
assert client._was_running is True
assert client._timelapse_during_print is True, \
"Timelapse should be detected even when xcam is parsed before state"
assert (
client._timelapse_during_print is True
), "Timelapse should be detected even when xcam is parsed before state"
@pytest.mark.asyncio
async def test_timelapse_flag_included_in_completion_callback(self):
@@ -148,27 +157,131 @@ class TestTimelapseTracking:
client.on_print_complete = on_complete
# Start with timelapse
client._process_message({
"print": {
"gcode_state": "RUNNING",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
"xcam": {"timelapse": "enable"},
client._process_message(
{
"print": {
"gcode_state": "RUNNING",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
"xcam": {"timelapse": "enable"},
}
}
})
)
# Complete print
client._process_message({
"print": {
"gcode_state": "FINISH",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
client._process_message(
{
"print": {
"gcode_state": "FINISH",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
}
}
})
)
assert "timelapse_was_active" in completion_data
assert completion_data["timelapse_was_active"] is True
@pytest.mark.asyncio
async def test_hms_errors_included_in_failed_completion_callback(self):
"""Verify completion callback receives hms_errors for failed prints."""
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
completion_data = {}
def on_complete(data):
completion_data.update(data)
client.on_print_start = lambda data: None
client.on_print_complete = on_complete
# Start print
client._process_message(
{
"print": {
"gcode_state": "RUNNING",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
}
}
)
# Add HMS error during print
client._process_message(
{
"print": {
"gcode_state": "RUNNING",
"hms": [{"attr": 0x07000002, "code": 0x1234}], # Filament module error
}
}
)
# Fail print
client._process_message(
{
"print": {
"gcode_state": "FAILED",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
}
}
)
assert "hms_errors" in completion_data
assert len(completion_data["hms_errors"]) == 1
assert completion_data["hms_errors"][0]["module"] == 0x07
assert completion_data["status"] == "failed"
@pytest.mark.asyncio
async def test_aborted_status_when_cancelled(self):
"""Verify completion callback receives 'aborted' status when print is cancelled."""
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
completion_data = {}
def on_complete(data):
completion_data.update(data)
client.on_print_start = lambda data: None
client.on_print_complete = on_complete
# Start print
client._process_message(
{
"print": {
"gcode_state": "RUNNING",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
}
}
)
# User cancels (goes to IDLE)
client._process_message(
{
"print": {
"gcode_state": "IDLE",
"gcode_file": "/data/Metadata/test.gcode",
"subtask_name": "Test",
}
}
)
assert completion_data["status"] == "aborted"
assert "hms_errors" in completion_data
class TestCallbackErrorHandling:
"""Test that callback errors are properly logged."""
@@ -216,6 +329,7 @@ class TestNoImportShadowing:
# Check logs for any import-related errors
errors = capture_logs.get_errors()
import_errors = [e for e in errors if "import" in str(e.message).lower()
or "local variable" in str(e.message).lower()]
import_errors = [
e for e in errors if "import" in str(e.message).lower() or "local variable" in str(e.message).lower()
]
assert not import_errors, f"Import errors found: {import_errors}"