Files
maziggy 6988a30eae Carry a fault's description in the status response (issue #2926)
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.
2026-08-24 09:47:39 +02:00

166 lines
7.7 KiB
Python

"""Tests for HMS error code translations."""
from backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, describe_fault, get_error_description
class TestHMSErrorDescriptions:
"""Tests for the HMS error descriptions dictionary."""
def test_dictionary_is_not_empty(self):
"""Verify the error descriptions dictionary has entries."""
assert len(HMS_ERROR_DESCRIPTIONS) > 0
def test_dictionary_has_expected_count(self):
"""Verify we have the expected number of error codes."""
# Should have 853 error codes from the frontend
assert len(HMS_ERROR_DESCRIPTIONS) == 853
def test_all_keys_are_valid_format(self):
"""Verify all keys follow the XXXX_YYYY format."""
import re
pattern = re.compile(r"^[0-9A-F]{4}_[0-9A-F]{4}$")
for code in HMS_ERROR_DESCRIPTIONS:
assert pattern.match(code), f"Invalid error code format: {code}"
def test_all_values_are_non_empty_strings(self):
"""Verify all descriptions are non-empty strings."""
for code, description in HMS_ERROR_DESCRIPTIONS.items():
assert isinstance(description, str), f"Description for {code} is not a string"
assert len(description) > 0, f"Description for {code} is empty"
class TestGetErrorDescription:
"""Tests for the get_error_description function."""
def test_returns_description_for_known_code(self):
"""Verify known error codes return their descriptions."""
# 0300_400C = "The task was canceled."
result = get_error_description("0300_400C")
assert result == "The task was canceled."
def test_returns_description_for_ams_error(self):
"""Verify AMS error codes return their descriptions."""
# 0700_8010 = AMS assist motor overloaded
result = get_error_description("0700_8010")
assert "AMS assist motor" in result
def test_returns_none_for_unknown_code(self):
"""Verify unknown error codes return None."""
result = get_error_description("XXXX_YYYY")
assert result is None
def test_handles_lowercase_input(self):
"""Verify function handles lowercase input."""
result = get_error_description("0300_400c")
assert result == "The task was canceled."
def test_handles_mixed_case_input(self):
"""Verify function handles mixed case input."""
result = get_error_description("0300_400C")
assert result == "The task was canceled."
def test_common_error_codes_have_descriptions(self):
"""Verify common error codes have descriptions."""
common_codes = [
"0300_4000", # Z axis homing failed
"0300_4006", # Nozzle clogged
"0300_8004", # Filament ran out
"0500_4001", # Failed to connect to Bambu Cloud
"0700_8010", # AMS assist motor overloaded
]
for code in common_codes:
result = get_error_description(code)
assert result is not None, f"Missing description for common code: {code}"
class TestDescribeFault:
"""`describe_fault` maps a fault's canonical `full_code` onto the catalogue,
so every surface that reports a fault resolves it the same way (#2926)."""
def test_resolves_an_eight_char_print_error_code(self):
"""The parser derives full_code and the catalogue key from the same
32-bit value, so the split is exact rather than a guess."""
assert describe_fault("03008004") == "Filament ran out. Please load new filament."
def test_resolves_regardless_of_case(self):
"""Firmware-facing code is uppercase, but a client echoing a value back
from its own store may not be."""
assert describe_fault("0300400c") == "The task was canceled."
def test_tolerates_surrounding_whitespace(self):
assert describe_fault(" 03008004 ") == "Filament ran out. Please load new filament."
def test_returns_none_for_an_hms_code_outside_the_catalogue(self):
"""A real P2S fault from #2728. Neither the whole 16-char key nor its
G1_G4 collapse ("0500_000A") is in the catalogue — no catalogue key has
an error group below 0x4000, and this family's is 0x000A."""
assert describe_fault("050002000003000A") is None
def test_collapses_a_sixteen_char_code_to_its_g1_g4_short_key(self):
"""Lossy, and kept deliberately: this is how the notification path, the
queue's failure-reason helper and the frontend modal have always
resolved `hms[]` faults, and it resolves real ones. Refusing would stop
describing faults that are described today (see the module docstring)."""
key = next(iter(HMS_ERROR_DESCRIPTIONS)) # e.g. "0300_4000"
module, error = key.split("_")
forced = f"{module}02000003{error}" # four 4-hex groups; G1 and G4 are the key
assert len(forced) == 16
assert describe_fault(forced) == HMS_ERROR_DESCRIPTIONS[key]
def test_prefers_the_whole_sixteen_char_key_over_the_collapse(self):
"""The full identifier is lossless, so it wins when the catalogue has
both. No 16-char keys ship today; this pins the order for when they do."""
key = next(iter(HMS_ERROR_DESCRIPTIONS))
module, error = key.split("_")
forced = f"{module}02000003{error}"
HMS_ERROR_DESCRIPTIONS[forced] = "specific variant"
try:
assert describe_fault(forced) == "specific variant"
finally:
del HMS_ERROR_DESCRIPTIONS[forced]
def test_matches_the_derivation_it_replaced(
self,
):
"""The regression guard for the consolidation: for every fault shape the
codebase can produce, `describe_fault` returns exactly what the
attr/code short-code lookup in the notification path returned before it.
Covers both families and all three alert levels a real `hms[]` code
carries — a divergence here means notifications silently stop firing for
faults that used to raise them."""
for key, expected in HMS_ERROR_DESCRIPTIONS.items():
module, error = int(key[:4], 16), int(key[5:], 16)
# print_error: attr is the whole 32-bit value, code its low half.
print_error = (module << 16) | error
assert describe_fault(f"{print_error:08X}") == expected
# hms[]: attr is groups 1-2, code is groups 3-4 (alert level + id).
for alert_level in (0x0000, 0x0002, 0x0003):
attr = (module << 16) | 0x0200
code = (alert_level << 16) | error
legacy = get_error_description(f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}")
assert describe_fault(f"{attr:08X}{code:08X}") == legacy == expected
def test_returns_none_for_an_unknown_eight_char_code(self):
assert describe_fault("99999999") is None
def test_returns_none_for_empty_or_missing(self):
"""The dataclass default is "" and the field is optional on the wire."""
assert describe_fault("") is None
assert describe_fault(None) is None
def test_returns_none_for_a_malformed_length(self):
"""Neither 8 nor 16 chars — no shape to interpret, so no guess."""
assert describe_fault("0300") is None
assert describe_fault("030080040") is None
def test_agrees_with_the_short_code_lookup_for_print_error_codes(self):
"""Pins the equivalence the consolidation rests on: for every 8-char
code the catalogue covers, describe_fault returns exactly what the
pre-existing short-code lookup did."""
for key, expected in HMS_ERROR_DESCRIPTIONS.items():
assert describe_fault(key.replace("_", "")) == expected
assert get_error_description(key) == expected