# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased ### Fixed - **AMS-HT: Custom Filament Preset Reverts to "Generic" in UI After Configure** ([#1053](https://github.com/maziggy/bambuddy/issues/1053)) — After configuring an AMS-HT slot (HT-A/HT-B) with a custom Bambu Cloud preset (e.g. "Devil Design PLA Basic"), the slot card and Configure modal kept showing "Generic PLA" even though the `ams_filament_setting` command succeeded and BambuStudio / the printer's LCD both rendered the correct custom preset. Root cause: the `GET /api/v1/printers/{id}/slot-presets` endpoint keyed its response dict by `ams_id * 4 + tray_id`, which collapses cleanly to the same integer the frontend uses for regular AMS slots (0 through 15) but produces `128 * 4 + 0 = 512` for HT-A — a key nothing looks up. The frontend's PrintersPage HT render path calls `getGlobalTrayId(ams.id, …, false)` which returns the ams_id itself (`128` for HT-A), and SpoolBuddy's AMS page used a third, unrelated formula (`(amsId - 128) * 4 + trayId + 64 = 64`). All three agreed for regular AMS so the mismatch only surfaced on HT, where the saved preset name never reached the UI and the render fell through to `tray.tray_type` → rendered as "Generic PLA". Backend now keys the response via a `_slot_preset_key` helper that mirrors frontend `getGlobalTrayId` (HT → `ams_id`, regular/external → `ams_id * 4 + tray_id`), and SpoolBuddyAmsPage uses the shared `getGlobalTrayId` helper instead of its home-grown formula. Regression test covers the key scheme for regular, HT, and external slots. Thanks to @mrnoisytiger for the detailed reproduction. - **⚠️ Bed-Jog "Home Z" Could Crash the Bed Into the Toolhead** ([#1052](https://github.com/maziggy/bambuddy/issues/1052)) — **Critical safety fix.** On H2C (and by extension any Bambu printer where Z-home moves the bed UP toward an endstop — H2D, H2S, and X1 family all share this kinematics) the bed-jog modal's "Home Z" button sent a raw `G28 Z` over the `gcode_line` MQTT command. Bare `G28 Z` skips the toolhead-park step that a full `G28` runs first, so the bed raised without stopping at a safe height — in the reporter's case the toolhead happened to be parked on the purge chute and no damage was caused, but hitting the button with a toolhead anywhere else would have driven the bed into it at full Z speed. Root cause was the `/api/v1/printers/{id}/home-axes` endpoint's per-axis gcode mapping (`"z" → "G28 Z"`, `"xy" → "G28 X Y"`, `"all" → "G28"`). The endpoint now ignores the `axes` argument entirely and always sends a bare `G28`, which Bambu firmware expands into the safe multi-step sequence (park toolhead → home XY → home Z). The MQTT client helper `BambuClient.home_axes()` has the same change. The bed-jog modal is retitled "Auto Home" and its copy now says "parks the toolhead, then homes X, Y, and Z" so users aren't surprised when X/Y motion happens first. After a successful Auto Home click, the modal no longer re-prompts on the next jog in the same session — the "not homed" warning is gated on a session-scoped acknowledgement flag that was only being set by "Move anyway" and now also fires on successful Auto Home. Regression test covers all three axes arguments producing the same bare `G28`. Thanks to @mikefromdot for catching this with an undamaged retest. - **AMS: Configure / Assign Spool Hidden on Reset Slots, and Assign Spool Missing Matching-Material Inventory** ([#1047](https://github.com/maziggy/bambuddy/issues/1047)) — Two separate symptoms from the same report. (1) After resetting an AMS slot from the printer UI, the Bambuddy printer card showed "Empty Slot" with no Configure or Assign Spool actions on hover, while the same slot in SpoolBuddy's AMS page still let the user re-configure it. Root cause: commit `c9efa4b8` (#784) added a `tray?.state === 10` gate to the `EmptySlotHoverCard` actions, intended to show the buttons only when a spool was physically present but not loaded (state=10) and hide them on truly empty slots (state=9). In practice, firmware often reports `state=9` (or no `state` field at all) after a user-initiated reset — even when a spool is still physically in the slot — so the actions disappeared exactly when the user needed them. The gate is redundant anyway (`EmptySlotHoverCard` is only rendered when the slot has no `tray_type`, so it's definitionally empty from Bambuddy's perspective), and configuring an empty slot is a valid "tell the printer what will be loaded here" operation. The gate is now removed at both the standard-AMS and AMS-HT render paths. (2) After configuring a slot with a Generic profile (e.g. "Devil Design PLA Basic Red"), the Assign Spool modal didn't list the matching inventory spool unless the user enabled the "Show all spools" toggle. Root cause: the filter at `AssignSpoolModal.tsx:144` required `normalizeValue(spool.slicer_filament_name) === normalizeValue(trayInfo.profile)` — manually-added inventory spools typically don't have `slicer_filament_name` populated, so they failed the exact-profile check even when the material matched. The filter now prefers an exact slicer-profile match when both sides advertise one, and falls back to partial material match in either direction (so e.g. a spool with `material="PLA"` is selectable for a slot reporting `"PLA Basic"`) when profile info is missing. (3) Once the matching spool was assignable, a "profile mismatch" confirmation dialog still warned on every assignment because Bambu Studio / OrcaSlicer slicer-profile names carry a printer/nozzle/variant qualifier after `@` (e.g. `"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"`) while the tray stores only the bare base name (`"Devil Design PLA Basic"`), and `checkProfileMatch` compared the full strings. Both the filter and the mismatch check now strip the `@…` qualifier before comparing, so identical base profiles are treated as a match. Regression test covers a spool with no slicer profile being surfaced for a slot whose profile + material are both set. Thanks to @TravisWilder for the report. - **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-pr ## [0.2.3.1] - 2020-04-20 ### Fixed - **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-preview thumbnail inside the Skip Objects modal opened a lightbox that showed a broken-image icon instead of the full-size plate preview. The thumbnail `` wrapped its `src` with `withStreamToken()` (which appends the short-lived camera-stream token to `/api/v1/` URLs that `` tags can't attach an `Authorization` header to), but the enlarged lightbox `` used a bare `${status.cover_url}?view=top` so the browser's unauthenticated request was rejected by the backend. Both images now go through `withStreamToken()`. Thanks to @elit3ge for the report and screenshot. - **P1S Print Dispatches Stuck at IDLE Due to task_id Int32 Overflow** ([#1042](https://github.com/maziggy/bambuddy/issues/1042)) — Since the #1011 fix switched `project_id` / `subtask_id` / `task_id` from hardcoded `"0"` to `str(int(time.time() * 1000))`, each submission sent a 13-digit epoch-millisecond value (~1.7×10¹²). P1S firmware (observed on 01.10.00.00) clamps oversized task identity fields to signed int32 max (`2147483647`), so every dispatch looked identical from the printer's perspective — it treated a fresh print as a continuation of the prior FAILED job, returned `result: success` for `project_file` (command accepted), but then sat at `gcode_state: IDLE` with an empty `gcode_file` instead of transitioning to `PREPARE`/`RUNNING`. Thanks to @EdwardChamberlain for pinpointing the exact line and suggesting the mod fix. The three identity fields are now set to `str(int(time.time() * 1000) % 2_147_483_647 or 1)`: modulo keeps values inside the signed-int31 window with a ~24-day uniqueness cycle (more than enough for reprint deduplication), and `or 1` guards against the astronomically unlikely zero case (the printer rejects `task_id=0`). Regression test `test_submission_id_fits_signed_int32` asserts all three IDs are `< 2**31`. Two of @EdwardChamberlain's other suggestions — resolving `bed_type` from the sliced 3MF's per-plate JSON instead of hardcoding `"auto"`, and gating dispatch success on an actual state transition to `PREPARE`/`RUNNING` rather than on `project_file`'s `result: success` — are larger changes tracked separately. - **FTP Download Zombie-Thread Race on Slow WiFi** ([#1014](https://github.com/maziggy/bambuddy/issues/1014)) — Users on 2.4 GHz WiFi with heavy neighborhood interference saw "Successfully downloaded" log lines for queued prints that Bambuddy nonetheless reported as failed, and the slicer file landed in `/app/data/archives/temp/` with the File Manager unable to find it. Root cause: `download_file_async` wrapped the blocking FTP `RETR` in `asyncio.wait_for` with a 30–60 s timeout (user-configurable via `ftp_timeout`), but the wrapped thread couldn't be cancelled. On a slow link the download would overshoot the timeout by 15–30 s, at which point `_run()` waited a hard-coded 0.5 s for the zombie to finish, gave up, and returned failure — which triggered `with_ftp_retry` attempt 2, whose `_download` spawned a brand-new FTP session that contended with attempt 1's still-running transfer. Attempt 1's zombie eventually completed and wrote the file to disk, but by then attempt 2 (and 3, 4) had long since run out their own timeouts with their own fresh `completion` dicts and reported failure; the archive pipeline saw only the final `None` from `with_ftp_retry` and created a fallback archive row with no 3MF data, which is why Skip-Object couldn't find the plate's objects even though the 3MF was on disk. Two fixes: the 0.5 s post-timeout sleep is replaced with a `threading.Event` the worker sets in its `finally` block, and `_run()` waits for that event with a bounded grace of `max(min(ftp_timeout, 30), 0.5)` s — covering the slow-WiFi overshoot case without extending a genuinely stuck connection indefinitely. The log line now includes the grace window (`timed out after Xs (plus Ys grace)`). Regression test `test_download_file_async_timeout_waits_for_slow_zombie` simulates a 1.5 s zombie with a 1.0 s wait_for timeout; old 0.5 s sleep would give up, new 1.0 s grace salvages. The existing `test_download_file_async_timeout_no_salvage_when_incomplete` still passes — a thread that never completes within the grace window still returns failure. Thanks to @heffe2001 for the detailed reproduction and support logs. - **Obico: Cold-Start Capture Timeout Sticks in Status Banner** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — On the very first detection poll after a restart, the initial RTSP snapshot capture occasionally exceeded the 20 s `SNAPSHOT_CAPTURE_TIMEOUT` (the first keyframe from the printer's camera can take a while on a cold RTSP connection). Subsequent polls every ~8 s recovered and captured in ~1.2 s, but the red `× Failed to capture snapshot for printer N` banner in Settings → Failure Detection → Status stayed up forever because `ObicoDetectionService._last_error` was written on failure and never cleared on the next successful poll. The successful branch in `_check_printer` now clears `_last_error` to `None` once a capture + ML call + classification complete, so the banner reflects only errors from recent cycles. Configuration-level errors (missing `external_url`, missing `ml_url`) still persist because they return before the clearing line — users still see them until they fix the setting. Regression test covers: seed `_last_error`, run one successful `_check_printer`, assert `_last_error is None`. Thanks to @fblix for the reproduction and screenshot. - **Printer Card Controls Row Overflows in Chrome** — At Medium card size on a wide viewport, the printer-card controls row (fan badges, airduct mode, print speed, bed jog, then Stop / Pause on the right) visibly overlapped in Chrome while rendering fine in Firefox and Safari. The controls-row layout had a `max-[550px]:flex-wrap` rule on the left badge group that only fires below 550 **viewport** pixels, so on a wide viewport with a narrow card the left group never wrapped — and since its badges don't truncate, Chrome painted the overflowing speed/bed-jog badges on top of the right-pinned Stop/Pause buttons. German locales made it obvious ("Pausieren" is 9 characters). The left group now uses unconditional `flex-wrap`, so when badges don't all fit on one line they wrap inside the left cell instead of colliding with the right cell; the parent row also wraps `gap-y` so Stop/Pause drops to a new line in the worst case. Pre-existing (commit `4ff3e2a6`, Feb 2026), surfaced while testing #939. - **MQTT Smart Plug Subscription Lost After Every Restart** ([#1010](https://github.com/maziggy/bambuddy/issues/1010)) — Users integrating a Shelly (or any other) plug through an external MQTT broker (e.g. ioBroker, Zigbee2MQTT, Home Assistant's MQTT broker) saw the plug's power / state / energy readings go dark after every Bambuddy restart, and the only fix was to open Settings → Smart Plugs, rename the topic to a dummy value, save, rename it back and save again. Root cause: the startup restore path in `main.py` (~line 4120) still used the legacy single-topic model (`mqtt_topic` plus `*_path` kwargs), while the Settings UI save path had been upgraded to the newer per-type model (`mqtt_power_topic` / `mqtt_energy_topic` / `mqtt_state_topic` each with their own paths, multipliers and `mqtt_state_on_value`). Plugs configured entirely with the new per-type fields got skipped at startup because the `if plug.mqtt_topic:` guard short-circuited — which is exactly what a Shelly-via-ioBroker setup looks like, since those publish power and state on separate topics. The "rename, save, rename back" workaround triggered the update endpoint, which was using the correct per-type code and re-established the subscription. Fix: extracted the topic-resolution + `service.subscribe()` call into a single `subscribe_plug_to_mqtt(service, plug)` helper in `backend/app/services/mqtt_smart_plug.py` that preserves legacy fallback, and routed the startup restore, create, and update routes all through it so future schema changes can't cause the three paths to drift again. Regression tests cover: per-type topics restored without a legacy topic set, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. Thanks to @saint-hh for the clear repro steps. - **Large 3MF Uploads Archived as Corrupted ZIPs** ([#1032](https://github.com/maziggy/bambuddy/issues/1032)) — On bare-metal Raspberry Pi installs (armv7l / Python 3.11 / Bookworm), 3MF files larger than a few MB arrived complete via the virtual-printer FTP server but the copy into `data/archives/` ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, where `GET /archives/{id}/plates` logged `Failed to parse plates from archive N: File is not a zip file` and the thumbnail / plate / filament panels came up blank. Two things conspired: `shutil.copy2` takes the Linux `sendfile()` fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; and `ThreeMFParser.parse()` had a bare `except: pass` around its `zipfile.ZipFile` open, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write with `fsync()` — no sendfile involved — with a post-condition `zipfile.is_zipfile()` check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes at `ERROR`. The parser's silent catch now logs at `WARNING` so corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copy `is_zipfile` sentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis. - **Thumbnails Blank Until Reload After Sign-In** — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that `` tags can't send via `Authorization` headers, so the token is appended as `?token=…` at render time. Two race conditions conspired to break this: (1) the token query was keyed only on `['camera-stream-token']` and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on `!!user`, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, `useStreamTokenSync` walks the DOM once and updates `src` on every already-rendered ``/`