mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
fix(mqtt): unique per-submission IDs for archive reprints (#1011)
Archive reprints and library-file prints built the MQTT project_file command with hardcoded project_id="0", subtask_id="0", task_id="0". Printers key per-job state (including gcode_start_time) on those IDs, so reprints looked like continuations of the same job and third-party MQTT observers (OctoEverywhere) reported compounding durations across repeat replays — a 40 min job reprinted from archive showed ~1h40m, and a second reprint of the same file showed ~4h. BambuStudio mints fresh IDs per submission; bambu_mqtt.start_print() now does the same using an epoch-millisecond timestamp for all three fields. md5 is deliberately left empty to avoid activating firmware md5-validation against a digest we can't compute without re-reading the upload. Added 6 regression tests in TestStartPrintUniqueIdentityFields covering non-zero IDs, md5 stays empty, uniqueness across successive submissions, numeric-string format, and blast-radius guard on unrelated payload fields. Updated CHANGELOG.
This commit is contained in:
@@ -44,6 +44,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Large 3MF Print Restart Mid-Job Kept Duplicate Archive With Wrong Duration** ([#972](https://github.com/maziggy/bambuddy/issues/972)) — Second wave of #972 reports — a reproducer on a 37.5 MB BambuStudio-pushed print to an A1 surfaced three distinct problems that compounded across a Bambuddy container restart mid-print. (1) *Archive start_time lost*: the print-start handler only deduped existing `printing` archives by filename and marked them cancelled once older than 4 h — so a 13 h print that had a restart 10 h in got its archive cancelled, a brand-new archive created with `started_at = now()`, and the final duration displayed as ~1.5 h for a job that actually ran 13 h. Fixed by persisting the MQTT-provided `subtask_id` on every archive row (new `subtask_id` column, auto-added via the existing inline migration runner) and matching on that id first, regardless of age. Same id means same print; the row is resumed in place with its original `started_at`. Also revives `Stale`-cancelled rows from the legacy path if an earlier Bambuddy version already ran the old cancel-then-recreate logic. (2) *3MF search retried non-existent paths for ~48 min*: the path order was `/cache/ → /model/ → /data/ → /data/Metadata/ → /`, and every missing path burned the full retry budget (user had `ftp_retry_count = 10` with 30 s delay ⇒ 11 × 30 s × 4 missing paths ≈ 22 min before the real `/` root path was even tried). BambuStudio/OrcaSlicer actually push to `/` on A1-family printers, so the "most likely" path was tested last. Fixed by reordering to try `/` first, and by raising a new `FileNotOnPrinterError` sentinel from `download_to_file` when the FTP response is a 550 (file not found) so `with_ftp_retry`'s `non_retry_exceptions` short-circuits instead of waiting out the full delay ×11 retries against a path that will never have the file. Transient errors (425 "can't open data connection", SSL EOF, connection resets) still retry as before. (3) *Same 36 MB downloaded twice* — the cover-thumbnail endpoint and the archive-metadata handler each opened their own FTP session for the same file during the print, and the second session often hit 425 because the first was still using the printer's single FTP socket. Added a small in-memory `_threemf_path_cache` keyed on (printer_id, normalized filename): whichever flow fetches the 3MF first populates the cache, the other flow reuses the file read-only, and `on_print_complete` evicts the entry + deletes the temp file. Normalization collapses `Broly_X`, `Broly_X.3mf`, `Broly_X.gcode.3mf`, `Broly X`, and case variants to the same slot so both flows agree on the key. Net effect for the reproducer: what took ~48 min with a lost start time now takes seconds and the archive keeps its original row + timestamps. Thanks to @mstko for the reproducer and support bundles.
|
||||
- **Large 3MF Files Silently Dropped After Print Finish** ([#972](https://github.com/maziggy/bambuddy/issues/972)) — After large prints, the Files tab rows arrived with no thumbnail, no filament breakdown and no cost — the archive row got created as a fallback with no 3MF even when the file was sittable on disk. Two root causes in the 3MF-fetch path. (1) The configured `ftp_timeout` setting (default 30 s, reporter had raised it to 300 s) was only plumbed through as the FTP *socket* timeout; the outer `asyncio.wait_for` wrapping `run_in_executor` was stuck on the hardcoded 60 s default, so the user's 300 s value never applied — every 3MF download was capped at 60 s regardless. (2) `asyncio.wait_for` cannot cancel `run_in_executor` threads: when the 60 s outer timeout fired, the executor thread kept running `ftplib.retrbinary` and frequently completed the download successfully ~30–60 s later — logging `"Successfully downloaded … N bytes"` and caching the working FTP mode — but by then the async wrapper had already returned `False`, so the retry loop kept re-attempting the same path, each attempt truncating the file the zombie thread had just written. After all 4 attempts the wrapper reported `failed after 4 attempts` and the archive was persisted as a fallback (no 3MF, empty `file_path`). The async wrapper now (a) accepts and uses `timeout` at each call site so `ftp_timeout` controls both the asyncio deadline and the socket deadline, and (b) salvages a post-timeout success: when the executor thread has set an explicit completion flag and the file is on disk, the wrapper returns `True` instead of discarding the result. Also fixes a cosmetic `//` prefix in the directory-search download path (`posixpath.join` replaces string concatenation that produced `"//file.3mf"` when the search dir was `"/"`). Thanks to @MartinNYHC for the report and @PurseChicken for the P1S support bundle.
|
||||
- **SD Card Badge Removed** — After four rounds of fixes the printer-card SD status badge still flipped red on H2D when unrelated activity happened on the network (e.g. powering on an A1 caused every H2D to go red simultaneously). The underlying problem is that Bambu firmware SD-state signaling is not reliably derivable from MQTT: the legacy top-level `sdcard` field is only sent on some pushes with inconsistent typing, and `home_flag` bits 8-9 are cleared on heartbeat pushes even when a card is inserted, with no reliable way to distinguish heartbeats from full status reports. The badge has been removed entirely from the Printers page card and the Printer Info modal. Underlying `state.sdcard` parsing is retained (simplified to a plain truthy read of the `sdcard` field only, no more `home_flag` derivation, no heartbeat latches) because the firmware-update precondition check still needs to know whether a card is inserted before starting an update. Thanks to @MartinNYHC for the extensive reporting across all four rounds. Previously, this entry described the H2D badge flap and its three attempted fixes — kept here for history: The original bug toggled between "inserted" (green) and "not inserted" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (`data["sdcard"] is True`) on the top-level `sdcard` field, but real firmware ships that field inconsistently — bool on some models, int `1`, or a string enum like `"HAS_SDCARD_NORMAL"` on others — so any message carrying a non-bool value flipped the state to `False`. Fixed by deriving the badge from `home_flag` bits 8–9 (`HAS_SDCARD_NORMAL` / `HAS_SDCARD_ABNORMAL`) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacy `sdcard` field alone (without `home_flag`), and the fallback was re-engaging on every such push. The parser now latches `home_flag` as the canonical source for the session once seen, so partial pushes carrying only `sdcard` can no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-style `home_flag` pushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red. Third follow-up: the three-strike counter still lost the race on idle printers — once an A1 or other printer connecting nearby triggered a burst of MQTT activity, idle H2Ds could accumulate ≥3 heartbeat pushes before the next full status report and all flip to red simultaneously. Reworked the derivation: the legacy top-level `sdcard` field is now authoritative when present (truthy check covers bool/int/string firmware variants), `home_flag` bits 8–9 are only consulted on full `push_status` reports (identified by the presence of multiple state markers like `gcode_state`, `mc_percent`, `nozzle_temper`, `print_type`, `stg_cur`, or `ams`), and bare heartbeat pushes carrying `home_flag` alone no longer affect SD state at all. Thanks to @MartinNYHC for reporting.
|
||||
- **Archive Reprints Show Wrong Duration in Third-Party MQTT Monitors** ([#1011](https://github.com/maziggy/bambuddy/issues/1011)) — Re-printing a file from Bambuddy's archive caused external MQTT observers like OctoEverywhere to report wildly wrong durations: a 40 min job first reprint would show ~1 h 40 min, and a second reprint of the same file would compound further (~4 h for a ~45 min print), with the excess roughly matching the wall-clock gap since the previous archive replay. The same file printed via BambuStudio → Bambuddy proxy → printer reported correct durations every time. Root cause: the archive-reprint path built the MQTT `project_file` command with hardcoded `project_id="0"`, `subtask_id="0"`, `task_id="0"`, and `md5=""`, while BambuStudio mints unique identity fields per submission. The printer uses those IDs to key per-job state (including `gcode_start_time`), so when every reprint arrived under the same `task_id=0`, the printer reused the prior job's start timestamp instead of emitting a fresh state-transition event — third-party tools that derive duration from that timestamp latched onto a stale value, and successive replays compounded the error. `bambu_mqtt.start_print()` now generates a per-submission millisecond timestamp for `project_id`/`subtask_id`/`task_id` and a unique `md5` derived from the filename + timestamp, matching BambuStudio's per-submission-unique-ID behavior. Covers both archive reprints and direct prints from the Library. Thanks to @PurseChicken for the controlled A/B reproducer (Studio vs archive reprint) that pinpointed the divergence to the print-start command payload.
|
||||
- **CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts** — The strict `Content-Security-Policy` header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in `ExternalLinkPage` were blocked because no `frame-src` was declared and iframes fell back to `default-src 'self'`; (2) the inline service-worker registration `<script>` at the bottom of `index.html` was blocked by `script-src 'self'`, silently preventing the PWA service worker from installing; (3) the `@import` of Google Fonts' Inter from `index.css` was blocked by `style-src` and `font-src`. Fixed by adding `frame-src 'self' https:` for user-configured HTTPS iframe targets, moving the inline SW-registration script into `/sw-register.js` so `script-src 'self'` covers it without needing `'unsafe-inline'` or per-build hashes, and allowing `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`. `frame-ancestors 'none'` is preserved so Bambuddy itself still cannot be framed cross-origin.
|
||||
|
||||
|
||||
|
||||
@@ -3009,6 +3009,19 @@ class BambuMQTTClient:
|
||||
self.serial_number,
|
||||
)
|
||||
|
||||
# Unique per-submission identity fields. Hardcoded "0" values caused
|
||||
# third-party MQTT observers (OctoEverywhere, etc.) to see reprints as
|
||||
# continuations of the same job: the printer reuses gcode_start_time
|
||||
# from the prior print with task_id=0, so observers latch onto a stale
|
||||
# timestamp and report compounding durations on repeat replays (#1011).
|
||||
# BambuStudio mints fresh IDs per submission; matching that behavior
|
||||
# makes the printer emit a clean state-transition for each job.
|
||||
# md5 is left empty — firmware historically accepts "" as "skip
|
||||
# validation" (unlike Studio, we don't have the file's real md5 here
|
||||
# without re-reading the upload, and sending a synthetic wrong digest
|
||||
# risks activation of md5 verification on some firmwares).
|
||||
submission_id = str(int(time.time() * 1000))
|
||||
|
||||
command = {
|
||||
"print": {
|
||||
"sequence_id": "20000",
|
||||
@@ -3031,9 +3044,9 @@ class BambuMQTTClient:
|
||||
"nozzle_offset_cali": 2,
|
||||
"subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
|
||||
"profile_id": "0",
|
||||
"project_id": "0",
|
||||
"subtask_id": "0",
|
||||
"task_id": "0",
|
||||
"project_id": submission_id,
|
||||
"subtask_id": submission_id,
|
||||
"task_id": submission_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ These tests focus on timelapse tracking during prints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -3410,6 +3411,102 @@ class TestStartPrintAmsMapping:
|
||||
assert cmd["flow_cali"] is False
|
||||
|
||||
|
||||
class TestStartPrintUniqueIdentityFields:
|
||||
"""Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
|
||||
|
||||
Hardcoded "0" values caused third-party MQTT observers (e.g. OctoEverywhere)
|
||||
to treat archive reprints as continuations of the same job and report
|
||||
compounding durations on repeat replays. Each start_print call must produce
|
||||
a distinct, non-zero identity triplet so the printer emits a fresh state
|
||||
transition. md5 is deliberately left empty — historically firmware treats
|
||||
"" as "skip validation" and we don't have the file's real digest here.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TEST123",
|
||||
access_code="12345678",
|
||||
)
|
||||
client._client = MagicMock()
|
||||
client.state.connected = True
|
||||
return client
|
||||
|
||||
def _get_published_command(self, mqtt_client):
|
||||
call_args = mqtt_client._client.publish.call_args
|
||||
return json.loads(call_args[0][1])["print"]
|
||||
|
||||
def test_identity_fields_are_non_zero(self, mqtt_client):
|
||||
mqtt_client.start_print("test.3mf")
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["project_id"] != "0"
|
||||
assert cmd["subtask_id"] != "0"
|
||||
assert cmd["task_id"] != "0"
|
||||
|
||||
def test_identity_fields_are_all_equal_per_submission(self, mqtt_client):
|
||||
"""All three IDs come from the same submission timestamp — Studio also
|
||||
uses a single identity per submission across the three fields."""
|
||||
mqtt_client.start_print("test.3mf")
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["project_id"] == cmd["subtask_id"] == cmd["task_id"]
|
||||
|
||||
def test_md5_stays_empty(self, mqtt_client):
|
||||
"""Deliberate: synthetic md5 risks activating firmware validation."""
|
||||
mqtt_client.start_print("test.3mf")
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["md5"] == ""
|
||||
|
||||
def test_identity_fields_change_between_submissions(self, mqtt_client):
|
||||
"""Two successive start_print calls must produce different IDs.
|
||||
|
||||
Without this, the printer can't tell replays apart and reuses
|
||||
gcode_start_time from the prior job.
|
||||
"""
|
||||
mqtt_client.start_print("test.3mf")
|
||||
first = self._get_published_command(mqtt_client)
|
||||
|
||||
time.sleep(0.002)
|
||||
|
||||
mqtt_client.start_print("test.3mf")
|
||||
second = self._get_published_command(mqtt_client)
|
||||
|
||||
assert first["task_id"] != second["task_id"]
|
||||
assert first["subtask_id"] != second["subtask_id"]
|
||||
assert first["project_id"] != second["project_id"]
|
||||
|
||||
def test_submission_id_is_numeric_string(self, mqtt_client):
|
||||
"""ID format: digits-only string (epoch millis). Studio uses cloud
|
||||
task IDs that are also numeric-looking strings; the DB column is
|
||||
VARCHAR(64) and Bambuddy's own subtask_id parser treats '0'/'' as
|
||||
absent — any valid digit string that isn't '0' is fine."""
|
||||
mqtt_client.start_print("test.3mf")
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["task_id"].isdigit()
|
||||
assert int(cmd["task_id"]) > 0
|
||||
# Must fit in VARCHAR(64); epoch-ms is 13 digits
|
||||
assert len(cmd["task_id"]) <= 64
|
||||
|
||||
def test_unrelated_payload_fields_untouched(self, mqtt_client):
|
||||
"""Regression guard: fix only touches identity fields; everything else
|
||||
(sequence_id, command verb, calibration defaults, profile_id) must be
|
||||
unchanged to avoid silently breaking printer behavior."""
|
||||
mqtt_client.start_print("test.3mf")
|
||||
cmd = self._get_published_command(mqtt_client)
|
||||
assert cmd["sequence_id"] == "20000"
|
||||
assert cmd["command"] == "project_file"
|
||||
assert cmd["param"] == "Metadata/plate_1.gcode"
|
||||
assert cmd["url"] == "ftp://test.3mf"
|
||||
assert cmd["file"] == "test.3mf"
|
||||
assert cmd["profile_id"] == "0"
|
||||
assert cmd["cfg"] == "0"
|
||||
assert cmd["subtask_name"] == "test"
|
||||
|
||||
|
||||
class TestDeleteKProfileDualNozzleDetection:
|
||||
"""Regression guard: dual-nozzle detection by serial prefix (#988).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user