2642 Commits
Author SHA1 Message Date
maziggy 58ac4b3b81 Merge remote-tracking branch 'origin/main' into 0.2.4.2 2026-05-19 14:26:00 +02:00
maziggy 162db57923 chore(security): nosec false-positive Bandit findings in tests
PR #1434 CI flagged 5 B402 (ftplib import) in test_bambu_ftp.py and 2
  B108 (hardcoded /tmp) in test_print_start_assigns_printer_id_to_vp_archive.py.
  Both are intentional in tests: the FTP client tests need real ftplib
  exception classes to construct mock 426 responses, and the /tmp path is
  a MagicMock attribute never written to. Marked with `# nosec B402` /
  `# nosec B108` plus a one-line justification each, matching the
  convention from c2630399.
2026-05-19 14:17:31 +02:00
maziggy 74691fd036 Bumped version 2026-05-19 13:58:21 +02:00
maziggy 020891f936 Post work PR #1431 2026-05-19 13:42:08 +02:00
David Dix 26f4dad832 Support for self-signed CA certificates (#1431) 2026-05-19 13:40:46 +02:00
dependabot[bot] 9cb3407600 chore(deps): bump ws (#1433)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [ws](https://github.com/websockets/ws).
2026-05-19 13:30:16 +02:00
dependabot[bot] 7d3c01293b chore(deps): bump ws (#1433)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [ws](https://github.com/websockets/ws).
2026-05-19 13:28:53 +02:00
maziggy 1677efb2c6 fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)
Reporter — the same person who originally requested the labels
  feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
  dimension didn't actually fit any variant of the MakerWorld AMS
  Filament Label Holder (model 752566) it advertised. Two new
  presets replace it:

  - ams_holder_74x33 (74 x 33 mm) matches the printable label STL
    bundled in the MakerWorld project
  - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
    the reporter validated on bench

  Both cross the 20 mm height threshold so they land in the roomy
  layout branch — swatch on the left, QR on the right, multi-line
  text (brand, material, hex code, spool ID) in the middle. The
  old 30x15 mm preset couldn't fit a QR code; the new ones do.

  No DB migration: the preset name was never persisted. Callers
  scripting the old ams_30x15 value get a clean 422 at the route's
  Literal validator with the new valid values listed.

  i18n: replaced inventory.labels.templates.ams.{label,hint} with
  amsHolderSmall and amsHolderLarge across all 8 locales with real
  translations; parity guard cleaned of the stale English-fallback
  cognate entries. Parity holds at 4856 leaves per locale.

  Tests: backend label renderer + integration tests cover both new
  presets; LabelTemplatePickerModal test updated for the 6-button
  grid and the new template value in the API-call assertion.
2026-05-19 13:14:03 +02:00
maziggy 0b33862ae9 fix(archives): assign printer_id when reusing VP-queue archives in print-start (#1403 follow-up)
VP-queue archives are created with printer_id=None at queue-add
  time because the scheduler hasn't picked a printer yet (and even
  for explicit-printer queue items, the archive predates dispatch).
  on_print_start's expected-archive branch updated status,
  started_at, and subtask_id but never assigned printer_id, so
  VP-queue-dispatched archives stayed permanently unassigned.

  That broke every UI/API path gated on archive.printer_id —
  critically the post-print "Scan for timelapse" action: the
  H.264 file is on the printer's SD card and reachable via the
  file browser, but the archive's scan endpoint refused the request
  and the button stayed greyed out forever.

  One-line fix: archive.printer_id = printer_id in the
  expected-archive branch. Guarded against clobbering an
  already-correct value so library-file queue items (which create
  their archive with the printer pre-assigned) are idempotent.
2026-05-19 12:55:16 +02:00
maziggy c1123365da fix(spoolbuddy): tolerate SPI_NO_CS rejection on Pi 5 (#1424)
Reporter on a Raspberry Pi 5 couldn't read NFC tags — gauge worked,
  SPI bus and wiring fine, but PN5180 transfers didn't complete.
  Manually commenting out `self._spi.no_cs = True` restored
  communication. Root cause: Pi 5's RP1 southbridge SPI driver
  (spi-rp1) doesn't honour the SPI_NO_CS ioctl the way the historical
  Broadcom driver on Pi 4 did.

  Safe to relax Pi-wide. SpoolBuddy's PN5180 NSS line is wired to
  GPIO23 (manual CS in _cs_low / _cs_high — the kernel's default
  auto-CS timing doesn't meet the PN5180's 5µs setup / 100µs hold
  spec). The hardware CE0 line (GPIO8) is not connected to the
  reader, so whether the kernel auto-toggles it is electrically
  invisible. The no_cs = True call was always cosmetic on this
  hardware.

  Wraps the assignment in try/except OSError in both the daemon and
  the diagnostic script; the daemon logs at debug level so future
  Pi-5-specific triage is greppable. README updated to drop the
  "spidev.no_cs = True resolves this" sentence and explain the
  manual GPIO23 CS scheme carries the timing on its own.
2026-05-19 12:33:33 +02:00
dependabot[bot] 18975b0dc2 chore(deps-dev): bump brace-expansion (#1421)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).
2026-05-19 12:21:43 +02:00
dependabot[bot] e15bdb3986 chore(deps-dev): bump brace-expansion (#1421)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).
2026-05-19 12:20:00 +02:00
maziggy 03e3f5313e fix(#1420): negative-cache cover 404s, add GitHub rate-limit backoff
Cover endpoint had no negative cache: when every FTP path returned
  550 for a print whose 3MF wasn't on the printer (typical SD-card
  print), each frontend refresh re-ran the full 8-path fan-out. Add
  _cover_404_cache keyed by (subtask_name, view_key) and short-circuit
  to 404 on hit; clear alongside _cover_cache on print start. Only
  populated on genuine 404 paths, not transient FTP errors, so flaky
  network doesn't lock out future retries.

  GitHub update-check had no backoff on 403 rate-limit. Add module-
  level _github_rate_limit_until plus three helpers; check before
  every api.github.com call in /updates/check and
  _discover_target_release. Read X-RateLimit-Reset from the 403 with a
  1-hour fallback when the header is absent and a 60-second floor to
  guard against container/GitHub clock skew. Route surfaces
  retry_after_seconds so the UI can display real wait time.

  The "ffmpeg didn't terminate gracefully" line the reporter quoted
  is the standard SIGTERM/SIGKILL pattern in camera.py and unrelated
  to the FTP loop; it goes away on its own once the cover endpoint
  stops hammering the printer.
2026-05-19 12:11:22 +02:00
maziggy 9bcaafbc1a fix(assign-spool): refresh printer status after assignment so card updates without manual Force-refresh (#1414 follow-up)
Reporter (@snozzlebert on A1 mini external slot) saw the
  Filament page Location column update correctly after Assign
  Spool, but the Printer card kept showing "Empty slot" until
  they manually pressed Force-refresh. MQTT command was going
  through fine; gap was client-side.

  AssignSpoolModal's two onSuccess callbacks invalidated the
  inventory / slot-assignment queries but never invalidated
  ['printerStatus', printerId] and never issued a pushall. For
  Bambu RFID-tagged spools the printer echoes the new tray_type
  on its own; for non-RFID spools and A1 mini external slots
  the firmware doesn't volunteer that state change.

  Added nudgePrinterRepublish() helper called from both onSuccess
  paths: api.refreshPrinterStatus(printerId) to issue the pushall
  (same call the Force-refresh button uses) plus invalidate
  printerStatus so the refetch lands. Refresh failures are
  swallowed — the assignment itself succeeded; a stale-cache
  nudge that didn't go through shouldn't surface as "assign
  failed". Same pattern as ConfigureAmsSlotModal since #1235,
  with the extra pushall because assign-spool affects firmware-
  side state.
2026-05-19 11:41:53 +02:00
maziggy 9c934c905d fix(ftp): tolerate transient 426 when file is intact on the printer (#1417 follow-up)
Previous daily build (1fac0276) tightened the post-STOR voidresp
  handler to fail on any ftplib.Error, stopping Bambuddy from
  sending a print command for a truncated 3MF. Reporter
  (@enjoylifenow on a P2S) then confirmed — after a clean SD-card
  filesystem check, reformat, and power cycle — that v0.2.4.1
  worked on the same hardware. That proves the 426 returned by
  this firmware revision is noise: the TLS data-channel close
  races the 226 confirmation, server reports failure, file is in
  fact on the SD card.

  Reverting wholesale would re-introduce the silent-truncation
  bug from the original fix. Narrow the rule instead: after an
  ftplib.Error from voidresp, run an FTP SIZE against the upload
  path. SIZE matches the local file size → warn and proceed
  (the reporter's case). SIZE mismatch, or SIZE itself raises →
  fail loudly with full diagnostics (the original tightened
  behavior — preserved).

  Applied identically to upload_file() and upload_bytes() so the
  A1-compatibility manual-transfer path is covered.

  Tests: two regressions from the previous round renamed and
  split into intact / truncated / size-check-fails. Intact-file
  tests inject SIZE explicitly because pyftpdlib only flushes on
  a clean voidresp — which can't happen when we monkeypatch
  voidresp to raise. Docstring spells that out. 87 FTP unit tests
  green; 118 FTP-touching tests across unit+integration green;
  ruff clean.

  The View-Timelapse-greyed-out behavior #1417 was originally
  about stays untouched; once the reporter confirms upload
  reliability is back, that diagnosis continues on a healthy
  install.
2026-05-19 11:32:22 +02:00
maziggy e2df0fc601 fix(ams): physically-empty slots report state=9 and render distinctly from reset slots (#1322 follow-up)
Two-part fix for the #1322 follow-up by @RosdasHH.

  Data layer.
  The previous narrow heuristic in printer_manager.py only caught
  the bare {"id": N} payload firmware sends right after a printer
  restart. In steady-state operation — and on the more common
  post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
  populated payload and signals emptiness via the tray_exist_bits
  bitmask. We already parse that bitmask and use it to wipe stale
  tray_type / tray_color / tag_uid fields, but never touched the
  state field, so downstream readers (printers.py API serializer,
  inventory.py's tray_state in {9, 10} short-circuit, AMS card)
  saw state: null and had to guess from absent payload fields.

  Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
  uses == not `in {...}` so a string would silently miss and the
  reporter's deadlock would come back) to the outer `if not
  slot_exists` branch, so the bitmask path now writes the
  canonical "no spool" code for every empty slot regardless of
  stale fields. The narrow heuristic in printer_manager.py:797
  stays as belt-and-suspenders for any MQTT path that doesn't
  flow through _handle_ams_data.

  UI layer.
  With the data flow now consistent, the AMS slot card renders
  physically-empty slots distinctly from reset slots, per
  reporter's mockup. New helper getEmptySlotKind(tray) returns
  "physical" (state ∈ {9, 10}), "reset" (any other empty state),
  or null (loaded). The inline label below the slot circle reads
  "Empty" for physical and "Reset" for reset; pre-fix both showed
  an em-dash. FilamentSlotCircle gains an emptyKind prop that
  picks a quieter dashed border colour for reset slots so the
  visual hierarchy reads loaded > reset > physically empty.
  EmptySlotHoverCard gains a kind prop and switches between
  "Empty slot" and "Slot reset — no spool assigned".
2026-05-19 11:21:03 +02:00
maziggy fc32b388de fix(stats): align Filament Used / By Time / Success Rate with Total Consumed and Total Prints (#1390 follow-up)
Three independent root causes behind the divergences the reporter
  flagged after the archived-spool fix shipped — fixed together.

  (1) Filament Used vs Total Consumed.
  _compute_run_filament_grams returned the slicer estimate for completed
  prints even when inventory had measured the actual AMS weight delta.
  That made Stats and Inventory two different sources of truth: Stats
  showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
  the two never agreed. Reordered the helper so the tracked spool delta
  (same source that drives weight_used behind Total Consumed) takes
  priority for every status. Slicer estimate stays as the fallback when
  no inventory was tracked; partial-progress scale stays as the fallback
  for failed/cancelled with no tracker. The _run_cost block right next
  to it was already tracker-first; only filament_used_grams was
  inconsistent.

  (2) Printer Stats By Time vs Quick Stats Print Time.
  /archives/slim only set actual_time_seconds when status == "completed".
  For failed/cancelled rows the frontend fell back to print_time_seconds
  (the slicer's full-print estimate — wrong number for a print that
  failed at 15%). Quick Stats already summed elapsed duration across
  all statuses, so the two halves of the page disagreed by the
  (estimate - actual-elapsed) gap on every non-completed event. Dropped
  the completed-only gate; failed/cancelled now report measured elapsed.

  (3) Success Rate %.
  Was successful / (successful + failed), excluding cancelled / stopped
  from the denominator. With "Total Prints: N" displayed right above
  the gauge that produced confusing numbers — 4 successful, 0 failed,
  48 cancelled showed 100% out of an apparent 52 prints. Switched to
  successful / total_prints — matches the count the user reads from
  the widget header.
2026-05-19 10:53:36 +02:00
maziggy 43510b9fc6 fix(inventory): Total Consumed includes archived spools and eraser works on archived (#1390 follow-up)
Reporter (@IndividualGhost1905) saw archived spools' consumption
  silently drop out of the Total Consumed running total — un-archiving
  put it back. The stat is lifetime-since-reset, not currently-
  available, so archived consumption belongs in it.

  InventoryPage.tsx stats loop split: totalConsumed sums over all
  spools (active + archived); totalWeight, lowStock, byMaterial,
  activeCount keep their archived-skip.

  Also fixes two adjacent UX gaps the reporter surfaced:

  - Per-spool eraser button was gated on !archived_at && weight_used
    > 0. Dropped the archived gate so an archived spool's tracking
    counter can be zeroed without un-archiving first.
  - activeSpoolIds (target of "Reset all usage" bulk action) excluded
    archived. Renamed to resetableSpoolIds and broadened to include
    archived so Reset-all genuinely zeroes the now-broader stat in
    one click. Backend reset endpoints already accept archived IDs.
2026-05-18 13:06:07 +02:00
maziggy fcd1801aab feat(inventory): sort-by-colour toggle in label-print modal (#1410)
Reporter asked for an option to order printed label sheets by colour
  instead of spool number so multi-colour rolls group related colours
  together physically on the sheet.

  Backend (labels.py) already preserves caller order, so this is
  frontend-only. LabelTemplatePickerModal gains a "Sort: By ID / By
  colour" chip pair next to the material filter. Colour mode converts
  each spool's rgba to HSL: chromatic colours (s >= 0.1) cluster in
  bucket 0 ordered by hue 0..360, achromatic colours go in bucket 1
  ordered by lightness so neutrals trail the rainbow black -> white.
  Stable tiebreaker on spool ID.

  Also fixes a latent issue exposed by the same code: the submit was
  always re-sorting selected IDs ascending, which would have clobbered
  any frontend order. Submit now uses sortedSpools.filter().map() so
  the visible order flows through to the PDF.

  Session-only state; toggle resets to "By ID" each time the modal
  opens. 3 new i18n keys translated across all 8 locales (parity 4852
  leaves). 2 new modal tests pin the colour-sort payload order and
  the unchanged ID-default. 17 modal tests + i18n parity + build all
  green.
2026-05-18 12:53:07 +02:00
maziggy cd19e746bd Post work PR #1402 2026-05-18 12:39:00 +02:00
Chanakyan ed5af66839 feat(inventory): show spool ID in edit modal and AMS hover card (#1385) (#1402)
feat(inventory): show spool ID in edit modal and AMS hover card (#1385)
2026-05-18 12:34:42 +02:00
maziggy ae0f485a84 Post work PR #1413 2026-05-18 12:03:21 +02:00
Ben Halverson feb44a9dc1 Merge pull request #1416 from benhalverson/fix/open-in-slicer
Fix library Open in Slicer URLs for extensionless filenames
2026-05-18 11:59:34 +02:00
maziggy 3b552094a7 fix(spoolman): edit-spool patches the linked filament in place when singleton (#1357 follow-up)
Editing a Spoolman spool used to mint a brand-new filament every time
  a match-key field (subtype/material/brand/color_hex) changed, orphan
  the previous one, and re-link the spool. The reporter ended up with
  dozens of duplicate "Amazon Basics / PLA Glow" filament rows.

  PATCH /spoolman/inventory/spools/{id} now:

  - Reuses the current filament_id when no filament-shaping field
    changed (a note/weight_used edit never touches the catalogue).
  - PATCHes the existing filament in place when it's a singleton
    (only this spool points at it, archived spools included).
  - Falls back to find_or_create_filament only when the filament is
    genuinely shared with another spool.

  Mirrors internal-inventory behaviour where editing a spool updates
  the thing the spool points at instead of proliferating new entities.
2026-05-18 11:35:10 +02:00
maziggy 134847a3bd feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When
  the camera viewer hits its error state OR before a print at any
  time, a Diagnose button runs a staged check against the printer and
  renders the result inline: which stage failed, how long it took,
  and a translated remediation hint. Cuts off the "user opens a
  'camera broken' ticket → ask for support bundle → triage" loop at
  the user's screen.

  Backend

  - New `backend/app/services/camera_diagnose.py` orchestrator with
    CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
  - New POST /printers/{id}/camera/diagnose route in camera.py.
  - Stages:
      tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
        with 3 s timeout. Distinguishes timeout, refused, and host-
        unreachable into distinct summary codes so the frontend can
        show a precise remediation (firewall vs LAN-only off vs
        wrong IP).
      first_frame — captures one JPEG end-to-end via the existing
        capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
        first keyframe collapse into one stage; the user-facing
        answer is the same regardless of which sub-layer failed.
  - Live-stream shortcut: when a viewer is currently watching the
    camera with a buffered frame < 10 s old, the diagnostic skips
    the real test and returns live_stream_active_healthy. Opening a
    fresh socket would kick the live viewer off on single-camera-
    connection firmwares (the #1348 reconnect-storm trigger), so we
    trust the real-world evidence instead.
  - Response surfaces protocol, port, and profile name for support
    triage — lets us ask "what does your modal say?" instead of
    "send the support bundle".

  Frontend

  - New CameraDiagnoseModal renders one row per stage with green-
    check / red-X / grey-skipped icons, the per-stage duration in
    ms, a remediation banner styled by overall status, and a Run
    again button.
  - Two entry points:
      1. The viewer's error overlay grows a Diagnose button next to
         Retry. Retry stays the primary action; Diagnose is the
         escape hatch for users who can't see what's wrong.
      2. A stethoscope icon in the viewer's always-visible control
         bar, between Refresh and Fullscreen. Pre-flight testing
         ("did my firmware update break the camera?", "is the
         camera up before I send a print?") doesn't require waiting
         for the stream to fail first.
  - Also lifted the previously-hard-coded "Camera unavailable" /
    "Retry" strings into camera.unavailable / camera.retry so the
    error UI is fully translated alongside the new keys.
2026-05-18 10:52:02 +02:00
maziggy 67cb5275d0 fix(camera): per-model profile registry; P2S gets relaxed RTSP probe (#1395)
Reporter on a P2S running firmware 01.02.00.00 saw the camera connect
  for a few seconds then time out, repeating. P1S on the same install
  worked fine — different protocol (chamber-image port 6000 vs RTSP via
  ffmpeg).

  The P2S RTSP path was running ffmpeg with `-probesize 32
  -analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower
  keyframe pacing means ffmpeg can't lock onto the stream within 32
  bytes — its own stderr says "consider increasing probesize" before
  giving up after ~2s. Bambuddy reconnects, cycle repeats.

  Instead of bumping the globals (which would regress every other RTSP
  model's startup latency), this lifts the per-model tuning into a new
  `camera_profiles` registry. CameraProfile dataclass holds the
  previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max,
  rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future
  per-model flags). get_camera_profile(model) returns the model's profile
  or DEFAULT_PROFILE.

  Default profile preserves the historical X1/H2 fast-startup values
  verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no
  behaviour change. P2S is the only override:

    P2S: probesize=1_000_000, analyzeduration=500_000

  SSDP internal codes (N7→P2S) resolve via an alias map so the camera
  path works during the early-connect window before the display name
  is settled.

  This is the first step of the camera-architecture overhaul agreed
  after #1395. Adding the next quirky model is a config entry, not
  another module-level constant.
2026-05-18 10:29:13 +02:00
maziggy b9340389d3 fix(archives): print-log filename column expands instead of clipping at 200px (#1406)
Reporter on a 27" monitor saw long filenames truncated even though
  the Print Log table had plenty of unused horizontal space. The
  print-name `<span>` had a hard `truncate max-w-[200px]` cap that
  ignored viewport width entirely.

  Replaced with `break-words` + a `title` attribute, dropping the
  explicit max-width so the column auto-sizes to content. On wide
  screens the full name shows on a single line; on narrow ones it
  wraps inside the cell instead of forcing horizontal scroll. The
  `title` hover preserves the original tooltip affordance for the
  edge case where a really long name still gets wrapped.
2026-05-18 10:03:41 +02:00
maziggy 8d52c713ef fix(inventory): hex colour field accepts character-by-character typing (#1407)
Reporter typed into the Add Spool modal's hex colour input and only
  the first character stuck - everything after that defaulted to "0"
  with no way to override except by pasting the full hex.

  Pre-fix, the #1055 fix aggressively normalized the input to a valid
  8-char rgba on every keystroke. After typing the first char the
  controlled input value snapped to e.g. "A00000", the browser placed
  the cursor at the end, and the user's next keystroke landed at
  position 7. The #1055 fix's 7-char branch then truncated that byte
  away, leaving the form state unchanged - so the user appeared to
  type nothing.

  Fix splits the typing-state from the backend-state:

  - The hex input gets its own `hexDraft` useState holding 0-6 chars
    freely. Typing one char at a time works naturally because the
    controlled value matches what the user typed.
  - `updateField('rgba', ...)` fires only when the draft reaches a
    complete 6-char RGB (commits as `<6chars>FF`). Below that, the
    form state stays untouched - no mid-keystroke snap.
  - On blur, a partial 1-5 char draft is right-padded with `0` and
    committed. Keeps the #1055 invariant: anything reaching the
    backend is exactly 8 hex chars matching /^[0-9A-F]{8}$/.
  - A `useEffect` resyncs the draft when an external action (color
    picker, swatch click, edit-mode load) changes the canonical hex.
  - Paste of 7-/8-char strings truncates to the leading RGB. Bambu
    filaments are opaque; the UI never exposed an alpha affordance,
    so dropping the (undocumented) paste-with-alpha case is fine.
2026-05-18 09:57:07 +02:00
maziggy 173edd9b7c ● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
  queue, started from the queue, and got no timelapse video. Their
  dispatch chain itself was correct (queue item -> scheduler -> MQTT
  command honors `timelapse`); the gap was at queue-add time.

  The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
  other print-option settings) from the workflow settings card. That was
  introduced in #1235 to stop column-level defaults from winning. But it
  also discarded the slicer's actual choice carried on the MQTT
  `project_file` command, which all the slicers (Studio / Handy / Orca)
  ship as `timelapse: true|1`. Result: a user with the new-install value
  `default_timelapse=false` had to either flip the global setting or
  edit every queue item by hand, even though their slicer's "Print
  options" UI clearly said "record timelapse".

  Investigation went wider than #1403 because Martin's hypothesis was
  "the print options modal isn't respected either." Cross-checking
  86 captured P1S `project_file` commands across the support packages
  shows 46 from the queue scheduler and 33 from background_dispatch
  emitting `"timelapse": true` correctly to real printers - the modal +
  re-print path is intact end-to-end. The slicer-side gap was the only
  real bug. Two unrelated dead-code issues turned up in the same dig and
  are folded in below.

  Fix (VP queue inheritance)

  - `on_print_command` in the VP manager now stashes the slicer's
    project_file dict keyed by filename, then signals an asyncio.Event.
  - `_add_to_print_queue` checks the dict first; if empty, creates the
    event and waits up to 2 s for it before reading the settings
    fallback. Each option flows through per-field - slicer value wins
    if present, else the existing settings default (so users who
    explicitly set `default_timelapse=true` in their VP workflow card
    still get that on slicers that don't send a print command).
  - MQTT field naming preserved exactly: `bed_leveling` (single L) on
    the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
    column. Integer 0/1 from H-family slicers and bool true/false from
    P1/X1 slicers both coerce via `bool()`.
  - Capture is gated on `mode == "print_queue"` so immediate / review /
    proxy modes keep their pre-fix no-op `on_print_command` and don't
    accumulate stashed entries over the VP's uptime.
  - Wait is also skipped when there's no MQTT server attached
    (`self._mqtt is None`), so unit tests that invoke
    `_add_to_print_queue` directly don't pay the 2 s tax.
  - Capture is consumed on use so the dict stays bounded.
  - `printer_manager.get_status(...).get(...)` against a `PrinterState`
    dataclass that has no `.get()` method.
  - Every print option discarded (timelapse, bed_levelling, AMS mapping).

  The route 500'd before ever reaching the printer. Rewritten to mirror
  `POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
  next pending queue item and let the scheduler dispatch with the
  queue's stored options intact. Response shape preserved.

  Side-bug b: vibration_cali default drift in background_dispatch

  - `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
    both default to `True` (matches Bambu Studio behavior for X1/P1).
  - Both `_process_job` call sites read
    `job.options.get("vibration_cali", False)`.

  Cosmetic today because the frontend always sends the field, but a
  latent landmine for any future caller that bypasses the schema. Both
  sites flipped to `True`.
2026-05-18 09:38:53 +02:00
maziggy e61a454a0f fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
  "Spools and remaining weights are not changed" - the dialog promised
  this; the implementation did the opposite. Root cause was an
  architectural conflation: `weight_used` did double duty as the
  resettable "consumed since tracking started" counter AND as the basis
  for the displayed remaining (`label_weight - weight_used`), so zeroing
  it correctly cleared the stat but unavoidably reset remaining to full.

  Spoolman has separate `used_weight` and `remaining_weight` fields, so
  the API call there was correct - but Bambuddy's frontend was also
  computing remaining as `label_weight - weight_used` for Spoolman
  spools (ignoring Spoolman's real `remaining_weight` field), so the
  same visual bug bit there too. Inventory-mode parity required fixing
  both halves in one drop.

  Internal mode

  - New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
  - Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
  - Displayed consumed = `weight_used - baseline`; remaining =
    `label_weight - weight_used` (unchanged).
  - Subsequent prints continue to grow `weight_used`, so the resettable
    counter naturally tracks post-reset delta and remaining keeps
    decrementing across the reset.

  Spoolman mode

  - `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
    and returns a synthetic `weight_used = label - remaining` so the
    frontend's remaining calc matches Spoolman's real stored value;
    `weight_used_baseline = synthetic - real_used_weight` so the consumed
    counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
  - Fallback path (no `remaining_weight` set) preserves the old behavior.
  - Related fix: `update_spool` (Spoolman PATCH) was deriving the default
    `weight_used` from `used_weight`, so editing unrelated fields AFTER
    a reset would patch Spoolman with `remaining_weight = label - 0 =
    label`, trampling the real value. Now derives from
    `remaining_weight` so non-weight edits preserve physical state.

  Frontend

  - `InventoryPage` `totalConsumed` aggregate switched to
    `Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
  - `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
    "consumed" table cell got the same treatment so forecast and
    inventory aggregates stay coherent across a reset.
  - `?? 0` keeps pre-migration installs rendering correctly until
    `init_db()` runs the idempotent ALTER TABLE.

  Migration

  - `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
    via `_safe_execute` - SQLite and Postgres both accept it; verified
    end-to-end on Postgres 16.
2026-05-18 08:51:27 +02:00
maziggy b51598ea69 fix(printers): refuse to add a printer when the MQTT probe fails (#empty-card-reports)
Several support reports traced back to one root cause: a mistyped access
  code in Add Printer left an empty card on the dashboard. POST /printers/
  was persisting the row first, then firing connect_printer() fire-and-forget.

  Now we test_connection() BEFORE the insert; failure returns HTTP 400 and
  the row is never written.

  Structured error response -- detail={"code", "message"} -- so the toast
  shows the localized message instead of the English fallback. New
  ApiError.code field on the frontend; printers.toast.connectionFailedNotAdded
2026-05-17 15:42:19 +02:00
maziggy 48a7024b96 security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
  several left public. That's a serious leak: the settings backup only
  filters bambu_cloud_token and auth_secret_key, so mqtt_username,
  mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
  external_url, and the printer access codes (via K-profiles) were going
  to whatever visibility the user picked.

  Hard guard at every save and re-checked on every push:

  - POST /github-backup/config and PATCH /github-backup/config (when URL,
    token, or provider changes) run a connection test internally and
    return 400 unless is_private comes back True.
  - run_backup() re-checks before each scheduled or manual push, so a
    repository that flipped from private to public gets a clear
    "Backup aborted: the target repository is no longer private" failure.

  Each provider's test_connection now returns is_private (GitHub /
  Gitea / Forgejo read data.private, GitLab reads visibility=="private";
  "internal" is treated as non-private). None means "couldn't determine"
  and is also rejected -- safer to fail closed.

  Frontend renders visibility inline on Test Connection: green check when
  private, red warning panel listing every credential at risk when public,
  yellow when unknown.

---

  ui(github-backup): show save-failure messages inline on the card

  The new "repository is not private" rejection message is ~250 characters
  listing every credential the backup carries (MQTT password, HA token,
  Prometheus token, Bambu Cloud email, printer access codes), which clips
  badly in a toast.

  Both the initial-setup save and the debounced autosave now stash the
  backend's error message into a saveError state and render it as a red
  inline banner above the test-result block, with whitespace-pre-wrap so
  the full message stays readable. The banner clears on success, on the
  next save attempt, and when the user starts editing URL / token / provider
  -- the three fields whose changes invalidate the privacy check -- so it
  doesn't linger after the user has already addressed the cause.

  Short success toasts (Settings saved, Token updated, Backup enabled) are
  unchanged.
2026-05-17 15:30:05 +02:00
maziggy b07122e395 fix(inventory): "Print labels..." works in Spoolman mode (#1390 follow-up)
The LabelTemplatePickerModal correctly branches on a spoolmanMode prop
  and the /spoolman/labels backend endpoint exists, but InventoryPage was
  instantiating the modal with spoolmanMode={false} hard-coded. Every
  click in Spoolman mode resolved to /inventory/labels with Spoolman spool
  IDs and returned 404 "Spool(s) not found".

  The hard-coded value came with a stale comment from the original label
  printing PR that said "Spoolman path hands users an iframe to Spoolman
  so the per-spool button never shows in that context" -- that assumption
  stopped being true when the unified inventory UI shipped.

  Pass the actual spoolmanMode value through, drop the stale comment.

  The existing LabelTemplatePickerModal.test.tsx already covers both
  branches at the component level (line 209: "routes to the Spoolman
  endpoint when spoolmanMode is true"). The gap was that no test exercised
  the InventoryPage wiring.
2026-05-17 15:11:42 +02:00
maziggy 8b9efd0160 fix(inventory): "Reset usage to 0" works in Spoolman mode too (#1390)
First cut of this action only wired the built-in inventory path, so the
  eraser buttons vanished when the user switched to Spoolman mode. Mirror
  the endpoints on the Spoolman router:

  - POST /spoolman/inventory/spools/{id}/reset-usage
  - POST /spoolman/inventory/spools/reset-usage-bulk

  Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
  /spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
  guard (rejects empty/missing spool_ids), and individual Spoolman failures
  are logged + counted out without aborting the batch.

  InventoryPage mutations now switch on spoolmanMode to pick the right
  client method, and the three "spoolmanMode ? undefined : ..." gates on
  the eraser buttons are gone.
2026-05-17 15:04:31 +02:00
maziggy f645bd2bbe fix(stats): per-event data for all widgets, not just Quick Stats (#1390)
After #1378 moved Quick Stats to print_log_entries, six widgets and
  Failure Analysis still iterated the archive list. That made reprints
  multiply event-based widgets while leaving archive-based ones unchanged,
  and made hard-deleted archives drop from archive-based widgets while
  their orphan events kept feeding Quick Stats.

  Swap the data source in two places:

  - GET /archives/slim now reads PrintLogEntry, LEFT JOINs the archive for
    the sliced print_time_seconds estimate, prefers PrintLogEntry's own
    duration_seconds as the measured-time field. StatsPage is the only
    caller -- every widget realigns in one step.
  - FailureAnalysisService swapped from PrintArchive to PrintLogEntry for
    every aggregation. project_id filter still resolves through archives
    but counts matching events.

  Conftest archive_factory now syncs the synthesized event's created_at
  with the archive's so backdated test data survives the change.
2026-05-17 14:38:31 +02:00
maziggy 1fac027654 fix(ftp): raise on ftplib.Error from voidresp instead of proceeding
bambu_ftp.upload_file (and upload_bytes) wrapped the voidresp() call in a
  broad "except Exception: log warning and proceed" because H2D printers
  can take 30+ seconds to send the 226 and we don't want to fail on that.
  But the same handler was swallowing ftplib.error_temp (e.g. 426 "Failure
  reading network stream") from buggy printer firmware, which explicitly
  means the data stream was cut mid-transfer and the file on the SD card
  is partial.

  Bambuddy then sent the print command anyway, and the printer surfaced a
  generic "unable to parse 3mf file" error 30 seconds into the print
  attempt -- with nothing in the log on the user side to suggest the
  upload had actually failed.

  Split the catch: ftplib.Error subclasses (server-reported failure)
  re-raise so the outer handler returns False; everything else (socket
  timeout etc.) keeps the existing proceed-with-warning behaviour so the
  H2D 226 tolerance survives.

  Two regression tests patch _ftp.voidresp to raise error_temp("426 ...")
  and assert both upload_file() and upload_bytes() return False.

  The underlying P2S firmware / TLS-data-channel issue that triggers the
  426 for the reporter is separate -- this change just stops Bambuddy from
  hiding it.
2026-05-17 14:03:23 +02:00
maziggy 74dcaf5142 refactor(settings): Spool Catalog now identical in internal and Spoolman modes
Drop the Spoolman-mode hijack that replaced the local spool tare catalog
  with an inline filament editor — two unrelated concepts that should never
  have shared a card. Spool Catalog now renders the same way in both modes;
  Spoolman users edit filament name and spool_weight in Spoolman's own UI.

  Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
  on the Filament settings page whenever Spoolman was disabled.

  - frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
  - frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
  - SpoolCatalogSettings test file rewritten to match the simplified component
  - PATCH /spoolman/inventory/filaments/{id} backend route left in place
2026-05-17 13:48:05 +02:00
maziggy bff240e90a fix(uploads): pre-flight validation for 3MF/gcode + visible upload errors (#1401)
Reporter @iitazz uploaded slicer output to Bambuddy, clicked Print,
  and the printer rejected every job with "Printing stopped because
  the printer was unable to parse the 3mf file". Support bundle showed
  the stored library file ended in .gcode (not .gcode.3mf), and
  background_dispatch.py appends ".3mf" to filenames that don't
  already end in .gcode.3mf/.3mf — so raw gcode shipped to the printer
  named .gcode.3mf and the firmware's 3MF parser choked. Same shape
  also surfaced as "File is not a zip file" on Bambuddy's own plate
  parser.

  New validate_print_file_upload() helper in library.py runs at upload
  time:
    - Reject filenames ending in .gcode (but not .gcode.3mf) with a
      clear message — Bambu printers need .gcode.3mf zip containers,
      not raw gcode.
    - For .3mf / .gcode.3mf uploads, verify body starts with PK\x03\x04
      (ZIP magic); reject otherwise pointing at the slicer's "Export
      Plate Sliced File" action.

  Applied to every relevant upload route: POST /library/files (covers
  File Manager + printer-card drag-drop), POST /archives/upload,
  POST /archives/upload-bulk (rejects per-row so one bad file doesn't
  abort the batch), POST /archives/{id}/source, POST /archives/upload-source.
  Runs after _resolve_upload_destination so folder-permission errors
  (403 readonly, 400 missing-path, 409 collision) still take precedence.
  STL / image / other non-print uploads bypass the validator.

  FileUploadModal frontend fix: the modal auto-closed after every
  batch regardless of per-file results, so a 400 rejection was captured
  but invisible. Now:
    - Errors render inline as red text under the file row instead of
      as a hover-only title tooltip.
    - Modal stays open if any file ended with status='error', so the
      user can read the backend's remediation message before closing.
    - Successful-only batches still auto-close as before.

  UploadModal (bulk archive) was already showing inline errors and
  not auto-closing — no change needed there.
2026-05-17 13:09:58 +02:00
maziggy 4ccde42e39 feat(inventory): storage location filter chip (#1400)
Reporter pgladel manages multiple physical filament storage
  locations and wanted to narrow the inventory list to a specific
  location without typing a search query each time.

  Adds a new Storage Location dropdown chip on the inventory page,
  next to the existing Material / Brand / Category / Spool Name
  filters. Distinct values are pulled from the spool list with
  .trim() so accidental trailing whitespace doesn't render as a
  separate option. A "No location set" entry appears when at least
  one spool has an empty storage_location (mirrors the categoryNone
  group). Chip self-hides when no spool has a storage location set.

  Same shape as the Category chip from #729 — clear-all-filters and
  hasActiveFilters both include the new state.

  i18n: reuses existing inventory.storageLocation label, adds
  inventory.storageLocationNone in all 8 locales. Parity check
  holds at 4818 leaves per locale. 24 InventoryPage tests still
  pass, frontend build clean.
2026-05-17 12:34:46 +02:00
maziggy 6f2cec5eb3 feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
2026-05-17 12:26:42 +02:00
maziggy b2f4ff5834 Updated BACKERS.md 2026-05-17 11:20:07 +02:00
maziggy b486880477 Updated BACKERS.md 2026-05-17 11:19:41 +02:00
maziggy e7045597bc fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)
Reporter IndividualGhost1905 followed up after the #1378 / #1343
  backfill landed and pointed at the next inconsistency: the per-
  archive delete dialog has had a "Also remove this print from Quick
  Stats" checkbox since #1343, but the "Purge Old" button and the
  scheduled daily auto-purge sweeper both ignored that choice and
  hard-deleted unconditionally. From the user side this looked like
  "automatically deleted from statistics without any warning" — half-
  true, and the inconsistency was real either way.

  The actual current shape (before this fix):

    - POST /archives/purge -> archive_purge_service.purge_older_than
      -> ArchiveService.delete_archive (hard). Archive row dropped.
      Linked PrintLogEntry rows have ON DELETE SET NULL so they
      survive as orphans with archive_id=NULL. Quick Stats keeps the
      filament / cost / energy contribution because the log rows are
      still there, but the archive-list-iterating widgets (Filament
      Trends, By Material, Color Distribution, Printer Stats) lose
      the row, and Time Accuracy loses its join target. Visibly
      inconsistent.
    - Scheduled _maybe_run_auto_purge -> same code path, same effect.
    - Single-archive DELETE /archives/{id} -> already takes
      purge_stats=true|false (default false=soft) and routes either
      soft_delete_archive (keeps everything, flips deleted_at) or
      deletes PrintLogEntry rows first + hard-deletes archive.

  The fix threads the same purge_stats flag through every bulk surface
  with soft as the default, matching the single-archive default:

  Backend:
    - archive_purge_service.purge_older_than(..., purge_stats=False)
      -> per-row soft_delete_archive when False, per-row
      PrintLogEntry deletion + delete_archive when True. Each runs in
      its own session (same pattern the sweeper already used).
    - preview_purge gains the same kwarg so the eligible-count
      matches what an actual run would touch: soft mode excludes
      already-soft-deleted rows, hard mode counts them as eligible
      for promotion.
    - get_settings / set_settings now persist archive_auto_purge_stats
      (default False). _maybe_run_auto_purge reads it on every tick.
    - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings
      schemas extended.
    - Route /archives/purge accepts the body flag, /purge/preview
      accepts the query param, /purge/settings GET + PUT echo the
      setting.

  Frontend:
    - "Purge old archives" modal: new "Also remove from statistics"
      checkbox under the preview, unchecked by default. Plumbed into
      the preview query key + the execute mutation.
    - Settings -> Archives auto-purge card: matching toggle next to
      the days slider, disabled when auto-purge itself is off.
    - api.previewArchivePurge / api.executeArchivePurge accept the
      flag; ArchivePurgeSettings type gains purge_stats.
    - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get
      new purgeStatsLabel / purgeStatsHint / purgeStatsDescription
      keys, plus rewritten effect / warning copy in archivePurge and
      archiveAutoPurge to reflect that the default no longer
      "permanently removes from the database" but instead hides the
      row + removes files while keeping Quick Stats intact. i18n
      parity check clean: 4814 keys across all 8 locales, no fallback.

  Behaviour change for existing users on auto-purge: the sweeper used
  to hard-delete by default and now soft-deletes by default. After
  the upgrade those installs start *preserving* more data in Quick
  Stats rather than losing it — safer direction of the two, but worth
  the explicit call-out. Anyone who wants the old behaviour ticks the
  new toggle once and it persists.
2026-05-17 10:54:28 +02:00
maziggy 0ddf0925b7 chore(cloud): clarify access-token hint and document the MakerWorld cookie path for China-region accounts (#1396)
Reporter wintsa123 (China-region user, bambulab.cn) couldn't log
  into Bambuddy. Looked like a code bug; turned out the code is fine
  and only the docs were wrong.

  What's already there (no change needed):

  - PR #1013 (April) added the China region selector to the
    token-login flow and routes validation to api.bambulab.cn instead
    of api.bambulab.com. The selector is in the form
    (frontend/src/pages/ProfilesPage.tsx:204-205) and the backend
    dispatch is in backend/app/services/bambu_cloud.py:15 +
    backend/app/api/routes/cloud.py:174.

  What was wrong:

  - The in-app `accessTokenHint` said "Paste your Bambu Lab access
    token (from Bambu Studio)" in all 8 locales. Bambu Studio never
    exposed the token in any UI, and the profile page on
    bambulab.com that used to show it has been removed. The hint was
    pointing users at sources that don't exist.
  - For China-region accounts the email / password flow is
    fundamentally unusable because those accounts are bound to phone
    numbers, not email — token login is the only working path. The
    hint didn't say so, so reporters kept trying email login first
    and getting confused.
  - The wiki's "Access Token Login" section in
    features/cloud-profiles.md only described the dead profile-page
    method and a Python-script alternative that only works against
    the global API. No mention of the China-region selector and no
    mention of the MakerWorld-cookie method that actually works
    today.

  What this change does:

  - Rewrites `accessTokenHint` in all 8 locales (en, de, fr, it, ja,
    pt-BR, zh-CN, zh-TW) to state that China-region accounts must
    use the token path and point at the wiki for the cookie
    retrieval procedure. zh-CN and zh-TW translations were written
    for the audience that actually needs this.
  - Rewrites the wiki's Access Token Login section: adds a
    "Region: China must use token login" callout, replaces the
    dead profile-page guidance with the MakerWorld cookie method
    (both makerworld.com and makerworld.com.cn, with the browser
    DevTools steps), keeps the Python-script alternative explicitly
    scoped to global-region accounts, and warns that the cookie
    value is sensitive and shouldn't be pasted into screenshots or
    threads.

  No backend changes. Once a user has the token + Region: China
  selected, the existing flow works.
2026-05-17 10:29:14 +02:00
maziggy 1bb0d4856d fix(vp): deep-merge ams on bridge cache so P1S/A1 partial pushes don't nuke AMS (#1387)
Reporter vmhomelab ran a Print Queue VP against a P1S, opened
  BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch
  (which restarts the VP) made AMS briefly appear, then it reverted to
  defaults. Proxy Mode worked fine.

  The earlier #1371 sticky-keys fix only handled one of two firmware
  incremental-push shapes: it preserved cached `ams` when the incoming
  push OMITTED the key entirely. P1S firmware (01.09.01.00) instead
  sends incrementals with the `ams` key present but the inner `ams.ams`
  array stripped — `{ams_status: 1, humidity: 2}` rather than
  `{ams: [...], ams_status: 1}`. To the existing "key present? leave it"
  check that read as "no need to preserve," so the bridge cache got
  overwritten with the stripped blob, the slicer's next 1 Hz read saw
  `ams` with no unit list, and BambuStudio fell back to its "no AMS"
  default render. Toggling Auto-Dispatch restarted the VP and got a
  fresh pushall through; the next P1S incremental stripped it again.

  H2D rarely trips this because its incrementals typically don't carry
  `ams` at all, so #1371 alone was enough — which is why H2D users
  (including the project owner) didn't see the bug while P1S/A1 users do.

  Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the
  structure Bambuddy itself already does in
  `bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value,
  but the `ams.ams` array is merged unit-by-unit by `id`, each unit's
  `tray` array is merged tray-by-tray by `id`, and units / trays the
  incremental doesn't mention survive intact from the cached full
  state. A tray-targeted incremental during a print
  (`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one
  tray's state without dropping the other trays' tray_type / tray_color.

  Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called
  from the existing sticky-keys block when both prev and new carry the
  `ams` key as dicts. Other sticky keys (vt_tray, net, ipcam,
  lights_report, ams_extruder_map, mapping) keep the prior absent-only
  preservation; only `ams` has the multi-shape partial problem worth
  the merge complexity.
2026-05-17 09:45:29 +02:00
maziggy 8e4f815b37 fix(stats): backfill PrintLogEntry.cost/energy/archive_id for pre-#1378 rows (#1390)
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
  per-event aggregation rewrite from #1378) and saw Quick Stats split
  between consistent values (Total Prints, Print Time, Filament Used,
  Energy, Success Rate matched the archive list) and zero-or-empty ones
  (Filament Cost, Time Accuracy).

  Root cause: #1378's migration added six columns to print_log_entries
  - archive_id, cost, energy_kwh, energy_cost, failure_reason,
  created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
  on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
  on legacy data); the time-accuracy query JOINs PrintArchive ON
  archive_id (drops every legacy run from the average). Counts and the
  pre-existing per-row fields (status, duration_seconds,
  filament_used_grams) kept working - which is why some panels looked
  right and others didn't.

  Two-step backfill added inside run_migrations next to the existing
  column-add block, as DML inside begin_nested() (not _safe_execute,
  which is documented DDL-only):

    Step 1: link each orphan log entry to its archive via
            print_name + printer_id (highest archive id wins on
            tiebreak - newest matches the overwrite-then-stop shape
            pre-#1378 reprints left behind).

    Step 2: copy archive.cost / energy_kwh / energy_cost onto the
            latest matching log entry per archive, BUT only for
            archives where no log entry yet carries a cost. That
            second clause is the idempotency anchor and the
            double-count guard for users running this after #1378
            has already written cost-bearing rows for new runs -
            those archives are left untouched.

  Earlier reprints stay NULL, matching the "first/latest writes, rest
  stay NULL" convention #1378 introduced for new prints. Sum across the
  legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
  Stats Filament Cost matches the pre-upgrade total instead of dropping
  to zero.

  SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
  subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
  SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
  end-to-end on SQLite (4 new unit tests in
  test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
  cost, idempotent, skip-archives-with-any-costed-run) and against a
  live postgres:16-alpine + asyncpg container (first-pass and second-
  pass produce identical state).

  The other widgets the reporter listed (Printer Stats, Filament
  Trends, By Material, Success by Material, Color Distribution) iterate
  the archives list on the frontend rather than calling /stats - they
  read consistent pre-upgrade data and aren't part of this fix; the
  inconsistency between them and Quick Stats resolves once the backfill
  brings Quick Stats in line.
2026-05-17 09:30:02 +02:00
maziggy 4a98914d4a fix(spoolman): persist Color Name via spool.extra — Spoolman has no filament.color_name field (#1357)
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit
  Save, and saw the value snap back to the subtype on the next read.
  The earlier #1319 fix correctly handled the read/form-prefill half
  (the color_name_is_synthesized flag, blank-on-synth form init), but
  the write half assumed Spoolman has a `color_name` field on Filament.

  It doesn't. Verified against the live FilamentUpdateParameters schema
  on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material,
  price, density, diameter, weight, spool_weight, article_number,
  comment, settings_extruder_temp, settings_bed_temp, color_hex,
  multi_color_hexes, multi_color_direction, external_id, extra. No
  color_name. Spoolman's PATCH happily returns 200 for
  {"color_name": "Red"} and silently discards the unknown key, so
  find_or_create_filament was either patching a void or creating
  filament after filament with the same field-that-doesn't-stick (which
  is what produced the "BB also created a bunch of new filaments"
  duplicate trail on each save attempt).

  The fix follows the same pattern as the existing BambuStudio slicer-
  preset storage: persist color_name on spool.extra.bambu_color_name as
  a JSON-encoded string, register the extra field via
  ensure_extra_field before write (Spoolman 400s on unknown extra keys),
  and read it back in _map_spoolman_spool with priority
  extra > filament.color_name (forward-compat for any future Spoolman
  release that adds the field) > subtype synth.

  Dropped the now-dead color_name passing through
  find_or_create_filament and create_filament — Spoolman would discard
  it anyway and keeping the dead pipe risked the same confusion the
  next time someone reads this code. The previous "match by name then
  patch color_name" loop is gone; what survives is the name-match
  resilience that lets an AMS-sync-created filament named "Glow" still
  match the user-driven edit's composed "PLA Glow", which prevents
  re-introducing the duplicate-filament trail.

  The frontend form's color_name_is_synthesized handling is unchanged
  — that part already worked.
2026-05-17 09:10:20 +02:00
maziggy 135b8fd93b fix(smart-plugs): HA entity search bypassed the schema's domain whitelist (#1388)
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
  a search prefix matching a multi-entity device (one switch.* plus
  several sensor.*/binary_sensor.* siblings under the same friendly
  name), clicked one of the non-switch siblings, and got a 422 on Save:

    String should match pattern
    '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'

  The screenshot confirms the bug shape — the X button next to the
  "empty-looking" Select Entity field only renders when haEntityId is
  truthy. So haEntityId was set, but selectedEntity (haEntities.find by
  that id) returned undefined, so the input rendered the placeholder
  text instead of the friendly-name display. That can only happen when
  the user had earlier picked an entity whose domain is NOT in the
  schema's allowed list, then the search cleared, the entity-list
  refetched without a search param, and the refreshed list (filtered to
  the default domains) no longer contained the user's pick.

  Root cause was in HomeAssistantService.list_entities: when a search
  query was present, the function bypassed the domain filter entirely
  and returned matches across every HA domain. Offering a clickable
  choice the schema can't accept is broken UX, and the cryptic Pydantic
  pattern echo on save made it look like a backend/schema problem
  rather than a search-permissiveness problem. Confirmed via git diff
  that the smart-plug code path is unchanged between v0.2.4 and
  0.2.4.1 — this has been latent since the script-domain commit in
  February 2026, only noticed now because the reporter hadn't reopened
  the modal in months.

  Fix: always apply the allowed-domains filter ({switch, light,
  input_boolean, script} — kept in sync with the regex in
  backend/app/schemas/smart_plug.py:17). Search composes on top as a
  substring match against entity_id or friendly_name, instead of
  replacing the domain filter. Whitespace-only search strings now
  fall back to the no-search behavior.
2026-05-17 08:20:28 +02:00
maziggy 96fd4bb7e3 fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
  and the reporter's diagnostic) but had been added to the H-family model
  gate in start_print_job. That single flag controlled both the firmware
  bool->int format (legitimately needed for the whole H-family, including
  H2S) and the dual-nozzle external-spool routing (correct only for actual
  dual-extruder printers).

  With no AMS attached and an external-spool slot (tray_id=254), the
  dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
  canonical 255 — exactly the failure the comment six lines above warns
  against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
  AMS mapping table". The use_ams=False fallback was also being skipped
  because the H-family bypass was meant for dual-nozzle routing.

  A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
  detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
  shares prefix "094" with H2D, so prefix detection misclassified it too.

  Split the conflated flag into two:

  - is_h_family — firmware format (int 0/1 for calibration fields).
    Includes H2S. H2S firmware structurally accepted the current command
    shape (failure was at AMS routing, not parsing), so the int format
    stays for H2S.

  - is_dual_nozzle — external-spool routing and use_ams gating. Excludes
    H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
    device.extruder.info, with a model-name fallback for the brief window
    after connect before push data arrives.

  The K-profile delete site and the kprofiles route now use the same
  runtime+model check instead of serial prefix.
2026-05-17 07:56:48 +02:00
maziggy 4cfa5f655a Updated CHANGELOG 2026-05-16 15:27:08 +02:00