mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
The HMS catalogue has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user why a print halted resolved the same 853 codes from its own duplicate of the same sentences -- this repo's Python table, the frontend modal's, and at least one third-party client whose catalogue exists purely because the server would not say. Each ages separately, and a relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament." hms_errors[] entries now carry a description, defaulting to null so a client that has never seen the field is unaffected. It is resolved where the fault is parsed rather than at the boundary that prompted the request, because there are three serializers of a fault, not one: the status response, the WebSocket broadcast, and the completion payload the queue's failure reason is built from. Adding it to only the first would have handed half the feature to a relay watching the stream, which is the likelier consumer of the three. The queue's failure reason now quotes the resolved sentence instead of looking the code up a fourth time, and the notification path reads it rather than re-deriving. That they cannot report different text for one fault is the point, and a test asserts they agree. describe_fault is the single mapping from either code shape onto the table. An 8-char print_error is the catalogue's MMMM_EEEE key with the separator removed -- the parser derives full_code and that key from the same 32-bit value -- so it resolves exactly. A 16-char hms[] identifier is tried whole and then collapsed to its first and last groups. That collapse is lossy, and keeping it was the decision worth making carefully. #2728 counts 65 documented faults falling onto 0300_0001 alone, so a hit can attribute a neighbour's sentence to this fault, and refusing it looks like the stricter reading. It is not: the notification path, the queue's failure-reason helper and the frontend modal have all resolved hms[] faults this way for as long as they have existed, and it resolves real ones -- a 0500_4038 nozzle mismatch arrives in that shape. Declining to collapse would have stopped describing faults that are described today, silently suppressed the notifications they raise, and left this field null while the UI showed text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. So the lookup is exactly what it was, verified rather than asserted: a test walks every catalogue code in both fault shapes across all three alert levels and checks the result against the derivation this replaces. A future change to the lookup cannot quietly stop notifications firing. The catalogue ships one language, so the field is English and unlocalized, which the schema and the API reference both say next to it. The camwall feed is deliberately left alone -- it is code-only because its token travels in a URL on a screen, and a readable sentence discloses more than the camera picture already does. The frontend keeps resolving its own text: switching it would change what filterKnownHMSErrors counts across eight call sites, which is #1840 and #2728's argument to have. HMSError.message goes with this -- a text field that was never set or read anywhere, and an invitation to populate the wrong one now that a live description sits beside it. ----- Record a failure code the user can actually look up The queue's failure reason formats a fault's module and error into MMMM_EEEE, and that one derivation never masked the error to 16 bits. A fault arriving from the printer's hms[] array carries its alert level in the code's high half, so the label came out as 0500_24038 -- five digits in a group that has four. It is not a code anyone can find on Bambu's HMS index, and because it matches no catalogue key the sentence explaining the failure was dropped along with it, leaving the bare number alone. The nozzle-size mismatch behind #1111 is exactly such a fault. Reported one way it read "[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting"; reported the other, the same physical fault read "[0500_24038]" and nothing else. There is already a helper that gets this right, used by the archive's own failure-reason lookup, so this calls it instead of keeping a fourth copy of the derivation. It also takes the raw integer code the MQTT payload carries, which the local version only handled as a string.
134 lines
5.5 KiB
Python
134 lines
5.5 KiB
Python
"""One fault, one sentence, on every surface that reports it (#2926).
|
|
|
|
The catalogue in ``services/hms_errors.py`` has always held the text, and the
|
|
status response never carried it, so each client resolved the same codes from
|
|
its own copy of the same table. The description is now resolved once, at parse
|
|
time, and passed through by all three serializers of an ``HMSError``: the
|
|
status response, the WebSocket broadcast, and the print-completion payload the
|
|
queue's failure reason is built from. These tests pin that they agree — the
|
|
point of resolving it in one place is that they cannot drift apart.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from backend.app.main import _format_hms_error_summary
|
|
from backend.app.schemas.printer import HMSErrorResponse
|
|
from backend.app.services.bambu_mqtt import HMSError, PrinterState
|
|
from backend.app.services.printer_manager import printer_state_to_dict
|
|
|
|
RUNOUT_SENTENCE = "Filament ran out. Please load new filament."
|
|
|
|
|
|
def _runout() -> HMSError:
|
|
"""A `print_error` fault the catalogue covers, as the parser builds it."""
|
|
return HMSError(
|
|
code="0x8004",
|
|
attr=0x03008004,
|
|
module=3,
|
|
severity=3,
|
|
full_code="03008004",
|
|
description=RUNOUT_SENTENCE,
|
|
)
|
|
|
|
|
|
def _uncatalogued() -> HMSError:
|
|
"""An `hms[]` fault the catalogue cannot describe — a real P2S code (#2728).
|
|
Its G1_G4 collapse is "0500_000A", which is not a key either."""
|
|
return HMSError(
|
|
code="0x3000a",
|
|
attr=0x05000200,
|
|
module=5,
|
|
severity=2,
|
|
full_code="050002000003000A",
|
|
description=None,
|
|
)
|
|
|
|
|
|
class TestStatusResponse:
|
|
def test_carries_the_description(self):
|
|
"""What the route's mapper produces — the field a third-party client
|
|
needs so it does not have to ship the catalogue itself."""
|
|
e = _runout()
|
|
assert (
|
|
HMSErrorResponse(
|
|
code=e.code,
|
|
attr=e.attr,
|
|
module=e.module,
|
|
severity=e.severity,
|
|
actions=e.actions,
|
|
job_id=e.job_id,
|
|
full_code=e.full_code,
|
|
description=e.description,
|
|
).description
|
|
== RUNOUT_SENTENCE
|
|
)
|
|
|
|
def test_defaults_to_none_when_not_supplied(self):
|
|
"""A producer that never sets it still validates, so the field cannot
|
|
break an existing construction path."""
|
|
assert HMSErrorResponse(code="0x8004", attr=0, module=3, severity=3).description is None
|
|
|
|
def test_serializes_as_null_rather_than_being_dropped(self):
|
|
"""A client distinguishing "no text" from "field absent" needs the key
|
|
present. Pydantic includes None by default; pin it so a later
|
|
`exclude_none` does not silently change the contract."""
|
|
payload = HMSErrorResponse(code="0x3000a", attr=0, module=5, severity=2).model_dump()
|
|
assert "description" in payload
|
|
assert payload["description"] is None
|
|
|
|
|
|
class TestWebSocketBroadcast:
|
|
def test_carries_the_description(self):
|
|
"""The broadcast is a separate hand-rolled serializer; a relay watching
|
|
the stream should not have to poll REST to find out what a fault means."""
|
|
state = PrinterState()
|
|
state.hms_errors = [_runout()]
|
|
assert printer_state_to_dict(state, printer_id=1)["hms_errors"][0]["description"] == RUNOUT_SENTENCE
|
|
|
|
def test_passes_none_through_for_an_uncatalogued_fault(self):
|
|
"""The fault is still broadcast — only the text is missing."""
|
|
state = PrinterState()
|
|
state.hms_errors = [_uncatalogued()]
|
|
entry = printer_state_to_dict(state, printer_id=1)["hms_errors"][0]
|
|
assert entry["full_code"] == "050002000003000A"
|
|
assert entry["description"] is None
|
|
|
|
|
|
class TestQueueFailureReason:
|
|
def test_prefers_the_resolved_description(self):
|
|
"""Deliberately a sentence the local fallback would NOT produce, so the
|
|
preference is observable rather than coincidentally identical."""
|
|
supplied = "Filament ran out, as resolved at parse time."
|
|
assert _format_hms_error_summary([{"code": "0x8004", "attr": 0x03008004, "description": supplied}]) == (
|
|
f"[0300_8004] {supplied}"
|
|
)
|
|
|
|
def test_falls_back_for_an_entry_without_the_field(self):
|
|
"""Entries predating the field still resolve, so the helper's own
|
|
contract is unchanged for any other caller."""
|
|
assert _format_hms_error_summary([{"code": "0x8004", "attr": 0x03008004}]) == (f"[0300_8004] {RUNOUT_SENTENCE}")
|
|
|
|
def test_bare_short_code_when_nothing_describes_it(self):
|
|
assert _format_hms_error_summary([{"code": "0x9999", "attr": 0x99990000, "description": None}]) == "[9999_9999]"
|
|
|
|
|
|
class TestSurfacesAgree:
|
|
@pytest.mark.parametrize("fault,expected", [(_runout(), RUNOUT_SENTENCE), (_uncatalogued(), None)])
|
|
def test_the_same_fault_reads_the_same_everywhere(self, fault, expected):
|
|
"""The reason to resolve once rather than at each boundary: these three
|
|
cannot report different text for one fault."""
|
|
state = PrinterState()
|
|
state.hms_errors = [fault]
|
|
broadcast = printer_state_to_dict(state, printer_id=1)["hms_errors"][0]["description"]
|
|
rest = HMSErrorResponse(
|
|
code=fault.code,
|
|
attr=fault.attr,
|
|
module=fault.module,
|
|
severity=fault.severity,
|
|
full_code=fault.full_code,
|
|
description=fault.description,
|
|
).description
|
|
assert broadcast == expected
|
|
assert rest == expected
|
|
assert fault.description == expected
|