Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,30 @@ message when it recovers. There are deliberately no repeat reminders. Note that
*low filament* warning is not possible: every AMS tray reports `remain: -1` for
third-party spools, so detection is reactive only.

**Error codes are explained, not just relayed.** Bambuddy hands over the number
only (its `hms_errors` entries carry no description), so an alert used to read
`⚠️ Fehler: 0x4003`. `hms.py` resolves the code against Bambu's official
catalogue — bundled offline under `hms_data/`, both languages — and the alert,
`!progress` and the failure notice now say what is actually wrong:

```
⚠️ 0700-2000-0002-0001 — AMS A Slot 1 Filament ist aufgebraucht. Bitte legen Sie
ein neues Filament ein.
```

Severity picks the marker (🛑 fatal · ⚠️ warning · ℹ️ info). Both code namespaces
the printer uses are covered — 16-char HMS codes and 8-char `print_error` codes —
and an unlisted code still goes out as a number, which is what the Bambu wiki and
support ask for. Refresh the catalogue after a firmware update with
`./.venv/bin/python scripts/refresh_hms_codes.py` and commit the result.

The catalogue can be **scoped to one machine** by serial prefix (`?d=01P` is the
P1S). That only filters the code set — of the ~3900 codes the scoped and the
generic list share, none differ in wording. The refresh script fetches both and
merges them, so the four codes only the P1S knows (part-cooling fan, Ethernet
accessory, SD-card wear) are present without losing an explanation for anything
else a firmware might emit.

## Localization

Replies are available in **German (default)** and **English**, chosen **per
Expand Down
61 changes: 19 additions & 42 deletions handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import classify
import colors
import config
import hms
import i18n
import signal_client
import slicing
Expand Down Expand Up @@ -1525,15 +1526,15 @@ async def _progress(group_id):
rem = s.get("remaining_time")
active = state.upper() in _ACTIVE_STATES or (isinstance(prog, (int, float)) and 0 < prog < 100)
paused = state.upper() in _PAUSED_STATES
hms = _hms_detail(s, lang)
hms_detail = _hms_detail(s, lang) # not `hms` — that's the module
if not (name and active):
text = i18n.t(lang, "progress_idle", state=state.lower())
# Nothing printing *and* the plate isn't confirmed clear → say why the
# queue is stalled, so checking in an hour actually explains itself.
if s.get("awaiting_plate_clear"):
text += "\n" + i18n.t(lang, "progress_awaiting_clear")
if hms:
text += "\n" + i18n.t(lang, "progress_hms", detail=hms)
if hms_detail:
text += "\n" + i18n.t(lang, "progress_hms", detail=hms_detail)
await signal_client.send_to_group(group_id, text, attachments=attachments)
return
parts = [f'{"⏸️" if paused else "🖨️"} „{name}" — {state}']
Expand All @@ -1552,9 +1553,12 @@ async def _progress(group_id):
clock = (datetime.datetime.now() + datetime.timedelta(minutes=r)).strftime("%H:%M")
parts.append(i18n.t(lang, "progress_remaining", dur=dur))
parts.append(i18n.t(lang, "progress_done_at", clock=clock))
if hms:
parts.append(i18n.t(lang, "progress_hms", detail=hms))
await signal_client.send_to_group(group_id, " · ".join(parts), attachments=attachments)
# The error goes on its own line, not into the " · " status strip: an
# explained code is a full sentence and would make that strip unreadable.
text = " · ".join(parts)
if hms_detail:
text += "\n" + i18n.t(lang, "progress_hms", detail=hms_detail)
await signal_client.send_to_group(group_id, text, attachments=attachments)


async def _go(group_id):
Expand Down Expand Up @@ -1583,40 +1587,10 @@ async def poll_completions(interval=60):

# ----- intervention alerts: the printer is stopped and needs a human -----

def _hms_entry(e):
"""(code, description) from one hms_errors entry. Bambuddy returns a list, but
the element shape isn't contractually pinned — accept dicts or bare strings,
and never raise: an exception in the poller kills that whole cycle for *every*
tracker, not just this one."""
if isinstance(e, dict):
code = (e.get("code") or e.get("hms_code") or e.get("attr") or "")
desc = (e.get("description") or e.get("desc") or e.get("message") or "")
return str(code).strip(), str(desc).strip()
return str(e).strip(), ""


def _hms_codes(pstatus):
"""Sorted HMS error codes as strings, or [] when the printer is healthy."""
try:
entries = (pstatus or {}).get("hms_errors") or []
codes = [c for c, _ in (_hms_entry(e) for e in entries) if c]
return sorted(set(codes))
except Exception:
log.warning("could not read hms_errors", exc_info=True)
return []


def _hms_detail(pstatus, lang="de"):
"""': 0500-4003 Filament ausgegangen' — code + text, max 3, or '' if healthy."""
try:
entries = (pstatus or {}).get("hms_errors") or []
except Exception:
return ""
bits = []
for e in entries[:3]:
code, desc = _hms_entry(e)
bits.append(f"{code} {desc}".strip() or i18n.t(lang, "hms_unknown"))
return (": " + "; ".join(bits)) if bits else ""
# Reading and explaining the printer's error codes lives in hms.py — Bambuddy
# ships the number only, the sentence comes from Bambu's bundled catalogue.
_hms_codes = hms.codes
_hms_detail = hms.detail


def _condition(pstatus):
Expand All @@ -1626,7 +1600,7 @@ def _condition(pstatus):
codes = _hms_codes(pstatus)
if codes:
return "hms:" + ",".join(codes)
if (pstatus or {}).get("hms_errors"):
if hms.entries(pstatus):
return "hms:unknown" # errors present but unparseable — still alert
if ((pstatus or {}).get("state") or "").upper() in _PAUSED_STATES:
return "pause"
Expand Down Expand Up @@ -1726,7 +1700,10 @@ async def _check_completions():
)
store.set_stage(job["id"], "done")
elif status == "failed":
detail = (f": {item.get('error_message')}" if item.get("error_message") else ".")
# Bambuddy rarely fills error_message; the *why* usually sits in the
# printer's HMS codes, which are still standing at that moment.
detail = (f": {item.get('error_message')}" if item.get("error_message")
else _hms_detail(pstatus, lang) or ".")
await signal_client.send_to_group(
job["group_id"],
i18n.t(lang, "completion_failed", name=job["model_name"], detail=detail),
Expand Down
175 changes: 175 additions & 0 deletions hms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Translate the printer's error numbers into sentences a human can act on.

Bambuddy hands us ``hms_errors`` entries that carry **no description at all**
(schema: ``code``/``attr``/``module``/``severity``/``actions``/``full_code``) —
so the bot used to relay something like „⚠️ Fehler: 0x4003", which tells nobody
anything. The sentence behind a code lives in Bambu's public catalogue (the one
Bambu Studio queries); we bundle it under ``hms_data/`` and look it up offline,
see ``scripts/refresh_hms_codes.py``.

Two code namespaces, both delivered in the same ``hms_errors`` list:

* **HMS** — 16 hex chars, ``f"{attr:08X}{code:08X}"``, printed as
``0700-8004-0002-0001``. Encodes the module *and* which AMS/slot is meant, so
the catalogue text names the actual slot.
* **print_error** — 8 hex chars, printed as ``0500-8061``. Same list, shorter
key, its own catalogue table.

Everything here is defensive on purpose: this runs inside the completion poller,
where one exception kills that cycle for *every* tracker, not just the entry
that was malformed.
"""
import functools
import gzip
import json
import logging
import os
import re

import i18n

log = logging.getLogger("hms")

_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hms_data")
_HEX = re.compile(r"[^0-9A-Fa-f]")

# Severity as Bambuddy reports it ((attr >> 8) & 0xF). Bambu's own scale:
# 1 fatal, 2 serious, 3 common, 4 info.
_SEVERITY_EMOJI = {1: "🛑", 2: "⚠️", 3: "⚠️", 4: "ℹ️"}
# Fallback when severity isn't reported: the error code's top nibble carries it
# (0x4xxx fatal, 0x8xxx serious/warning, 0xCxxx prompt) — see Bambuddy's parser.
_NIBBLE_SEVERITY = {"4": 1, "8": 2, "C": 4}


@functools.lru_cache(maxsize=4)
def _table(lang):
"""{'hms': {code: text}, 'err': {code: text}} for a language, or empty dicts.

Cached: the file is ~4500 entries and gets hit once per poll per error."""
path = os.path.join(_DATA, f"hms_{i18n.normalize(lang)}.json.gz")
try:
with gzip.open(path, "rt", encoding="utf-8") as fh:
data = json.load(fh)
return {"hms": data.get("hms") or {}, "err": data.get("err") or {}}
except Exception:
log.warning("HMS catalogue %s unavailable — codes stay unexplained", path,
exc_info=True)
return {"hms": {}, "err": {}}


def _hexkey(value):
"""'0x4003' / '0500-4003' / 4003 → bare uppercase hex, or '' if nothing usable."""
if isinstance(value, int):
return f"{value:X}"
text = str(value or "").strip()
if text[:2].lower() == "0x":
text = text[2:]
text = _HEX.sub("", text)
return text.upper()


def _fields(entry):
"""(full_code, severity, actions) from one ``hms_errors`` element.

Accepts every shape seen in the wild: Bambuddy's dict, a dict carrying only
the legacy ``code``/``hms_code``, or a bare string. ``full_code`` is the
catalogue key — reconstructed from ``attr`` + ``code`` when Bambuddy omits
it, since ``code`` alone is just the low 32 bits."""
if not isinstance(entry, dict):
return _hexkey(entry), None, []
full = _hexkey(entry.get("full_code"))
code, attr = entry.get("code"), entry.get("attr")
if len(full) not in (8, 16):
if isinstance(attr, int) and code is not None and _hexkey(code):
full = f"{attr:08X}{int(_hexkey(code), 16):08X}"
else:
full = _hexkey(code or entry.get("hms_code") or entry.get("attr"))
severity = entry.get("severity")
actions = entry.get("actions")
return full, (severity if isinstance(severity, int) else None), \
[str(a) for a in actions] if isinstance(actions, list) else []


def _display(full_code):
"""'07008004' → '0700-8004'; 16-char codes get the four-group form."""
if len(full_code) in (8, 16):
return "-".join(full_code[i:i + 4] for i in range(0, len(full_code), 4))
return full_code or "?"


def _text(full_code, lang):
"""The catalogue sentence for a code, or '' when it isn't listed."""
table = _table(lang)
if len(full_code) == 16:
return table["hms"].get(full_code, "")
if len(full_code) == 8:
# print_error lives in the short table; a few short codes only exist on
# the HMS side, so try both before giving up.
return table["err"].get(full_code) or table["hms"].get(full_code, "")
return ""


def _severity(full_code, reported):
if reported in _SEVERITY_EMOJI:
return reported
# The error half of the code (low 16 bits) starts with the severity nibble.
nibble = full_code[8:9] if len(full_code) == 16 else full_code[4:5]
return _NIBBLE_SEVERITY.get(nibble.upper(), 2)


def describe(entry, lang="de"):
"""One printable line: '⚠️ 0700-8004 — AMS A Slot 1: Filament ist alle …'.

Falls back to a bare code line when the catalogue doesn't know it — the code
is still worth showing, it's what the Bambu wiki and support ask for."""
lang = i18n.normalize(lang)
try:
full, reported, _ = _fields(entry)
# An explicit description (older Bambuddy builds, tests) wins over the
# catalogue: it's what this specific installation decided to say.
text = ""
if isinstance(entry, dict):
text = str(entry.get("description") or entry.get("desc")
or entry.get("message") or "").strip()
text = text or _text(full, lang)
emoji = _SEVERITY_EMOJI.get(_severity(full, reported), "⚠️")
code = _display(full)
if not text:
return f"{emoji} {code} — {i18n.t(lang, 'hms_unknown')}"
return f"{emoji} {code} — {text}"
except Exception:
log.warning("could not describe HMS entry %r", entry, exc_info=True)
return f"⚠️ {i18n.t(lang, 'hms_unknown')}"


def entries(pstatus):
"""The raw ``hms_errors`` list, tolerating any junk in that field."""
try:
raw = (pstatus or {}).get("hms_errors")
except Exception:
return []
return list(raw) if isinstance(raw, (list, tuple)) else []


def codes(pstatus):
"""Sorted canonical codes — the identity used to decide 'is this the same
incident as last poll?'. Empty when the printer is healthy."""
try:
found = {_fields(e)[0] for e in entries(pstatus)}
return sorted(c for c in found if c)
except Exception:
log.warning("could not read hms_errors", exc_info=True)
return []


def detail(pstatus, lang="de", limit=3):
"""':\\n<line>\\n<line>' for the ``{detail}`` slot of the alert templates, or
'' when nothing is wrong. Capped so a cascade of follow-up codes doesn't
bury the message."""
found = entries(pstatus)
if not found:
return ""
lines = [describe(e, lang) for e in found[:limit]]
if len(found) > limit:
lines.append(i18n.t(i18n.normalize(lang), "hms_more", n=len(found) - limit))
return ":\n" + "\n".join(lines)
Binary file added hms_data/hms_de.json.gz
Binary file not shown.
Binary file added hms_data/hms_en.json.gz
Binary file not shown.
16 changes: 12 additions & 4 deletions i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,10 @@ def normalize(lang):
"en": "⏸️ paused — waiting for you",
},
"progress_hms": {
"de": "⚠️ Fehler{detail}",
"en": "⚠️ error{detail}",
# {detail} already starts with ':' and carries a severity emoji per
# line (see hms.detail) — no second warning sign here.
"de": "Der Drucker meldet{detail}",
"en": "The printer reports{detail}",
},
"progress_awaiting_clear": {
"de": "🧹 Die Platte ist noch voll — schick !go, wenn sie frei ist.",
Expand All @@ -472,8 +474,14 @@ def normalize(lang):
"en": "▶️ „{name}\" is running again.",
},
"hms_unknown": {
"de": "unbekannter Fehler",
"en": "unknown error",
# Shown when Bambu's catalogue doesn't list the code (new firmware).
# The number still goes out — it's what the wiki and support ask for.
"de": "unbekannter Fehlercode (in der Bambu-Wiki nachschlagen)",
"en": "unknown error code (look it up in the Bambu wiki)",
},
"hms_more": {
"de": "… und {n} weitere Meldung(en)",
"en": "… and {n} more message(s)",
},

# ----- !go -----
Expand Down
Loading
Loading