fix(mqtt): keep the layer total that arrives with the print-start frame (#2702)

fix(support): redact push_status values, not the serialised JSON (#2702)
This commit is contained in:
maziggy
2026-08-02 09:41:19 +02:00
5 changed files with 536 additions and 20 deletions
+36 -6
View File
@@ -1227,6 +1227,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
return out
def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
"""Sanitize a push_status snapshot's string *values*, never its JSON text.
This used to run :func:`sanitize_log_content` over the serialised snapshot.
That pass includes a generic Bambu-serial regex
(``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
shipped invalid JSON — unusable for exactly the ground-truth purpose the
snapshot exists for (found while diagnosing #2702).
Walking the structure instead leaves numbers, bools and None untouched, so
the output always parses. Keys are structural and never rewritten.
"""
if isinstance(node, str):
return sanitize_log_content(node, sensitive_strings)
if isinstance(node, dict):
return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
if isinstance(node, list | tuple):
# Tuples too: `json.dumps` renders them as arrays, so stringifying one
# here would change the file's shape rather than just its content.
return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
if node is None or isinstance(node, bool | int | float):
return node
# Anything else (datetime, Decimal, …) would be stringified by json.dumps'
# ``default=str`` *after* this pass and so escape sanitisation entirely.
return sanitize_log_content(str(node), sensitive_strings)
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
"""Get recent log lines, sanitized for inclusion in bug reports."""
# Collect sensitive strings from DB for redaction
@@ -1300,12 +1329,13 @@ async def generate_support_bundle(
"captured_at": datetime.now(timezone.utc).isoformat(),
"raw_data": redacted,
}
# Belt-and-suspenders: pass the JSON text through the string-based
# sanitizer so any user-named string (printer name, serial baked
# into a tray uuid) the structural pass missed still gets caught.
snapshot_json = json.dumps(snapshot, indent=2, default=str)
snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
# Belt-and-suspenders: pass every string value through the
# string-based sanitizer so any user-named string (printer name,
# serial baked into a tray uuid) the structural pass missed still
# gets caught. Values only — sanitizing the serialised JSON text
# corrupted numeric literals (see _sanitize_push_status_values).
snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
# Add log file
# Off the event loop: this reads up to 10 MB and then runs one full regex
+77 -14
View File
@@ -736,6 +736,12 @@ class BambuMQTTClient:
# and the FINISH-state fallback don't both fire on the same
# print. Reset to False on every print start.
self._finish_photo_captured: bool = False
# #2702: one-shot re-request of the layer total. Armed at print start
# when the starting frame carried no `total_layer_num`, spent on the
# first layer advance that still has no denominator. Bambu firmware
# only re-sends *changed* fields, so a total we never received (or
# dropped) is only recoverable via a full pushall.
self._total_layers_refresh_armed: bool = False
# #2547 end-of-print telemetry probe state. `_armed` is cleared once the
# window has run for a print so a late FINISH re-send can't reopen it.
self._eop_probe_armed: bool = True
@@ -2993,6 +2999,29 @@ class BambuMQTTClient:
f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
)
self.state.mc_print_sub_stage = new_sub_stage
# Positive `total_layer_num` carried by *this* frame, or 0. Read up
# front because three places below consult it and they run in an order
# that is not the order they read most naturally in: the layer-advance
# refresh (#2702) must not fire on a frame that already answers it, the
# apply step must ignore firmware-reset 0s (#1771), and the new-print
# reset must not discard a total that belongs to the starting print.
total_from_this_frame = 0
if "total_layer_num" in data:
try:
total_from_this_frame = max(int(data["total_layer_num"] or 0), 0)
except (TypeError, ValueError):
# Must not escape. `_on_message` catches only JSONDecodeError
# and paho is left at `suppress_exceptions = False`, so an
# exception raised here is re-raised on the network thread and
# takes the printer connection down over one unusable field.
# Treat it as "not reported": the refresh below then recovers
# the real total from a pushall.
logger.debug(
"[%s] ignoring unusable total_layer_num: %r",
self.serial_number,
data["total_layer_num"],
)
if "layer_num" in data:
new_layer = int(data["layer_num"])
old_layer = self.state.layer_num
@@ -3003,6 +3032,25 @@ class BambuMQTTClient:
# Trigger layer change callback if layer increased
if new_layer > old_layer and self.on_layer_change:
self.on_layer_change(new_layer)
# #2702: the print is demonstrably laying down layers but we still
# have no denominator, so the pushall requested at print start
# either went unanswered or raced the printer learning the total.
# Ask once more — by layer 1 the printer definitely knows it.
# One-shot: an unanswered pushall must not turn into a per-layer
# retry loop for the rest of the print.
if (
new_layer > old_layer
and self._total_layers_refresh_armed
and not self.state.total_layers
and not total_from_this_frame
):
self._total_layers_refresh_armed = False
logger.debug(
"[%s] layer %s with no total_layer_num — re-requesting full status",
self.serial_number,
new_layer,
)
self._request_push_all()
# #1867 last-layer finish-photo trigger. A1 Mini (and other
# firmware variants) skips `stg_cur=22`, so the fallback fires
# at gcode_state=FINISH — which runs AFTER user End G-code
@@ -3032,15 +3080,12 @@ class BambuMQTTClient:
"timelapse_was_active": self._timelapse_during_print,
}
)
if "total_layer_num" in data:
# Some firmware (P1S observed) resets `total_layer_num` to 0 at
# print end — same shape as the `layer_num` reset guarded above.
# Preserve the last known good value so the usage-tracker split
# path (#1771) has a denominator that survives the reset frame.
# Explicit reset to 0 happens on print start (`_handle_print_start`).
new_total = int(data["total_layer_num"])
if new_total > 0:
self.state.total_layers = new_total
if total_from_this_frame:
# Firmware (P1S observed) resets `total_layer_num` to 0 at print
# end — same shape as the `layer_num` reset guarded above. Applying
# only positive values preserves the last known good denominator so
# the usage-tracker split path (#1771) survives the reset frame.
self.state.total_layers = total_from_this_frame
# Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
# Convert to 0-100 percentage for display
@@ -4152,11 +4197,29 @@ class BambuMQTTClient:
# Reset layer tracking for new print (needed for layer-based timelapse)
self.state.layer_num = 0
# Reset total_layers so the previous print's value can't bleed into
# this print's usage-tracker split before the new push_status arrives
# with the slicer's total (#1771 follow-on to the preservation guard
# above at line ~2135 — the guard now ignores firmware-reset 0s, so
# the explicit reset has to happen here instead).
self.state.total_layers = 0
# this print's usage-tracker split (#1771 follow-on to the
# preservation guard at the `total_layer_num` parse above — that
# guard ignores firmware-reset 0s, so the explicit reset has to
# happen here instead).
#
# #2702: reset to *this frame's* total, not to 0. The frame that
# trips the new-print detection can carry the new print's
# `total_layer_num` as well — the parse above has already applied
# it, and zeroing unconditionally threw it away. That looked
# harmless but is not recoverable: Bambu firmware sends only
# changed fields, so the printer never offers the total again, and
# the print runs to completion at `n/0` in the UI, in
# `{total_layers}` notifications, and as the usage-split
# denominator. The value only reappears on the next full pushall
# (reconnect / Force Refresh), which is why the symptom looked
# random and why a *stable* connection made it worse.
self.state.total_layers = total_from_this_frame
# If the starting frame brought no total, ask for one. Costs one
# MQTT message per print and covers the ordering where the printer
# published the total a frame or two before the state flip.
self._total_layers_refresh_armed = not total_from_this_frame
if self._total_layers_refresh_armed:
self._request_push_all()
# Reset completion tracking for new print
self._was_running = True
self._completion_triggered = False