浏览代码

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.
maziggy 1 周之前
父节点
当前提交
6988a30eae

文件差异内容过多而无法显示
+ 0 - 0
CHANGELOG.md


+ 1 - 0
backend/app/api/routes/printers.py

@@ -512,6 +512,7 @@ async def get_printer_status(
             actions=e.actions,
             job_id=e.job_id,
             full_code=e.full_code,
+            description=e.description,
         )
         for e in (state.hms_errors or [])
     ]

+ 17 - 13
backend/app/main.py

@@ -1267,11 +1267,13 @@ def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> b
 def _format_hms_error_summary(hms_errors: list[dict]) -> str | None:
     """Build a human-readable failure reason from MQTT hms_errors for PrintQueueItem.error_message.
 
-    Each entry has keys: code ('0x4038'), attr (32-bit int), module, severity.
-    The short code used for the hms_errors.py lookup table is 'MMMM_EEEE' — module
-    from attr bits 16-31, error from the numeric part of code. Falls back to the raw
-    short code when no description is on file. Returns None for an empty list so
-    callers can leave error_message unset.
+    Each entry has keys: code ('0x4038'), attr (32-bit int), module, severity, and
+    — since #2926 — the description the parser already resolved, which is preferred
+    when present so the queue's failure reason reads the same as the status
+    response. The short code still produces the bracketed label, and still
+    resolves the sentence for a caller whose entries predate the field. Falls back
+    to the bare short code when no description is on file. Returns None for an
+    empty list so callers can leave error_message unset.
     """
     if not hms_errors:
         return None
@@ -1280,13 +1282,15 @@ def _format_hms_error_summary(hms_errors: list[dict]) -> str | None:
     parts: list[str] = []
     for err in hms_errors:
         try:
-            code_str = str(err.get("code", "")).replace("0x", "")
-            error_num = int(code_str, 16) if code_str else 0
-            module_num = (int(err.get("attr", 0)) >> 16) & 0xFFFF
-            short_code = f"{module_num:04X}_{error_num:04X}"
+            # `_hms_short_code` rather than a local derivation: this one used to
+            # format the error without masking it to 16 bits, so an `hms[]` entry
+            # whose code carries an alert-level group produced a five-digit label
+            # like "0500_3000A" — not a code the user can look up, and never a
+            # catalogue key, so the sentence was lost with it.
+            short_code = _hms_short_code(err.get("attr", 0), err.get("code", 0))
         except (TypeError, ValueError):
             continue
-        description = get_error_description(short_code)
+        description = err.get("description") or get_error_description(short_code)
         parts.append(f"[{short_code}] {description}" if description else f"[{short_code}]")
     return "; ".join(parts) if parts else None
 
@@ -1729,8 +1733,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                     0x12: "Chamber",
                 }
 
-                from backend.app.services.hms_errors import get_error_description
-
                 # Capture camera snapshot once for all error notifications (no DB held).
                 error_image_data = await _capture_snapshot_for_notification(
                     printer_id, printer, logging.getLogger(__name__)
@@ -1749,7 +1751,9 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
                         # Only notify for errors with known descriptions — printers
                         # send many undocumented/phantom codes that aren't real errors.
-                        description = get_error_description(short_code)
+                        # Resolved at parse time (#2926); short_code is still needed
+                        # for the suppression set below.
+                        description = error.description
                         if not description or short_code in _HMS_NOTIFICATION_SUPPRESS:
                             continue
 

+ 9 - 0
backend/app/schemas/printer.py

@@ -170,6 +170,15 @@ class HMSErrorResponse(BaseModel):
     # truncated short_code that historically caused silent command rejection
     # (#1830, H2D wrong-plate verification).
     full_code: str = ""
+    # The bundled catalogue's sentence for this fault, so a client does not have
+    # to carry its own copy of the same table to tell a user why a print halted
+    # (#2926). English only and not localized — the catalogue ships one language.
+    # None when the catalogue does not cover the code, which is common for
+    # `hms[]`-array faults: those resolve through a lossy collapse of their
+    # 16-char identifier and many land on no key at all (#2728). A client should
+    # treat null as "no text available", never as "no fault" — `full_code` is
+    # what identifies the fault, and it is always present.
+    description: str | None = None
 
 
 class AMSTray(BaseModel):

+ 20 - 2
backend/app/services/bambu_mqtt.py

@@ -22,6 +22,7 @@ from datetime import datetime, timezone
 import paho.mqtt.client as mqtt
 
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
+from backend.app.services.hms_errors import describe_fault
 from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
 
 logger = logging.getLogger(__name__)
@@ -625,7 +626,13 @@ class HMSError:
     attr: int  # Attribute value for constructing wiki URL
     module: int
     severity: int  # 1=fatal, 2=serious, 3=common, 4=info
-    message: str = ""
+    # The bundled catalogue's sentence for this fault, resolved once here so
+    # every surface that reports it — the status response, the WebSocket
+    # broadcast, the completion payload, notifications — says the same thing.
+    # None when the catalogue does not cover the code; `describe_fault` documents
+    # the lookup and why the lossy `hms[]` collapse is kept as it was.
+    # Replaces a `message` field that was never set or read anywhere.
+    description: str | None = None
     # User-facing remediation actions from the bundled HMS catalog (e.g. "RESUME_PRINTING",
     # "CHECK_ASSISTANT"). Defaults to an empty list rather than None so the field always
     # satisfies HMSErrorResponse.actions: list[str] — a future code path that builds an
@@ -4576,6 +4583,7 @@ class BambuMQTTClient:
                                 actions=actions,
                                 job_id=self.state.subtask_id,
                                 full_code=full_code,
+                                description=describe_fault(full_code),
                             )
                         )
             self._apply_mqtt_verify_state(verify_failed)
@@ -4651,6 +4659,7 @@ class BambuMQTTClient:
                                     # print_error is already 32-bit — `f"{print_error:08X}"`
                                     # is the firmware's matching key with no truncation.
                                     full_code=f"{print_error:08X}",
+                                    description=describe_fault(f"{print_error:08X}"),
                                 )
                             )
 
@@ -5228,7 +5237,16 @@ class BambuMQTTClient:
             # Include HMS errors for failure reason detection
             hms_errors_data = (
                 [
-                    {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
+                    {
+                        "code": e.code,
+                        "attr": e.attr,
+                        "module": e.module,
+                        "severity": e.severity,
+                        # Carried so the queue's failure reason quotes the same
+                        # sentence the status response and the broadcast do,
+                        # rather than resolving the code a fourth time (#2926).
+                        "description": e.description,
+                    }
                     for e in self.state.hms_errors
                 ]
                 if self.state.hms_errors

+ 45 - 0
backend/app/services/hms_errors.py

@@ -873,3 +873,48 @@ def get_error_description(error_code: str) -> str | None:
         Human-readable description or None if not found
     """
     return HMS_ERROR_DESCRIPTIONS.get(error_code.upper())
+
+
+def describe_fault(full_code: str | None) -> str | None:
+    """Resolve a fault's description from the canonical `full_code`.
+
+    `full_code` is the identifier the firmware itself matches on: 8 hex chars
+    for a 32-bit `print_error`, 16 for a 64-bit `hms[]` entry. This is the one
+    place that maps either shape onto this table, so every surface that reports
+    a fault says the same thing about it.
+
+    An 8-char code is this table'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 code is tried whole first, then collapsed to `G1_G4` (the first
+    and last of its four hex groups). That collapse is lossy and not injective:
+    it discards the Part No. and Alert level groups, and #2728 measured 65
+    documented faults falling onto `0300_0001` alone, so a hit can in principle
+    attribute a neighbouring fault's sentence to this one. It is kept because it
+    is what this codebase has always done -- the notification path, the queue's
+    failure-reason helper and the frontend modal all resolve `hms[]` faults this
+    way, and it does resolve real ones (a `0500_4038` nozzle mismatch arrives in
+    that shape). Refusing to collapse would not be a stricter reading of the
+    same data; it would silently stop describing faults that are described
+    today, and leave this field null while the UI shows text for the same fault.
+    Narrowing it is #2728's subject, and belongs there where the key spaces can
+    be changed together.
+
+    Returns None for an empty, malformed, or unknown code.
+    """
+    if not full_code:
+        return None
+    code = full_code.strip().upper()
+    if len(code) == 8:
+        return HMS_ERROR_DESCRIPTIONS.get(f"{code[:4]}_{code[4:]}")
+    if len(code) == 16:
+        # `is not None` rather than truthiness: an entry whose text is empty is
+        # still an entry, and falling through on it would resolve the fault to a
+        # neighbour's sentence. No blank values ship today; the frontend lookup
+        # draws the same distinction and a regenerated catalogue could.
+        exact = HMS_ERROR_DESCRIPTIONS.get(code)
+        if exact is not None:
+            return exact
+        return HMS_ERROR_DESCRIPTIONS.get(f"{code[:4]}_{code[12:]}")
+    return None

+ 4 - 0
backend/app/services/printer_manager.py

@@ -1539,6 +1539,10 @@ def printer_state_to_dict(
                 "actions": e.actions,
                 "job_id": e.job_id,
                 "full_code": e.full_code,
+                # Same field as the status response carries (#2926) — a relay
+                # watching the stream should not have to poll REST to find out
+                # what a fault means.
+                "description": e.description,
             }
             for e in (state.hms_errors or [])
         ],

+ 31 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5452,6 +5452,37 @@ class TestHMSFullCode:
         assert len(mqtt_client.state.hms_errors) == 1
         assert mqtt_client.state.hms_errors[0].full_code == "05008051"
 
+    def test_print_error_path_carries_the_catalogue_description(self, mqtt_client):
+        """The sentence is resolved once, at parse time, so every surface that
+        reports the fault quotes the same text (#2926)."""
+        mqtt_client._update_state({"print_error": 0x03008004})
+        assert len(mqtt_client.state.hms_errors) == 1
+        assert mqtt_client.state.hms_errors[0].description == "Filament ran out. Please load new filament."
+
+    def test_print_error_path_leaves_description_none_for_an_uncatalogued_code(self, mqtt_client):
+        """An undocumented code gets no invented text — the field is the
+        catalogue's answer, not a placeholder."""
+        mqtt_client._update_state({"print_error": 0x03009999})
+        assert len(mqtt_client.state.hms_errors) == 1
+        assert mqtt_client.state.hms_errors[0].description is None
+
+    def test_hms_array_path_resolves_via_the_short_key(self, mqtt_client):
+        """`hms[]` faults resolve through the G1_G4 collapse — the same lookup
+        the notification path and the frontend modal have always used. 0500_4038
+        is the nozzle-size mismatch behind #1111 and it arrives in this shape."""
+        mqtt_client._update_state({"hms": [{"attr": 0x05000000, "code": 0x00004038}]})
+        assert len(mqtt_client.state.hms_errors) == 1
+        assert "nozzle diameter" in (mqtt_client.state.hms_errors[0].description or "")
+
+    def test_hms_array_leaves_description_none_when_uncatalogued(self, mqtt_client):
+        """A real P2S fault (#2728) whose collapse is "0500_000A" — not a
+        catalogue key, since none has an error group below 0x4000. The fault is
+        still reported; only the text is absent."""
+        mqtt_client._update_state({"hms": [{"attr": 0x05000200, "code": 0x0003000A}]})
+        assert len(mqtt_client.state.hms_errors) == 1
+        assert mqtt_client.state.hms_errors[0].full_code == "050002000003000A"
+        assert mqtt_client.state.hms_errors[0].description is None
+
     def test_hms_array_catalog_lookup_tries_16_char_first(self, mqtt_client, monkeypatch):
         """When the catalog has both an 8-char and a 16-char entry for the
         same fault family, the 16-char (specific variant) wins. The 8-char

+ 92 - 1
backend/tests/unit/services/test_hms_errors.py

@@ -1,6 +1,6 @@
 """Tests for HMS error code translations."""
 
-from backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, get_error_description
+from backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, describe_fault, get_error_description
 
 
 class TestHMSErrorDescriptions:
@@ -72,3 +72,94 @@ class TestGetErrorDescription:
         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

+ 133 - 0
backend/tests/unit/test_hms_description_surfaces.py

@@ -0,0 +1,133 @@
+"""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

+ 24 - 0
backend/tests/unit/test_hms_error_summary.py

@@ -52,3 +52,27 @@ def test_tolerates_malformed_entry_and_skips_it():
 
 def test_all_malformed_returns_none():
     assert _format([{"code": "not-hex", "attr": "also-not-int"}]) is None
+
+
+def test_masks_a_32_bit_code_into_a_four_digit_label():
+    """An `hms[]` entry's code carries the alert level in its high 16 bits. The
+    label used to be formatted from the unmasked value, producing "0500_3000A" —
+    five digits in a group that has four, so it matched no catalogue key and was
+    not a code anyone could look up either."""
+    summary = _format([{"code": "0x3000a", "attr": 0x05000200, "module": 5, "severity": 2}])
+    assert summary == "[0500_000A]"
+
+
+def test_masking_lets_a_32_bit_code_resolve_its_description():
+    """0500_4038 is the nozzle-size mismatch. Arriving as an `hms[]` entry with
+    an alert-level group, it went undescribed purely because of the formatting
+    above; now it reads the same as when it arrives via print_error."""
+    summary = _format([{"code": "0x00024038", "attr": 0x05000200, "module": 5, "severity": 2}])
+    assert summary is not None
+    assert summary.startswith("[0500_4038] ")
+    assert "nozzle diameter" in summary.lower()
+
+
+def test_accepts_an_integer_code():
+    """`_hms_short_code` takes both shapes; the raw MQTT payload carries ints."""
+    assert _format([{"code": 0x4038, "attr": 0x05000000, "module": 5, "severity": 1}]).startswith("[0500_4038] ")

+ 6 - 0
frontend/src/api/client.ts

@@ -390,6 +390,12 @@ export interface HMSError {
   // this back as HmsActionBody.print_error so we don't truncate the 64-bit
   // identifier into the silent-rejection short code (#1830).
   full_code?: string;
+  // The backend's resolved catalogue sentence for this fault (#2926). English
+  // only, and null when the catalogue does not cover the code. Resolved with the
+  // same lookup order this file's consumers use (full_code, then the G1_G4
+  // collapse), so it agrees with what HMSErrorModal renders — the modal still
+  // resolves its own text, and this is here for parity with the API.
+  description?: string | null;
 }
 
 export interface HMSActionBody {

部分文件因为文件数量过多而无法显示