mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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:
@@ -30,6 +30,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest.
|
||||
|
||||
### Fixed
|
||||
- **The layer count stayed empty for a whole print, and First Layer Complete notifications read `1/0` (#2702, reporter @sn8key)** — On a P1S, a job started from Archives or through the Virtual Printer showed no layer information in the Print Status panel, and the Discord **First Layer Complete** notification went out as `1/0` instead of `1/33`. The count usually appeared some minutes into the print, which made it look intermittent, and the reporter noticed it filling in at the exact moment they submitted a bug report. **Root cause.** Bambuddy applies the printer's reported layer total as soon as it arrives, and separately clears that total when it detects a new print starting, so a leftover count from the previous job cannot become the denominator for this job's filament split. Both happen while processing the same status message, in that order — and the message announcing a new print is frequently the same one carrying the new print's layer total, so the value was stored and then immediately discarded. That is not merely late but unrecoverable: Bambu firmware sends only fields whose value has changed, so having published the total once, the printer never mentions it again. It could only come back in a full status refresh, which Bambuddy requests when it connects and when you press Force Refresh, but not during a print. Submitting a bug report happens to trigger one, which is why that appeared to fix it; and an install whose connection drops from time to time recovered by itself within minutes, which is why the fault looked random and why a *stable* connection made it worse rather than better. Everything that shows a layer count reads this single value, so the finish-photo trigger that fires on the last layer and the mid-print filament split were affected the same way. **Fix.** The new-print reset now keeps the total that arrived alongside the starting message, while still discarding anything left over from the previous job. If the starting message carried no total, Bambuddy asks for a full refresh once, and once more if layers are being laid down and there is still no total — by which point the printer certainly knows it. That is at most two extra messages per print, and never a per-layer retry. One incidental fix: an unusable layer total — `null`, or anything that isn't a number — previously raised an error out of the routine that reads status messages. Nothing above that point catches it, so the whole message was thrown away and the connection's listener stopped, leaving the printer to look silent until the staleness check rebuilt the connection. Such a value is now ignored, and the refresh described above then fetches the real total. Covered by tests for the same-message case, the case where the total arrives a message earlier, the previous job's total still being discarded, the one-shot bound, the refresh answer not re-triggering the reset, and the malformed values.
|
||||
- **Support bundles could contain a printer-status file that no tool could open (#2702)** — Every support bundle includes a redacted copy of the printer's last status message, so that a given model and firmware's actual field layout is available when diagnosing a report. For printers whose AMS flow-calibration factor had a particular number of decimal places, that file was not valid JSON. **Root cause.** Redaction ran over the finished text of the file, and one of its patterns recognises Bambu serial numbers by shape — a shape the digits of a decimal number can also take. A calibration value of `0.0199999995529652` came out as `0.[SERIAL]`, which is not a number, so the file failed to parse from that point on. **Fix.** Redaction now walks the structure and rewrites text values only, leaving numbers, true/false and empty values exactly as the printer reported them, so the file always parses. Nothing is redacted less thoroughly than before. Covered by tests using the value from the bundle that exposed this, and for the walk leaving keys, booleans and nested containers alone.
|
||||
- **Setting Spoolman options over the API with a true/false value returned a server error** — `PUT /settings/spoolman` accepts a free-form body, and sending the natural JSON form for a switch — `{"spoolman_enabled": true}` rather than `{"spoolman_enabled": "true"}` — came back as a 500 with nothing useful in it. The shipped UI always sends strings, so this only affected people driving Bambuddy from a script or a Home Assistant `rest_command`, which is exactly where a real boolean is the obvious thing to send. **Root cause.** Settings are stored as text and every reader compares them as text, but the submitted value went in untouched. Deciding whether Spoolman had just been switched on called a string operation on it, which a boolean does not have; and the raw boolean was also written straight to a text column, which SQLite quietly turns into 1/0 while PostgreSQL refuses it outright — so the stored result depended on which database the install used. **Fix.** Boolean-ish settings are now converted to a canonical `true`/`false` on the way in, accepting real booleans, `1`/`0`, and the usual spellings (`True`, `yes`, `on`) case-insensitively, since this is a documented API that scripts talk to. A value with no sensible reading, such as `"banana"`, now returns a 400 naming the field instead of being stored as-is and silently treated as off. Two details are preserved deliberately: a blank value still means "use the default" for the two options that default to on, and reading a stored value stays as strict as it has always been elsewhere in the codebase, so no existing row changes meaning. Text options are checked too, so a JSON object can no longer be stored as its own printed form. One incidental improvement: a value stored as `True` by an earlier API call showed as off in the UI, which compares case-sensitively, while the backend treated it as on — canonical storage removes that disagreement. Covered by tests across the accepted spellings, the rejected values, the blank-means-default behaviour, and the read path.
|
||||
- **External-camera timelapses and finish photos came out empty when the live view was open (#2707, reporter @bitbarista)** — On a printer with an external camera, watching the live view while a print ran meant the layer timelapse recorded almost nothing and the finish-photo notification went out with no image attached. The reporter measured zero of 87 layer captures on one print and zero of 105 on another, both watched from start to finish. A USB camera allows exactly one program to hold it open, so every capture taken during a live view failed outright rather than merely degrading. **Root cause.** Bambuddy already knew not to do this for the printer's built-in camera: a snapshot taken while somebody is watching reuses the viewer's frame instead of opening a second connection (#1348, #1271). That rule was never extended to the external-camera paths — and could not have been, because the buffered frame it depends on was only ever published by the built-in paths. The live external stream tracked when frames arrived but never kept one, and the stream hands out frames already wrapped for the browser, so there was nothing for a consumer to reuse. **Fix.** The external stream now publishes each frame as it goes past, and every one-shot consumer — layer timelapse, the finish photo and its fallback, the notification snapshot, Obico polling and the plate check — reuses that frame instead of opening a second handle. If a viewer is attached but no frame has arrived yet, that single attempt is skipped rather than competing, because kicking the viewer off is worse than missing one frame. Two things improve as a side effect: `/camera/snapshot` and the finish-photo fallback chain can now serve an external camera's live frame, where before they found an empty buffer, and the buffer is released when the last viewer of that printer leaves. Covered by tests for the frame plumbing, a buffering failure being unable to break the live stream, and each consumer in all three states — viewer with a frame, viewer without one, and nobody watching.
|
||||
- **A long-running camera stream could eventually stall itself, with nothing in the log to explain it (#2707)** — ffmpeg's error output was only read when something had already gone wrong, which meant that for the entire life of a working stream nobody read it. ffmpeg writes a startup banner, its analysis of the incoming video, and then a progress line at a steady rate, and the operating system only buffers a fixed amount of that before it stops the writer. Once that happened ffmpeg would block trying to write the next line, stop producing frames, and the stream's own timeout would fire — reported as `RTSP read timeout` and a reconnect, with no indication that Bambuddy had starved it. How long it takes to reach that point is unmeasured and clearly long: one stream ran 21 minutes 36 seconds without trouble, so this is a limit that was being ignored rather than a fault anyone has reported. **Fix.** A streaming ffmpeg's error output is now read continuously and the most recent portion kept, so the limit cannot be reached. The kept portion is what gets logged when a stream does fail, which is more useful than before: it holds what ffmpeg said as things went wrong, where reading the buffer on demand returned whatever it had printed first — usually the startup banner, which is then discarded as noise. Credentials are masked on this path through the same single funnel as every other camera log. Covered by tests for continuous draining, the size bound, keeping the newest output, credential masking, and the ownership handover with the shutdown path — reading the same output from two places at once is an error, so only one owner reads it at a time.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""The layer total must survive the print-start reset (#2702).
|
||||
|
||||
`_update_state` applies `total_layer_num` early and, further down, resets
|
||||
`total_layers` when it detects a new print (added by #1771 so the previous
|
||||
print's total can't bleed into the next one's usage split). Those two run in
|
||||
the same function on the same frame, so a frame that carried both the new
|
||||
print's total *and* the transition into RUNNING had its total applied and then
|
||||
zeroed.
|
||||
|
||||
That is unrecoverable rather than merely late: Bambu firmware sends only
|
||||
changed fields, so the printer never re-sends a total it already published.
|
||||
The value reappears only in a full pushall — i.e. on reconnect or a manual
|
||||
Force Refresh — which is why the reporter saw `n/0` for nine minutes on a
|
||||
flawless connection, why it looked random, and why a *stable* link made it
|
||||
worse.
|
||||
|
||||
Frames here are trimmed to the fields the code under test reads. No printer is
|
||||
needed: the fix is a property of how one function orders its own writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""A client with a recording stand-in for the MQTT connection."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
c = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TEST123",
|
||||
access_code="12345678",
|
||||
)
|
||||
c._client = MagicMock()
|
||||
# A new print is only detected once a previous state has been observed
|
||||
# (#1304 guard), so give every test a plausible pre-print history.
|
||||
c._previous_gcode_state = "IDLE"
|
||||
c._previous_gcode_file = None
|
||||
c._was_running = False
|
||||
return c
|
||||
|
||||
|
||||
def pushalls(client) -> list[dict]:
|
||||
"""Every pushall published on this client, decoded."""
|
||||
sent = []
|
||||
for call in client._client.publish.call_args_list:
|
||||
payload = json.loads(call.args[1])
|
||||
if payload.get("pushing", {}).get("command") == "pushall":
|
||||
sent.append(payload)
|
||||
return sent
|
||||
|
||||
|
||||
def running_frame(**extra) -> dict:
|
||||
"""A frame that flips the printer into RUNNING with a file — a new print."""
|
||||
return {"gcode_state": "RUNNING", "gcode_file": "widget.3mf", "subtask_name": "widget", **extra}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_total_arriving_with_the_start_frame_survives(client):
|
||||
"""The reported bug: total and transition in one frame lost the total."""
|
||||
client._update_state(running_frame(total_layer_num=33, layer_num=0))
|
||||
|
||||
assert client.state.total_layers == 33
|
||||
|
||||
|
||||
def test_total_arriving_with_the_start_frame_needs_no_pushall(client):
|
||||
"""We already have the denominator, so don't spend a round-trip on it."""
|
||||
client._update_state(running_frame(total_layer_num=33))
|
||||
|
||||
assert pushalls(client) == []
|
||||
assert client._total_layers_refresh_armed is False
|
||||
|
||||
|
||||
def test_previous_prints_total_still_cannot_bleed_through(client):
|
||||
"""#1771's reason for the reset — preserved exactly.
|
||||
|
||||
A start frame with no total of its own must land on 0, never on the
|
||||
finished print's denominator.
|
||||
"""
|
||||
client.state.total_layers = 120 # left over from the print that just ended
|
||||
|
||||
client._update_state(running_frame())
|
||||
|
||||
assert client.state.total_layers == 0
|
||||
|
||||
|
||||
def test_start_frame_without_a_total_asks_the_printer_for_one(client):
|
||||
"""Covers the ordering where the total was published a frame or two early.
|
||||
|
||||
Re-applying this frame's own value can't help there — the value was
|
||||
already consumed and zeroed — so recovery has to come from a pushall,
|
||||
the only message that re-sends unchanged fields.
|
||||
"""
|
||||
client._update_state(running_frame())
|
||||
|
||||
assert len(pushalls(client)) == 1
|
||||
assert client._total_layers_refresh_armed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The one-shot re-request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_first_layer_advance_without_a_total_re_requests_once(client):
|
||||
client._update_state(running_frame())
|
||||
assert len(pushalls(client)) == 1 # from print start
|
||||
|
||||
client._update_state({"layer_num": 1})
|
||||
|
||||
assert len(pushalls(client)) == 2
|
||||
assert client._total_layers_refresh_armed is False
|
||||
|
||||
|
||||
def test_later_layer_advances_do_not_keep_re_requesting(client):
|
||||
"""An unanswered pushall must not become a per-layer retry loop."""
|
||||
client._update_state(running_frame())
|
||||
client._update_state({"layer_num": 1})
|
||||
before = len(pushalls(client))
|
||||
|
||||
for layer in range(2, 12):
|
||||
client._update_state({"layer_num": layer})
|
||||
|
||||
assert len(pushalls(client)) == before
|
||||
|
||||
|
||||
def test_no_re_request_once_the_total_is_known(client):
|
||||
"""The pushall answered: layers advance without further traffic."""
|
||||
client._update_state(running_frame())
|
||||
client._update_state({"total_layer_num": 33}) # the pushall's answer
|
||||
before = len(pushalls(client))
|
||||
|
||||
client._update_state({"layer_num": 1})
|
||||
client._update_state({"layer_num": 2})
|
||||
|
||||
assert client.state.total_layers == 33
|
||||
assert len(pushalls(client)) == before
|
||||
|
||||
|
||||
def test_the_recovered_total_is_what_downstream_reads(client):
|
||||
"""End-to-end on the reporter's sequence, minus the 9-minute wait.
|
||||
|
||||
Start with no total, layers advance at `n/0`, the pushall answers, and
|
||||
from then on the UI, `{total_layers}` notifications and the usage-split
|
||||
denominator all see 33 — they read this one field.
|
||||
"""
|
||||
client._update_state(running_frame())
|
||||
client._update_state({"layer_num": 1})
|
||||
assert client.state.total_layers == 0 # the symptom in the screenshot
|
||||
|
||||
client._update_state({"layer_num": 2, "total_layer_num": 33})
|
||||
|
||||
assert (client.state.layer_num, client.state.total_layers) == (2, 33)
|
||||
|
||||
|
||||
def test_the_pushall_answer_does_not_re_trigger_the_reset(client):
|
||||
"""Loop safety: the answer is a *full* frame, gcode_state and file included.
|
||||
|
||||
If that re-tripped the new-print detection it would reset the total it just
|
||||
delivered and request another pushall, once per round-trip, forever.
|
||||
"""
|
||||
client._update_state(running_frame())
|
||||
assert len(pushalls(client)) == 1
|
||||
|
||||
client._update_state(running_frame(total_layer_num=33, layer_num=1, mc_percent=3))
|
||||
|
||||
assert client.state.total_layers == 33
|
||||
assert len(pushalls(client)) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interaction with the pre-existing firmware-reset guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_firmware_reset_to_zero_mid_print_is_still_ignored(client):
|
||||
"""P1S zeroes total_layer_num at print end; #1771's guard keeps the total."""
|
||||
client._update_state(running_frame(total_layer_num=33))
|
||||
|
||||
client._update_state({"layer_num": 33, "total_layer_num": 0})
|
||||
|
||||
assert client.state.total_layers == 33
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", 0, "0", -1, "abc", "33.7", [], {}, 3.9])
|
||||
def test_unusable_totals_do_not_break_ingest(client, value):
|
||||
"""A bad total must not escape `_update_state`.
|
||||
|
||||
The old parse did a bare ``int(data["total_layer_num"])``. `_on_message`
|
||||
catches only `JSONDecodeError` and paho is left at
|
||||
``suppress_exceptions = False``, so anything this raised was re-raised on
|
||||
the network thread and took the printer connection down over one field.
|
||||
`None`, `[]` and `{}` all did exactly that.
|
||||
"""
|
||||
client._update_state(running_frame(total_layer_num=value))
|
||||
|
||||
assert client.state.total_layers in (0, 3) # 3.9 truncates; the rest are 0
|
||||
assert client.state.gcode_file == "widget.3mf" # the rest of the frame landed
|
||||
|
||||
|
||||
def test_an_unusable_total_does_not_stop_the_layer_counter(client):
|
||||
"""The read happens before the layer block, so it must not be able to raise.
|
||||
|
||||
Otherwise a firmware sending a malformed total would freeze `layer_num` for
|
||||
the whole print — the frame would abort before reaching it.
|
||||
"""
|
||||
client._update_state(running_frame())
|
||||
|
||||
client._update_state({"layer_num": 7, "total_layer_num": "not-a-number"})
|
||||
|
||||
assert client.state.layer_num == 7
|
||||
|
||||
|
||||
def test_a_string_total_is_accepted(client):
|
||||
"""Bambu ships numbers as strings in plenty of other fields."""
|
||||
client._update_state(running_frame(total_layer_num="33"))
|
||||
|
||||
assert client.state.total_layers == 33
|
||||
|
||||
|
||||
def test_a_zero_total_on_the_start_frame_counts_as_no_total(client):
|
||||
"""`total_layer_num: 0` is the firmware's "don't know yet", not a value."""
|
||||
client.state.total_layers = 120
|
||||
|
||||
client._update_state(running_frame(total_layer_num=0))
|
||||
|
||||
assert client.state.total_layers == 0
|
||||
assert len(pushalls(client)) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A restarted print (file change while RUNNING) takes the same path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_file_change_while_running_also_keeps_its_own_total(client):
|
||||
"""`is_file_change` shares the reset, so it needs the same treatment."""
|
||||
client._update_state(running_frame(total_layer_num=33))
|
||||
client._was_running = True
|
||||
|
||||
client._update_state(
|
||||
{"gcode_state": "RUNNING", "gcode_file": "other.3mf", "subtask_name": "other", "total_layer_num": 77}
|
||||
)
|
||||
|
||||
assert client.state.total_layers == 77
|
||||
@@ -1285,3 +1285,169 @@ class TestRedactRawPushStatus:
|
||||
assert _redact_raw_push_status(None) == {} # type: ignore[arg-type]
|
||||
assert _redact_raw_push_status([]) == {} # type: ignore[arg-type]
|
||||
assert _redact_raw_push_status("") == {} # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestSanitizePushStatusValues:
|
||||
"""The bundled push_status snapshot must stay parseable JSON.
|
||||
|
||||
Sanitization used to run over the *serialised* snapshot. The generic
|
||||
Bambu-serial regex in ``log_reader`` (``0[0-3][A-Z0-9][A-Z0-9]{9,13}``)
|
||||
matches the decimal expansion of a float as readily as a serial, so an AMS
|
||||
``k`` flow factor came out as ``0.[SERIAL]`` and the whole file stopped
|
||||
parsing — found in a real bundle while diagnosing #2702, which is exactly
|
||||
the case the snapshot was added to serve.
|
||||
"""
|
||||
|
||||
def test_float_that_matches_the_serial_regex_survives(self):
|
||||
"""The observed reproducer, verbatim."""
|
||||
import json
|
||||
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {"ams": [{"tray": [{"k": 0.0199999995529652}]}]}
|
||||
|
||||
out = _sanitize_push_status_values(raw, {})
|
||||
|
||||
assert json.loads(json.dumps(out)) == raw
|
||||
|
||||
def test_output_always_parses(self):
|
||||
"""Whatever it does to values, the result must be valid JSON."""
|
||||
import json
|
||||
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {
|
||||
"k_values": [0.0199999995529652, 0.019999999552965164, 0.02],
|
||||
"home_flag": 7554487,
|
||||
"sdcard": True,
|
||||
"resolution": "",
|
||||
"nozzle": None,
|
||||
}
|
||||
|
||||
json.loads(json.dumps(_sanitize_push_status_values(raw, {})))
|
||||
|
||||
def test_still_redacts_strings(self):
|
||||
"""The point of the pass is not lost — string values are sanitized."""
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {"tag_uid": "0123456789ABCDEF", "name": "Martin's P1S", "ip": "192.168.1.50"}
|
||||
|
||||
out = _sanitize_push_status_values(raw, {"Martin's P1S": "[PRINTER]"})
|
||||
|
||||
assert out["tag_uid"] == "[SERIAL]"
|
||||
assert out["name"] == "[PRINTER]"
|
||||
assert out["ip"] == "[IP]"
|
||||
|
||||
def test_walks_nested_containers(self):
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {"ams": [{"tray": [{"tray_uuid": "0123456789ABCDEF"}]}]}
|
||||
|
||||
out = _sanitize_push_status_values(raw, {})
|
||||
|
||||
assert out["ams"][0]["tray"][0]["tray_uuid"] == "[SERIAL]"
|
||||
|
||||
def test_keys_are_left_alone(self):
|
||||
"""Keys are structural — renaming one would break the schema."""
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {"0123456789ABCDEF": 1}
|
||||
|
||||
assert list(_sanitize_push_status_values(raw, {})) == ["0123456789ABCDEF"]
|
||||
|
||||
def test_non_json_scalars_are_sanitized_not_smuggled(self):
|
||||
"""``json.dumps(default=str)`` runs after this pass, so do it here."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {"seen_at": datetime(2026, 7, 29, 23, 12, 40, tzinfo=timezone.utc), "who": object()}
|
||||
|
||||
out = _sanitize_push_status_values(raw, {"2026-07-29": "[WHEN]"})
|
||||
|
||||
assert out["seen_at"].startswith("[WHEN]")
|
||||
assert isinstance(out["who"], str)
|
||||
|
||||
def test_bools_stay_bools(self):
|
||||
"""`isinstance(True, int)` — a bool must not fall through to str()."""
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
out = _sanitize_push_status_values({"sdcard": True, "force_upgrade": False}, {})
|
||||
|
||||
assert out["sdcard"] is True
|
||||
assert out["force_upgrade"] is False
|
||||
|
||||
def test_the_full_bundle_chain_on_a_real_p1s_payload(self):
|
||||
"""The route's transform, end to end, on the shape from the #2702 bundle.
|
||||
|
||||
`_redact_raw_push_status` then `_sanitize_push_status_values` then
|
||||
`json.dumps(default=str)` — the composition the bundle writer applies.
|
||||
The bundle that exposed this had five `k` values corrupted, so the
|
||||
snapshot could not be read at all; the field the report was about
|
||||
(`total_layer_num`) was sitting in it, intact and unreachable.
|
||||
"""
|
||||
import json
|
||||
|
||||
from backend.app.api.routes.support import (
|
||||
_redact_raw_push_status,
|
||||
_sanitize_push_status_values,
|
||||
)
|
||||
|
||||
raw = {
|
||||
"gcode_file": "AMS_Filament_Clip_3MF.3mf",
|
||||
"layer_num": 2,
|
||||
"total_layer_num": 33,
|
||||
"home_flag": 7554487,
|
||||
"sdcard": True,
|
||||
"net": {"info": [{"ip": "192.168.1.50", "mask": 0}]},
|
||||
"ams": {
|
||||
"ams": [
|
||||
{
|
||||
"id": "0",
|
||||
"humidity": "5",
|
||||
"tray": [
|
||||
{"id": "0", "k": 0.0199999995529652, "tag_uid": "0123456789ABCDEF"},
|
||||
{"id": "1", "k": 0.0209999997168779, "tag_uid": "44F782D000000100"},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
snapshot = {
|
||||
"model": "P1S",
|
||||
"firmware_version": "01.10.00.00",
|
||||
"raw_data": _redact_raw_push_status(raw),
|
||||
}
|
||||
text = json.dumps(_sanitize_push_status_values(snapshot, {}), indent=2, default=str)
|
||||
|
||||
parsed = json.loads(text) # used to raise "Expecting ',' delimiter"
|
||||
trays = parsed["raw_data"]["ams"]["ams"][0]["tray"]
|
||||
assert [t["k"] for t in trays] == [0.0199999995529652, 0.0209999997168779]
|
||||
assert parsed["raw_data"]["total_layer_num"] == 33
|
||||
# Redaction still did its job on both fronts.
|
||||
assert "gcode_file" not in parsed["raw_data"]
|
||||
assert trays[0]["tag_uid"] == "[SERIAL]"
|
||||
# The structural pass replaces the printer's LAN address with the
|
||||
# sentinel 0.0.0.0, which is itself an IPv4 literal, so the value pass
|
||||
# then masks it to [IP]. Harmless — the real address is already gone —
|
||||
# and matches what shipped in the bundle behind #2702.
|
||||
assert parsed["raw_data"]["net"]["info"][0]["ip"] == "[IP]"
|
||||
|
||||
def test_does_not_mutate_the_live_snapshot(self):
|
||||
"""`state.raw_data` is read by the dispatcher on every tick.
|
||||
|
||||
The bundle writer passes a redacted copy, but a walker that mutated in
|
||||
place would still be one refactor away from redacting the live state.
|
||||
"""
|
||||
import copy
|
||||
|
||||
from backend.app.api.routes.support import _sanitize_push_status_values
|
||||
|
||||
raw = {"tag_uid": "0123456789ABCDEF", "ams": [{"tray": [{"k": 0.02, "n": "0123456789ABCDEF"}]}]}
|
||||
before = copy.deepcopy(raw)
|
||||
|
||||
out = _sanitize_push_status_values(raw, {})
|
||||
|
||||
assert raw == before, "input was mutated"
|
||||
assert out["tag_uid"] == "[SERIAL]" # and the copy really was redacted
|
||||
|
||||
Reference in New Issue
Block a user