Commit Graph
290 Commits
Author SHA1 Message Date
maziggy ecdf577cfc test(security-fixtures): nosec B104/B108 on new 0.2.4.6 test code
Two adversarial-input fixtures added on the 0.2.4.6 branch were
  missing the # nosec annotation that 32b3a93e established for the
  same pattern. New TestNotArmedDiagnosticLogging test for the #1429
  defensive diagnostic uses bind_address="0.0.0.0"; new
  test_queue_start_user_attribution.py (#1670 fix) uses a /tmp path
  in a PrintArchive fixture. Same annotation-only convention as the
  test_virtual_printer.py sites.
2026-06-09 13:32:15 +02:00
maziggy e7574fa67c Merge remote-tracking branch 'origin/main' into 0.2.4.6 2026-06-09 13:28:38 +02:00
maziggy f87b5bb3b8 feat(diagnostic, archives): install step 4 — proactive check + reactive banner
Two complementary surfaces for the most-missed install step ("Store sent
  files on external storage"):

  1. Connection diagnostic check (printer-side variant)
     - Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
     - Pass / fail / skip; instant, no I/O.
     - Catches the newer-firmware variant where the toggle moved onto the
       printer itself (P2S 01.02 / Studio 2.6+).

     An FTP upload-and-verify probe was tried first and rejected. /cache
     is always writable from Bambuddy regardless of the slicer setting;
     only BambuStudio's own behaviour changes when the toggle flips.
     Empirically confirmed against X1C + H2D with the slicer option
     toggled off: probe still succeeded, home_flag bit 11 stayed True.

  2. Archives-page banner (slicer-side variant)
     - The slicer-side toggle is invisible to the printer — older
       BambuStudio doesn't push the change to the printer. The diagnostic
       can't see it.
     - Symptom is deterministic: archiver creates rows with
       extra_data.no_3mf_available=True (main.py:2770) when it can't pull
       the 3MF from /cache after a slicer-initiated print.
     - New endpoint GET /archives/no-3mf-warning returns whether any
       archive in the last 30 days has the flag (excluding soft-deleted).
     - Amber dismissible banner at the top of /archives; one-shot
       localStorage dismissal (matches Layout.tsx update-banner pattern,
       but persistent across sessions).
     - React-Query disabled after dismissal so the endpoint isn't polled
       once the user has been told.
2026-06-09 12:20:01 +02:00
maziggy bebdb38e41 feat(print-log): per-row classification editor + fix silent-drop in GET
(#1687 part 4, reported by @IndividualGhost1905)

  Reporter clarified after part 1 shipped that point 2 wasn't about
  archive `tags` (which describe the model — home decor, toys), but
  about failure-cause classification on the *log* row itself:
  spaghetti, jam, bed-adhesion, etc. Different surface, different
  lifetime.

  The data field he wanted already existed. PrintLogEntry.failure_reason
  is a String(100); the Failure Analysis widget already groups by it;
  the Archive Edit modal already mirrors archive.failure_reason into
  the most recent log entry (archives.py:1421, shipped with #1444).
  The only gaps were:

  1. The GET endpoint silently dropped failure_reason (and archive_id
     and created_by_id) from PrintLogEntrySchema construction even
     when set in the DB — so the Print Log table couldn't render what
     the Failure Analysis widget grouped by. Fixed independently of
     the editor; regression test added.

  2. Orphan log entries (no archive — dispatch errors, aborts before
     archive creation, manual entries) had no edit path at all because
     the Archive Edit modal cannot reach them. The new endpoint is
     the only way to classify those rows.

  Changes:

  - Backend: new PATCH /print-log/{entry_id} taking
    {failure_reason, status}, gated on require_ownership_permission(
    ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape
    as the per-row DELETE. Validates against the same 11-key failure
    vocabulary and 5-key status set the Archive Edit modal uses;
    unknown values return 400 rather than getting stored as raw text
    (the i18n layer maps the value back through the vocabulary,
    unrecognised values would render as literal strings).
    Empty-string failure_reason stores back as NULL so the column's
    nullable=True intent is preserved end-to-end. GET endpoint now
    surfaces failure_reason, archive_id, created_by_id.

  - Frontend: FAILURE_REASON_KEYS moved to an export from
    EditArchiveModal.tsx so the new editor reuses the exact same
    vocabulary — backend and frontend stay in lockstep. Pencil icon
    beside the existing trash icon on every Print Log row, opens a
    compact two-field modal (status + failure reason). Save
    invalidates print-log and archives-stats query keys so the
    Failure Analysis widget reflects the re-classification on the
    same response cycle. Failure reason rendered as a sub-label under
    the status badge, matching PrintLogTable.tsx's convention.

  - i18n: 10 new keys (editEntryTitle, editEntryDescription,
    entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus
    a 5-key statuses block) translated across all 11 locales. No
    English fallbacks.

  - Wiki: features/print-log.md gains per-row actions section,
    updated permissions table, PATCH/single-DELETE endpoint docs.
2026-06-09 11:21:03 +02:00
maziggy 85fbd7fc35 fix(queue): persist "Print Anyway" so scheduler stops re-flagging it
When the user clicked Print Anyway on a filament-deficit warning, the
  acknowledgement was one-shot. The route cleared manual_start and
  filament_short, then the next scheduler tick re-ran
  compute_deficit_for_queue_item against identical spool state, found
  the same deficit, and re-set both flags. The item bounced between
  "user said anyway" and "scheduler re-blocked" — every Play click
  returned 409, every confirm got rolled back on the next tick.

  Add a persistent acknowledgement flag on the queue item:

  - New column `skip_filament_check` on print_queue. SQLite + Postgres
    migration branched on is_sqlite() so Postgres doesn't reject
    DEFAULT 0 on BOOLEAN.

  - PrintQueueItemCreate + PrintQueueItemResponse schemas + the
    TypeScript types carry the field.

  - POST /print-queue/{id}/start with skip_filament_check=true now
    ALSO sets item.skip_filament_check = True (not just clearing
    manual_start / filament_short).

  - PrintScheduler._block_on_filament_deficit short-circuits to
    False — no compute, no flag-setting, no notification — when
    item.skip_filament_check is True. We trust the operator's
    decision and stop fighting them.

  - PrintModal at queue-creation time threads
    skip_filament_check=true into the create payload when the user
    clicks Print Anyway on the frontend deficit warning, so a print
    that was warned-then-acknowledged at add-to-queue time goes in
    pre-acknowledged — scheduler never blocks it on first tick.

  Flag is not auto-cleared on spool swap by design: if remaining is
  now sufficient, the check returns no deficit anyway, so the flag
  is moot. Auto-clearing would add lifecycle complexity without
  changing behaviour.

  AMS Backup awareness (the other half of the discussion) intentionally
  NOT included — verified the H2D's bit-26 of print.cfg toggles with
  the printer-side AMS Backup setting, but the X1C's cfg has a
  different shape entirely and verifying every model family isn't
  realistic. Silently under-warning would be worse than always
  per-slot. The check stays single-slot for now.
2026-06-09 10:43:07 +02:00
maziggy 58d1b1eb74 fix(system): use PID 1 create_time for uptime/boot time on containers (#1690)
System -> Uptime / Boot Time read psutil.boot_time(), which on shared-kernel
  containers (Docker, LXC, Proxmox containers) is /proc/stat:btime - the host
  kernel's boot time, not the container's. Reporter on Proxmox LXC saw the
  Proxmox node's uptime instead of the Bambuddy container's.

  PID 1 is the container's entrypoint (or the host init on bare metal), and
  its create_time is the POSIX wall-clock timestamp of when it started.
  Switching to psutil.Process(1).create_time() reports the right value on
  containers and matches host boot within a sub-second on bare metal.

  Defensive fallback to psutil.boot_time() on psutil.Error / OSError so the
  endpoint still returns 200 with the best-available answer if /proc/1/stat
  is unreadable (locked-down container, custom seccomp policy).
2026-06-09 08:09:02 +02:00
maziggy 40729da013 feat(print-log): per-row delete on Print Log page (#1687 part 1)
Reporter noted the existing "Also remove this print from Quick Stats"
  toggle at archive delete is one-shot: if you kept stats then, there was
  no later way to drop the row; and rows without a backing archive
  (errors, aborts, manual entries) had no delete affordance at all.

  Backend: DELETE /print-log/{entry_id} mirrors delete_archive's
  ownership flow via require_ownership_permission(ARCHIVES_DELETE_ALL,
  ARCHIVES_DELETE_OWN). Owners drop their own rows; admins drop any row;
  missing IDs return 404 rather than 200-silently. /archives/stats
  aggregates over PrintLogEntry, so the filament / time / cost / count
  contribution drops out of Quick Stats in the same response cycle. The
  linked archive (if any) is untouched - the log row is a sibling, not a
  child.

  Frontend: trash icon next to the filament cell on every row, gated on
  the same permission shape as the archive trash. Confirm modal -> row
  gone. Mutation invalidates both print-log and archives-stats query
  keys so the totals re-render without a manual refresh.

  #1687 also asks for per-row tagging (already covered by
  EditArchiveModal's tags field) and per-row filament-usage-history
  edits (deferred - "restore deducted grams" is only consistent for the
  most recent usage row per spool; needs a separate design call).
2026-06-09 07:54:27 +02:00
maziggy 68877c639e feat(settings): split API slicer + Open-in-Slicer preferences (#1329)
Reporter wanted to slice via the Bambu Studio sidecar but open files
  locally in OrcaSlicer. preferred_slicer drove both the in-app
  SliceModal sidecar selection AND the desktop "Open in Slicer" URI
  handoff, so picking one forced the other.

  New open_in_slicer setting (str | None) drives only the desktop URI;
  null inherits from preferred_slicer so existing installs behave
  identically. Storage in the existing app_settings key/value table;
  GET normalises the "None" string back to null mirroring the
  default_printer_id convention.

  Frontend: Settings -> Slicer card adds a second dropdown ("Open in
  Slicer" with "Same as API slicer" / Bambu Studio / OrcaSlicer);
  ArchivesPage, MakerworldPage, ModelViewerModal switch desktop-URI
  call sites to open_in_slicer ?? preferred_slicer. MakerworldPage's
  "Slice in {{slicer}}" label additionally branches on useSlicerApi
  so the label matches what the button actually dispatches.
2026-06-08 10:39:21 +02:00
maziggy 7b4c5b3c1f fix(vp): show target printer's serial on proxy-mode VP card
The runtime services (SSDP, MQTT bind identity, cert subject) already
  advertise the target printer's serial via target_printer_serial or
  self.serial in proxy mode, but the API response that drives the VP
  settings card always returned the self-generated suffix-based serial.
  The card therefore displayed a serial that didn't match what slicers
  see, breaking the "one identity per VP" mental model.

  _vp_to_dict now resolves vp.target_printer_id -> Printer.serial_number
  when mode == VP_MODE_PROXY and substitutes the result into the response
  serial field. Archive / queue / review keep the self-generated serial
  (those modes never speak the target's identity). Orphaned target falls
  back to self-generated so the card still renders.
2026-06-08 09:54:56 +02:00
Samed Yüksel 66c09dff2d feat(inventory): CSV import/export for the inventory page (#1576) (#1659) 2026-06-08 09:30:16 +02:00
maziggy 47fe30c5ad fix(queue): credit user who clicks /start in print log when auth on (#1670)
The print log's User column came from printer_manager.get_current_print_user,
  but only background_dispatch (Archive Print, Library Print) ever populated
  that dict. The Queue manual-start path went straight from
  POST /queue/{id}/start into PrintScheduler._start_print, neither end
  recording the clicker — so any print started from the queue landed in
  PrintLogEntry with created_by_username NULL even with auth enabled.

  Two-sided fix: /start now writes user.id to item.created_by_id when no
  prior owner is set (preserves UI-added items' original uploader), and
  PrintScheduler gains _propagate_owner_to_printer_manager called from
  _start_print to hand the owner into set_current_print_user before the
  print command goes out.
2026-06-07 08:22:42 +02:00
maziggy d82f4e032f fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)
Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
  the spool from the Dashboard left the slicer's filament dropdown
  showing "unknown", but clicking Configure right after made the
  slicer recognize it correctly. "Configure" felt like a mandatory
  follow-up step rather than a refinement.

  Bambu cloud uses three preset-ID shapes:
    GFS…   — Bambu official cloud preset
    PFUS…  — cloud user-created preset
    PFCN…  — cloud shared / partner preset (Polymaker's "(Custom)"
             Bambu Lab H2D variants ship this prefix)

  apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
  cloud-detail lookup that extracts the underlying filament_id. PFCN
  slipped past the cloud-lookup branch, fell into the local-preset
  int() parse path, raised ValueError, dropped into
  normalize_slicer_filament which returns any P-prefix unchanged, and
  the raw PFCN landed in tray_info_idx. The printer's calibration
  table can't index that, so the slicer rendered "unknown". The
  Configure modal rescued every assign because it does its own
  getCloudSettingDetail and writes the resolved filament_id.

  Extend the cloud-detail-lookup branch (inventory.py:129) and the
  discard safety net (inventory.py:223) to include PFCN alongside
  GFS/PFUS. Three behaviours fall out:

    * Cloud-authenticated: the real filament_id from
      detail["filament_id"] ships as tray_info_idx (Polymaker PLA
      Matte resolves to GFL05).
    * Cloud unavailable: raw PFCN discarded, the slot reuses an
      existing valid P-prefix preset if material matches.

  Source comment now lists all three cloud-ID shapes so the next time
  Bambu invents a new prefix the maintainer doesn't have to re-derive
  the structure from a bug report.
2026-06-06 10:17:09 +02:00
maziggy 19073f5c84 fix(vp): auto-derive access code from target printer in non-proxy modes
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
  a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
  bytes through to the real printer. The slicer holds one code in its
  profile (the one it bound the VP with), and that code has to satisfy
  both the VP listener and the real printer at the far end of the
  bridge. If the codes diverge the bridge silently fails at the second
  hop — slicer reaches .49:8883, FINs before sending a ClientHello,
  retries identically. The wiki framed the code-match requirement as a
  camera-only concern; it isn't, all bridged protocols inherit.

  Fix removes the foot-gun instead of re-documenting it. When a target
  is selected on a non-proxy VP the access-code field switches to a
  read-only display showing the target's code with an Eye-toggle
  reveal; the backend auto-inherits on every create / update (any
  explicit access_code submitted alongside a target is silently
  overridden as belt-and-braces for non-UI clients). The required-when-
  enabling check now treats target-set as satisfying the access-code
  requirement. Standalone (no-target) non-proxy VPs still get the
  editable input + Save button.

  One-shot startup migration corrects any pre-existing mismatched
  rows: SELECTs diverged VPs and logs one INFO line per row for the
  audit trail, then UPDATEs via correlated subquery. Idempotent and
  portable between SQLite and Postgres.
2026-06-05 17:19:16 +02:00
maziggy 4851f54595 feat(file-manager): split "All Files" into internal-only + External views (#1621)
Reporter linked a NAS and the auto-imported files drowned their own
  Bambuddy uploads in the "All Files" sidebar listing. There was no filter
  to escape it — only per-folder clicks. Restore the pre-external semantics:
  "All Files" now lists managed-storage files only. The combined
  across-every-external view moves to a new sibling sidebar entry,
  "External", that only appears when at least one external folder is linked.

  Backend: GET /api/v1/library/files gains internal_only and external_only
  query flags. Filter is on LibraryFile.is_external. Both flags set is a
  400, not a silent pick-one.

  Frontend: new topLevelView state on FileManagerPage (default internal);
  the query passes the scope only when selectedFolderId is null. Mobile
  selector dropdown uses __top:internal / __top:external sentinels so the
  same state round-trips through option values. Empty-state copy
  distinguishes internal-empty from external-empty.
2026-06-05 10:11:03 +02:00
maziggy 9554ebd05d refactor(inventory): rename /reset-usage to /reset-consumed-counter to match what it actually does (issue #1644)
The old endpoint name implied that calling it would drop weight_used to
  0. In practice it only stamps weight_used_baseline = weight_used so the
  Inventory page's "Total Consumed" widget (weight_used - baseline) reads
  0 going forward, while remaining (label_weight - weight_used) is
  preserved. Calling the endpoint via curl and seeing weight_used
  unchanged in the JSON response is confusing.

  New paths:
  - internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
             /api/v1/inventory/spools/reset-consumed-counter-bulk
  - spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
             /api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk

  Behaviour is unchanged in both modes; internal stamps the baseline
  directly, Spoolman-mode PATCHes upstream used_weight=0 and the
  _map_spoolman_spool read mapping reconstructs the same "displayed
  consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
  between modes was already in place and is preserved.

  The Spoolman-client method reset_spool_usage keeps its name because it
  describes what is sent upstream to Spoolman, not what Bambuddy's
  endpoint promises to callers.

  Frontend:
  - api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
    renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
  - Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
    counters" (short, unambiguous); tooltips and confirm-modal bodies
    still spell out the full semantics.
2026-06-05 09:22:05 +02:00
maziggy 32b3a93e60 test(security-fixtures): suppress Bandit B108/B104 on adversarial inputs
The hardcoded /tmp paths and 0.0.0.0 bind in
  test_archives_api.py / test_attach_timelapse_safe_path.py /
  test_vp_mqtt_bridge.py are deliberate adversarial-input fixtures for
  the path-traversal containment tests and the #1429 bind_address=0.0.0.0
  auto-resolve path — not insecure temp-file usage by the tests. Same
  nosec-without-comment pattern as the existing test_virtual_printer.py
  sites.
2026-06-03 14:37:31 +02:00
maziggy 673001e3cd fix(maintenance): #1596 persist wiki_url on Custom Type create + correct
inline mutation type

  POST /api/v1/maintenance/types hard-coded the MaintenanceType
  constructor and silently dropped `wiki_url`, so the Documentation URL
  field disappeared after save. PATCH worked because it uses
  `data.model_dump(exclude_unset=True) + setattr`, which is why editing
  a freshly-created type DID save the URL — masking the bug under any
  "save then immediately fix it" retest. Reporter @BurntOutHylian
  pre-triaged the issue to the exact constructor call at
  routes/maintenance.py:206-213; fix is the missing `wiki_url=data.wiki_url`
  argument.

  Frontend nit from the same report: MaintenancePage.tsx:1131's
  `updateTypeMutation` declared `data: Partial<{ name; default_interval_hours;
  interval_type; icon }>` — omitting `wiki_url`. The value reached the
  API correctly at runtime because `api.updateMaintenanceType` accepts
  `Partial<MaintenanceTypeCreate>` (which has wiki_url), but the inline
  type lied about the payload shape. Extended the inline `Partial<{...}>`
  to include `wiki_url?: string | null`. Pure type fix — no runtime change.
2026-06-02 14:58:10 +02:00
maziggy a837a3acbd ● fix(library): #1600 thumbnail extraction for external-folder .gcode.3mf
files + unify file_type classification across ingest paths

  #1600: external-folder sliced outputs landed
  with no thumbnail. Cause: four backend ingest paths classified
  LibraryFile.file_type differently for the same .gcode.3mf family.
  upload / ZIP-extract / in-process used os.path.splitext()[1] which
  returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching
  the thumbnail-extraction gate at library.py:1467 (file_type == "3mf").
  External-folder scan explicitly detected the compound and stored
  file_type="gcode.3mf" — preserving "sliced output" identity — but
  then skipped both the "3mf" gate and the "gcode" gate, so the file
  landed with thumbnail_path = None. Same compound-extension drift that
  bit #1543 in the 3D preview, in a surface that audit didn't trace
  back to.

  Unified fix:

  - New classify_file_type(filename) helper in routes/library.py is the
    single source of truth. Returns "gcode.3mf" for sliced outputs and
    ext[1:] otherwise.
  - Applied to every ingest path: upload (line 1704), ZIP-extract
    (1998), external-folder scan (the bug site — the manual compound
    check is replaced), and in-process save_3mf_from_bytes (471, used
    by MakerWorld import).
  - External-scan thumbnail gate widened to
    `if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip
    with Metadata/plate_1.png; ThreeMFParser doesn't care about the
    trailing extension.
  - gcode-download endpoint at GET /library/files/{id}/gcode had the
    same drift in reverse: gate was `elif file.file_type == "3mf":` so
    a row stored with file_type="gcode.3mf" (the external-scan path's
    pre-unification behaviour, and the canonical going forward) got
    rejected with HTTP 400. Widened to the same compound-aware tuple.

  One-shot DB migration in core/database.py::run_migrations backfills
  existing legacy rows:

    UPDATE library_files
       SET file_type = 'gcode.3mf'
     WHERE file_type = '3mf'
       AND LOWER(filename) LIKE '%.gcode.3mf'

  Idempotent (post-update rows no longer match the file_type='3mf'
  predicate, so re-runs at every boot are no-ops) and dialect-neutral
  (LOWER + LIKE are identical under SQLite and Postgres). Without the
  backfill, users would have a permanent split state: old uploads at
  '3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced
  outputs in the dashboard stats query at line 4615 and show two
  entries in the file-manager filter dropdown for the same conceptual
  type.

  Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx
  already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After
  the migration the DB only contains canonical values, so the legacy
  '3mf' branches in the frontend become dead code for sliced files —
  they stay as defence-in-depth in case any future ingest path I
  missed reverts to the legacy classifier.
2026-06-02 14:51:08 +02:00
maziggy 5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
  that rules out subnet / mDNS-reflector theories): with the physical printer
  off the slicer's "Send" landed in Bambuddy's archive; once the printer
  powered on every subsequent "Send" went straight to the printer's SD card
  and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
  receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
  once the printer was online.

  Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
  _vp_ip_uint32_le ONLY on client-identity change and early-returned on
  every refresh tick when the same client object was still bound. If
  target_client.ip_address was empty at first bind (DB row stale, or client
  constructed before SSDP refresh filled it in), the encoding stayed None,
  the net.info[*].ip rewrite block was skipped, the cache filled with the
  real printer IP, sticky-key preservation kept the poisoned net value
  alive across every subsequent incremental push, and the slicer followed
  the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
  workaround both reporters independently arrived at. Same shape on
  multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
  whose ip equalled _target_ip_uint32_le, so a secondary interface IP
  Bambuddy never saw would leak through unchanged.

  Bridge fix:
  - _resolve_client calls a new _refresh_ip_encoding() on every refresh
    tick, even when client identity is unchanged; self-heals once
    ip_address becomes valid.
  - _refresh_ip_encoding() sweeps the existing _latest_print_state when
    encoding becomes valid for the first time. Without the sweep,
    sticky-key preservation keeps the pre-arm poisoned cache alive
    forever — incremental pushes that don't include net carry the bad
    value forward.
  - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
    that doesn't already equal the VP IP, not just entries matching
    _target_ip_uint32_le. Multi-NIC printers stop leaking secondary
    interfaces. Zero-IP placeholders are left alone so "active interface"
    detection still works.
  - INFO logging on encoding arm/update and on cache sweep so future
    bundles directly answer "did the rewrite fire?".

  Mode wire-value rename (#1429 follow-up, separate confusion source):
  - Both reporters' support bundles showed mode: immediate while the UI
    said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
    immediate in the support-info.json file. In the webui the printer is
    set to archive". UI button "Archive" had always saved immediate, and
    "Queue" had always saved print_queue. Canonical wire values are now
    archive / review / queue / proxy, matching the button labels 1:1.
  - New normalize_vp_mode() + VP_MODE_* constants in
    models/virtual_printer.py; manager.py normalises on construction so
    a legacy row read pre-migration still dispatches correctly.
  - core/database.py::run_migrations rewrites existing virtual_printers
    and settings rows; idempotent (re-runs are no-ops); identical SQL
    under SQLite and Postgres.
  - API routes accept both legacy and canonical on input, normalise
    before storage. GET /settings/virtual-printer normalises on read so
    the frontend's mode-button highlight works for stale legacy values.
  - Three frontend VP components (VirtualPrinterSettings,
    VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
    and type aliases to canonical; each got its own normalizeMode()
    helper so a stale-cached settings payload still highlights the right
    button. Two pre-existing `printer.mode === 'queue' ? 'review'`
    legacy mappings in VirtualPrinterCard were the source of a test
    failure caught mid-implementation where the new canonical 'queue'
    was being mis-aliased back to 'review' and hiding the auto-dispatch
    + force-color-match toggles.

  mode handler is NOT the dispatch bug: manager.py::_archive_file (the
  handler for archive mode) doesn't dispatch to the physical printer.
  The "files end up on the printer's SD card" symptom was the IP-leak
  from the bridge cache. The mode rename is purely clarity / support-
  bundle accuracy.
2026-06-02 14:33:20 +02:00
maziggy 1e08c25a9f fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
Two stacked causes under-reported multi-plate prints in the project
  rollup and the archive card.

  Root cause 1 - parser only read plate 1.

  ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
  prediction / weight from that one element. Any multi-plate file's
  archive-level print_time_seconds / filament_used_grams reflected
  plate 1 alone. The /plates endpoint already looped findall and was
  correct, which is why the plate carousel showed the right numbers
  while the archive card was wrong.

  Fix: loop every <plate> and sum prediction + weight. Per-plate
  concepts (plate_number, _plate_index, printable_objects) only set
  when there's exactly one plate - for multi-plate exports the
  archive represents all plates and a single index doesn't apply at
  the file level. bed_type keeps the first plate's value as a
  best-effort default. Malformed prediction / weight on individual
  plates skip cleanly rather than poison the sum.

  Root cause 2 - project rollup aggregated PrintArchive, not the
  per-run log.

  compute_project_stats and list_projects quick-stats summed
  PrintArchive.print_time_seconds / filament_used_grams / cost /
  energy_* WHERE project_id. A reprint reuses the source archive row
  and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
  archive - and that archive's numbers were already plate-1-only from
  cause 1. The Archive Print Log path was already correct because it
  drove off print_log_entries (archives.py:420 comment).

  Fix: both compute_project_stats and the list_projects quick-stats
  block inner-join print_log_entries -> print_archives WHERE
  archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
  failed_prints counts runs in failed/aborted/cancelled/stopped,
  completed_items is SUM(PrintArchive.quantity) for runs where
  status='completed', time/filament/cost/energy from PrintLogEntry.
  Orphan log rows (archive_id IS NULL post archive deletion) are
  excluded by the inner join.

  Same-shape fixes carried forward (no follow-ups per project rule):

  system.py system-info totals: total_print_time / total_filament
  had the same bug shape - summed PrintArchive directly so reprints
  collapsed to one row. Now sums PrintLogEntry.duration_seconds /
  filament_used_grams. The semantic shift is also a correctness
  improvement: the field now reflects time the printer actually spent
  printing, not slicer-estimated time.

  archives.py time-accuracy metric: estimate / actual per run where
  estimate = PrintArchive.print_time_seconds. Post-parser-fix
  multi-plate archives have file-level estimate but per-run actual =
  one plate, so ratio = N x 100% for an N-plate file. The calc now
  clamps each row to the [50%, 200%] plausibility band before
  contributing to the printer-level average; single-plate accuracy
  (the case the metric is designed for) stays fully included.

  Backfill: users with AMS spool tracking - the reporter's case - have
  per-run filament_used_grams from the tracked spool delta, so stats
  become correct immediately. Users without tracking fall back to the
  archive estimate and undercount until they reprint. Archive card
  still reads PrintArchive.filament_used_grams directly so old
  multi-plate archives keep plate-1-only numbers until reslice -
  forward-only as the reporter accepted.
2026-06-02 13:35:06 +02:00
maziggy c9bc5eb4e9 fix(webhook): #1584 PrinterState dataclass attribute access in status / stop / cancel
webhook.py treated printer_manager.get_status() return as a dict and
  called .get(...) on it. The return is a PrinterState dataclass
  (backend/app/services/bambu_mqtt.py), so the call raised AttributeError
  and Starlette surfaced it as a generic 500 for every printer with a
  status row. Non-existent printers correctly returned 404 because the
  early "Printer not found" branch fired before the crash.

  Reporter's repro matched exactly: id 1 (existing printer) returned 500,
  id 2 and id 3 (no row) returned 404. Verified end-to-end against a live
  PG-backed instance with the reporter's key shape — same 500 before the
  patch, 200 with the correct payload after.

  8 crash sites across 3 routes:
    - webhook_get_printer_status   GET  /printer/{id}/status      5 sites
    - webhook_stop_print           POST /printer/{id}/stop        2 sites
    - webhook_cancel_print         POST /printer/{id}/cancel      2 sites

  Every status.get("X", default) replaced with status.X if status else
  default. Pydantic response schema unchanged; PrinterState's dataclass
  defaults cleanly cover the "registered but never connected" branch so
  the status route now returns 200 with connected=false, state=null
  rather than crashing.
2026-06-02 12:57:51 +02:00
maziggy 396e9aa09e security: harden path-traversal class across routes + services; fifth CI backstop
Two attacker-controlled strings were being joined to library_dir with no
  resolve + containment check in the project ZIP import endpoint:

    - linked_folders[*].name from the request's project.json
    - per-entry zf.namelist() paths from the ZIP itself

  An absolute path in either field collapsed the join (Path("/lib") / "/etc"
  becomes Path("/etc") because pathlib discards the left side when the right
  is absolute) and the next write_bytes landed wherever the attacker chose.

  Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
  had NO validation on filename and FileResponse-served arbitrary paths -
  the DELETE counterpart at least gated on the photos membership check.

  Adjacent finding from the services audit: ArchiveService.attach_timelapse
  wrote archive_dir / filename where filename ultimately came from a printer's
  FTP listing (compromised-printer threat model) or the /timelapse/select
  query param. A malicious printer that exposes a directory entry with ..
  segments could write the timelapse outside the archive directory.

  New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
  single source of truth: rejects empty / null-byte / absolute parts up-front,
  joins under parent, resolves both sides, asserts is_relative_to. Returns the
  resolved canonical path on success, raises HTTPException(400) on escape, or
  PathTraversalError when http=False (for service-layer callers that need to
  match a non-HTTP return contract).

  Wired into the import vectors, both archive photo handlers, and the
  attach_timelapse service. The full audit sweep inspected every Path/Name
  join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
  sites + 8 service-layer sites confirmed safe and tagged with
  # SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.

  Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
  AST-walks both layers and fails the build on any <dir-like>/<bare variable>
  join that doesn't either route through safe_join_under or carry the marker.
  The services layer is in scope because it receives values verbatim from the
  routes AND from external sources Bambuddy has no control over (the printer
  FTP-listing case above).

  SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
  the rule now names the printer FTP-listing case explicitly so future
  services-layer audits set the right expectation.

--------------

  fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files

  Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
  "solid test\nendsolid test" shape) produced one WARNING per file in
  stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
  correct - trimesh returns a valid Mesh with zero vertices, the safeguard
  matches, and the function returns None so the library entry is still
  created without a thumbnail - but the volume turned a successful upload
  into thousands of WARNING lines in the journal.

  Two changes:

  1. The per-file "Failed to load STL or empty mesh" message in
     stl_thumbnail.py is now logger.debug instead of logger.warning. It's
     a per-file content observation, not an actionable error; the caller
     already handles None correctly. The branch now catches the rare
     "large enough but trimesh still can't parse it" case, visible in
     debug logs without spamming production.

  2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
     with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
     below any real STL). The three thumbnail call sites in library.py
     (extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
     pre-skip files below this size before calling generate_stl_thumbnail.
     Stubs never enter the trimesh pipeline at all.

  Behavior is unchanged for real STLs: any file >=200 bytes runs through
  the existing pipeline, MAX_VERTICES still triggers simplification at
  100k vertices for the 256x256 thumbnail render, large files still get
  thumbnails.

------------

  fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)

  On first STL upload, three matplotlib-internal log lines surfaced:

    WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
    INFO    [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
    INFO    [matplotlib.font_manager] generated new fontManager

  The writable-dir warning fired because Bambuddy's $HOME isn't writable for
  matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
  which lost the font cache on every host reboot, so font_manager rebuilt it
  each cold start - producing another batch of INFO lines.

  Fix is two small additions in stl_thumbnail.py before the matplotlib import:

  1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
     settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
     persists across container restarts and the writable-dir warning never
     fires. Respects an externally-set MPLCONFIGDIR so operators who chose
     their own path aren't overridden. Best-effort with a debug fallback if
     settings can't be imported or the mkdir fails.

  2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
     import demotes the per-font INFO scan that fires when font_manager
     builds its cache cold. Real font warnings (>= WARNING) still surface.

  3 new tests: font_manager logger at WARNING after module import;
  _configure_matplotlib_cache creates the directory under base_dir and sets
  MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
  5516 backend tests green, frontend gates clean.
2026-06-02 12:12:54 +02:00
maziggy be15a375a6 fix(oidc): #1569 populate User.email from standard 'email' claim when email_claim is preferred_username
When an operator configures `Email Claim = preferred_username` (e.g. Authentik) the
  primary `_resolve_provider_email` correctly rejects the identity value as non-email
  shaped and returns None, leaving auto-provisioned users with `email=None` even though
  the same token carries a valid standard `email` claim.

  Add a narrow fallback in the auto-create-users branch only: when
  `provider.email_claim != "email"` and the primary returned None, resolve the standard
  `email` claim with the same Fall A/B shape + email_verified enforcement and use it for
  `User.email` and `UserOIDCLink.provider_email`.

  The auto-link-existing-accounts gate is left on the primary `provider_email`, so the
  GHSA Fall-B / Fall-C guards remain intact - the fallback never feeds account matching.
2026-06-02 10:23:21 +02:00
maziggy b7d7c82501 fix(security): WebSocket auth gate + audit-driven hardening sweep 2026-06-02 10:02:17 +02:00
maziggy ec51394196 fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
  documented scope flags (can_read_status / can_queue / can_control_printer)
  enforced only inside /api/v1/webhook/* to an explicit per-Permission
  allowlist consulted by every dependency:

    - core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
      Permission to one scope flag on APIKey; unmapped = 403.
      _check_apikey_permissions now takes the api_key and checks the flag.
    - require_any_permission_if_auth_enabled + require_ownership_permission
      were returning None for any valid key with zero scope check; both now
      invoke _check_apikey_permissions and fail closed.
    - Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
      UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
      (INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
      SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
      "queue-only" keys keep working and hardened "read-only" keys do not
      silently gain writes.
    - CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
      alongside the existing _cloud_api_key_gate.
    - Migration column-existence check (_api_keys_column_exists) gates the
      backfill so user-edited values are never overwritten on restart.

  Structural drift backstop: test_every_permission_has_a_classification fails
  CI on any new Permission added without an explicit scope mapping —
  prevents the denylist-shape regression that grew the prior surface.

  Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
  green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
  table + allowlist callout + upgrade notes updated.
2026-06-02 08:28:24 +02:00
maziggy e10462678f fix(security): GHSA-6mf4-q26m-47pv — fail-closed on auth-probe DB errors (CVSS 9.8)
is_auth_enabled() and auth_middleware both caught every exception during
  the auth-state probe and returned the "allow" answer instead of denying
  the request. Reporter's PoC floods /api/v1/auth/login to exhaust file
  descriptors, forcing the next SQLite connect to raise, then hits a
  protected endpoint during the fail-open window with no token — granting
  unauthenticated access to admin-account creation, API-key creation, DB
  backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6.

  Fix:
  - is_auth_enabled (backend/app/core/auth.py): only returns False for the
    legitimate "settings row absent" case; any actual exception propagates
    so the caller can deny the request.
  - auth_middleware (backend/app/main.py): returns 503 on any probe failure
    instead of await call_next(request).

  4 new regression tests in test_auth_fail_closed.py pin the contract
  (propagates DB exceptions, returns False for no-row, True for "true",
  False for "false"). 1 existing security test renamed and updated to
  accept either 500 or 503 (both fail-closed) and to verify the
  SQLAlchemy detail does not leak in the body.

  Codebase grep confirmed no other auth-decision predicate has the same
  fail-open shape: _validate_api_key returns None on catch (→ 401 fail-
  closed downstream), is_advanced_auth_enabled propagates correctly,
  permissions.py has no catch-alls.

  Reported by @wondercrash via private advisory.
2026-05-30 13:49:09 +02:00
maziggy 2241924312 fix(library): reject FAT32-illegal filename chars at rename/upload/queue time (#1540)
Bambu printer SD cards are FAT32/exFAT, which forbids < > : " / \ | ? *
  plus control chars and trailing dots/spaces. Library rename only blocked
  path separators, so a name like L|R.3mf was accepted and only failed
  later at FTP upload with 553 Could not create file - far from the rename
  action that caused it. Bambu Studio refuses these names in its save
  dialog; Bambuddy now does the same.

  New backend/app/utils/filename.py centralises validation. Wired into
  update_file, upload_file, print_library_file, and queue add. Existing
  rows with bad names are left alone (no silent rewrite of user data);
  users get an actionable 400 pointing at rename.

  Frontend rename modal mirrors the same set client-side with inline error.
  New fileManager.invalidFilenameChar i18n key translated across all 9
  locales. 26 new tests in test_filename_validation.py.
2026-05-27 09:54:07 +02:00
maziggy e9beb1e8fc fix(archives): handle fallback archives in source-3MF upload (#1531)
Archives created from prints Bambuddy didn't archive (cloud / Handy /
  SD-card prints) carry file_path="". The two source-upload routes
  computed the destination as (base_dir / archive.file_path).parent /
  "source", which collapsed to base_dir.parent / "source" for fallback
  rows — sending the file to /app/source/ (outside the data volume,
  orphaned on container restart) and raising 500 on the final
  relative_to.

  Centralise the destination math in _resolve_source_3mf_path. Normal
  archives keep the <archive>/source/<filename> layout. Fallback
  archives land at <base_dir>/archive/no_source/<id>/<filename>, which
  stays inside the data volume and is addressable by every existing
  read site. The helper also asserts the resolved directory is under
  base_dir.resolve() so a corrupted row fails with a clear message
  instead of writing outside the volume.

  Both upload routes (upload_source_3mf and upload_source_3mf_by_name)
  now route through the helper. Two regression tests in
  TestUploadSourceThreeMF pin both branches.
2026-05-26 11:04:46 +02:00
maziggy 4387a09162 fix(spoolbuddy): route weight sync by inventory mode exclusively (#1530)
POST /spoolbuddy/scale/update-spool-weight tried the local DB first
  and only fell back to Spoolman on a local miss. Combined with
  nfc/tag-scanned's post-#1119 always-Spoolman routing, a stale local
  Spool row sharing a numeric id with a Spoolman spool would absorb
  the sync silently while the Spoolman row stayed unchanged.

  Mirror the routing already used by nfc/tag-scanned: pick the branch
  via _get_spoolman_client_or_none() and never cross. Local mode now
  returns 404 on a local miss instead of falling through.

  New TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman
  asserts both directions: Spoolman gets the update, the colliding
  local row's weight_used and last_scale_weight are untouched.
2026-05-26 10:36:37 +02:00
maziggy c0c07bc509 fix(archives): cross-model re-slice no longer carries source printer_id
When the user re-sliced an H2D archive for X1C, the new archive card and
  reprint modal both showed the source printer's name (e.g. "Workshop H2C")
  even though sliced_for_model on the same row correctly read "X1C".

  slice_and_persist_as_archive copied source_archive.printer_id verbatim
  onto the new PrintArchive row. Both the archive card
  (ArchivesPage.tsx:3571) and the reprint modal read printer_id first and
  only fall back to sliced_for_model when it's None. With printer_id set
  to the source's H2D printer, neither could see that the new file is for
  a different model.

  Same shape as the sliced_for_model fix already in the same function — a
  cross-model re-slice means the source's physical printer isn't where
  this output prints anymore. When the slicer-baked target model differs
  from source_archive.sliced_for_model, drop printer_id so both surfaces
  fall back to the sliced_for_model badge ("X1C"). Same-model re-slices
  keep printer_id so the reprint modal still pre-selects the source
  printer.

  Edge case: when source_archive.sliced_for_model is None (older archives
  that predate that column being populated), we can't tell whether this
  is a cross-model re-slice. Fail open and preserve printer_id rather
  than spuriously nulling it.

  slice_and_persist (the library-file path) doesn't have this bug —
  LibraryFile has no printer_id column.

  Tests in TestSliceArchiveResliceModel cover all three branches: cross-
  model nulls printer_id; same-model keeps it; unknown source model
  preserves it.

  Surfaced via the bambuddy demo doing H2D -> X1C re-slices after the
  .bbscfg System-tier slicing fix landed.
2026-05-23 14:25:12 +02:00
maziggy 4096d8d6bd fix(csp): nonce-based script-src so Cloudflare-injected scripts pass (#1460 follow-up)
Behind Cloudflare, the bot-detection script CF injects into every HTML
  response carries a hash that rotates per request, so it can never be
  allowlisted by hash. Reporters with CF in front had to relax their NPM
  CSP to 'unsafe-inline' as a workaround.

  Per Cloudflare's documented behaviour, when a nonce is present in the
  page's script-src, CF clones it onto its injected <script>. The SPA CSP
  now stamps a fresh per-request nonce via secrets.token_urlsafe(16),
  keeping 'self' for our own scripts (index.html has had no inline scripts
  since the SW registration moved to /sw-register.js in the original
  #1460 PR), so no HTML body rewriting is needed.

  Also folded in: /manifest.json, /sw.js and /sw-register.js now accept
  HEAD as well as GET, so `curl -I` and uptime scanners stop returning
  405 on those routes - a separate red herring during this issue's
  debugging.

  Tests: 3 new in test_security_headers.py - 'nonce-' token stamped into
  SPA script-src while 'self' remains and 'unsafe-inline' does not; nonce
  is fresh per request across 5 sequential calls; HEAD on the three PWA
  routes never returns 405. 22/22 security-header tests green; backend
  ruff clean.
2026-05-23 12:28:58 +02:00
maziggy 4686d108ef feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
2026-05-23 12:16:34 +02:00
maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
2026-05-23 08:47:46 +02:00
maziggy e222a0ef0e feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection
  Diagnostic. Scans Bambuddy's recent app log against a curated allowlist
  catalog of known failure signatures (rejected access code, FTPS :990
  timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite
  "database is locked" contention), dedupes and classifies each finding
  as layer8/environment/bug, and deep-links to the troubleshooting wiki.
  Sample log lines are sanitized before they leave the process. Exposed
  via GET /system/health and surfaced on two surfaces sharing one
  SystemHealthPanel component: a System Health section on the System
  page, and inline in the bug reporter when the form opens.

  The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight:
  saving runs the connection diagnostic and, on a failed check, warns with
  a "save anyway" escape hatch instead of silently saving a printer that
  will immediately show offline.

  Log read/parse/sanitize primitives extracted from routes/support.py into
  a shared services/log_reader.py (behaviour-preserving); affected support
  tests repointed accordingly.

  Tests: test_log_health.py (11), test_system_api.py (2 new),
  SystemHealthPanel + BugReportBubble + AddPrinterPreflight +
  EditPrinterPreflight (8 frontend). All strings translated across the 9
  locales. Backend ruff clean, full unit suite green, frontend build +
  eslint clean, i18n parity green.
2026-05-22 16:31:16 +02:00
maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
Two recurring virtual-printer support pains, both on the Virtual Printers
  settings page.

  Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
  checklist — VP enabled, services running, bind interface still exists, access
  code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
  / discovery ports on the bind IP. start_server swallows per-service bind
  errors, so a service object can exist while nothing is listening; probing the
  bind IP from outside is the only reliable signal and it catches the common
  "VP not visible in the slicer" bind-IP-conflict and stale-interface cases.

  Slicer certificate: virtual printers present a TLS cert signed by a shared CA
  the slicer must trust. Until now users had to docker exec in and cat
  bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
  and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
  GET /virtual-printers/ca-certificate returns only the public certificate; the
  CA private key never leaves the backend. The CA is generated on demand so the
  button works before the first VP is enabled.

  Backend:
  - services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
  - schemas/virtual_printer.py — VPDiagnosticResult
  - CertificateService.get_ca_certificate_info() + manager helper
  - routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic

  Frontend:
  - VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
  - caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
    non-secure-context fallback + downloadTextFile), de-duplicating the
    existing FQDN-copy logic
  - vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales

  9 backend unit tests + 4 route integration tests + 6 frontend tests.
  Backend ruff clean, frontend build clean, i18n parity green.
2026-05-22 13:24:35 +02:00
maziggy 4925b4c830 fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard
Five follow-up fixes to cross-printer re-slicing, all surfaced while
  testing archive re-slices.

  1. Re-sliced archive now records the printer it was sliced FOR.
     slice_and_persist_as_archive copied sliced_for_model from the source
     archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
     from the freshly-sliced 3MF's parsed metadata instead, falling back
     to the source only when absent.

  2. Real slicer rejections are surfaced instead of silently masked.
     _run_slicer_with_fallback retried with the 3MF's embedded settings on
     any sidecar 5xx — including genuine content rejections (object off
     the bed, incompatible filament temps), which "succeeded" only by
     re-slicing for the source's original printer. A new
     _slicer_rejection_message detects the slicer's own error string and
     surfaces it as a 400; the embedded-settings fallback is kept only for
     true CLI crashes.

  3. A failed slice opens an error modal, not a 3s toast. The slicer's
     reason is actionable and a toast hides it before it can be read. New
     AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
     failed job. New slice.failedTitle key in all 9 locales.

  4. Sliced files no longer report "0 g" filament usage. The sidecar
     doesn't always populate the X-Filament-Used-* headers;
     ThreeMFParser._parse_gcode_header now also reads the slicer's own
     "total filament weight/length" from the G-code header, and both
     slice-persist paths fall back to it when the sidecar reports 0.

  5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
     dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
     multi-extruder validation; both slice routes now reject it up front
     with a clear 400. The dual-nozzle model classification — previously
     an inline tuple duplicated across start_print and the K-profile
     routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
     in printer_models.py, consumed by all three sites and the guard.

  Full cross-nozzle-class re-slicing (dual-nozzle project_settings
  reconciliation) remains separately tracked.

  Tests: _slicer_rejection_message, _canonical_printer_model,
  guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
  filament parse, AlertModal, and end-to-end slice-API coverage including
  an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
  build clean.
2026-05-22 12:34:08 +02:00
maziggy e1a236e408 fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122)
GET /spoolman/spools/unlinked hid any spool with a non-empty
  extra.tag from the AMS-slot assignment picker. extra.tag is only an
  RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value
  into that same Spoolman field -- so every OpenSpoolman-tagged spool
  became un-assignable in Bambuddy even when it occupied no slot.

  get_unlinked_spools now determines assignability from the
  spoolman_slot_assignments table (the documented source of truth for
  slot assignments) and ignores extra.tag entirely. Both link_spool and
  the AMS auto-sync upsert a row there for every occupied slot, so the
  ledger is complete. get_linked_spools and find_spool_by_tag still use
  extra.tag -- they are genuine tag-match maps and are unaffected.

  Internal-inventory mode needs no parallel change: it stores tags in
  its own DB with no Spoolman extra collision.

  Updates test_get_unlinked_spools_success and adds
  test_get_unlinked_spools_excludes_slot_assigned.
2026-05-21 09:33:47 +02:00
maziggy b06f8f6951 fix(spool-assignments): union both assignment tables in the missing-spool check + symmetric mode-switch clear (#1473)
notify_missing_spool_assignments_on_print_start queried only the legacy
  SpoolAssignment table. In Spoolman mode that table is empty -- bindings
  live in spoolman_slot_assignments -- so every used tray was flagged
  missing, firing a false-positive notification on every print.

  - spool_assignment_notifications.py: the assigned-tray set is now the
    union of SpoolAssignment + SpoolmanSlotAssignment rows. Union-only,
    so legacy-mode behavior cannot regress.
  - settings.py: the Spoolman toggle cleared SpoolAssignment on switch-on
    but never cleared SpoolmanSlotAssignment on switch-off. Added the
    symmetric clear so stale Spoolman rows can't leak into a later
    internal-mode session and mask a real missing-assignment warning.

  Adds 3 notification tests + 1 mode-switch integration test. An audit
  of the remaining SpoolAssignment consumers confirmed usage_tracker,
  spool_tag_matcher and routes/inventory are correctly internal-mode-only.
2026-05-21 09:15:10 +02:00
maziggy d3f0e9ac73 fix(spoolman): per-print weight tracker falls back to local slot-assignment table for tag-less spools (#1459)
Reporter on Postgres + Spoolman saw weight never decremented after
  prints. Traced to _report_spool_usage_for_slots calling only
  client.find_spool_by_tag() — which returns None when extra.tag is empty.
  Non-RFID spools assigned via the Bambuddy UI intentionally leave
  extra.tag empty (per #1457 — we don't want fallback tags polluting
  Spoolman), so tag-less spools never got matched and weight tracking
  silently no-op'd. The tracker never consulted the local
  spoolman_slot_assignments table that has the binding.

  Adds _resolve_spool_id_via_slot_assignment() as stage 2 of the
  resolution chain. Stage 1 (existing tag-lookup) wins when present so
  RFID auto-sync remains unchanged. (ams_id, tray_id) derived from
  global_tray_id via the existing _global_tray_id_to_ams_slot helper,
  so external slots and AMS-HT slots resolve correctly. Threaded
  printer_id through the three callers (partial G-code, partial linear,
  final-usage report). Resolution path is logged ("via tag" vs "via
  slot-assignment") so support bundles confirm the fix is live.

  extra.tag is deliberately NOT auto-populated — that would re-introduce
  the exact pollution #1457 cleaned up. Slot-assignment table is the
  source of truth for non-RFID; extra.tag is reserved for hardware RFID.
2026-05-20 11:16:56 +02:00
maziggy 12b0c138f7 fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457)
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
  the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
  correctly showed the freshly assigned full spool. Two layers compounded:

    (1) Non-RFID slots fall back to a deterministic per-slot tag
        (hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
        that tag to Spoolman extra.tag but never cleared it from the
        previous holder on re-binding.

    (2) The frontend's hover-card resolver preferred the (stale) tag-link
        over the user's explicit slot-assignment. Same precedence bug in
        SpoolBuddy's fill-bar resolver and slot-action picker.

  Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
  everywhere. FilamentHoverCard's existing match-dedupe then collapses the
  two "Open in Inventory" buttons back into one.

  Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
  from POST /spoolman/inventory/slot-assignments (with the slot's
  deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
  the literal tag being bound — works for RFID and fallback). Best-effort:
  Spoolman 5xx and per-spool patch failures log + continue, never wedge
  the bind. get_fallback_spool_tag_for_slot promoted to a public helper
  mirroring the frontend's signature exactly.
2026-05-20 11:00:54 +02:00
maziggy badf0bed04 Fix: Failure Analysis widget honours edited failure_reason / status (#1444)
PrintLogEntry.failure_reason is captured once at print-completion time
  (main.py:3641) by copying archive.failure_reason — which is NULL while
  the user hasn't classified the failure yet. The PATCH /archives/{id}
  route then writes only to print_archives via a generic setattr loop,
  so the log entry stays NULL and failure_analysis.py keeps grouping the
  print as "Unknown". Same desync hits status — flipping it in the modal
  never reached the entry either.

  Mirror failure_reason and status from the PATCH payload to the latest
  PrintLogEntry for that archive (highest id). Latest-only because
  archive.failure_reason / status already reflect the latest run's outcome
  (each reprint clears the archive value at main.py:2195 and rewrites it
  at completion), so the Edit Archive modal is implicitly editing the
  latest run — reprints of an archive that succeeded on the second attempt
  keep the earlier failed run's original classification intact.

  Scoped to those two fields only. cost / print_name / printer_id stay
  unmirrored because per-run values legitimately diverge from archive
  ones (partial-print cost on a failed run vs source archive's full-print
  cost — see _compute_run_filament_grams at main.py:596).
2026-05-20 09:26:56 +02:00
MartinNYHC 12a352e5b8 Merge branch 'main' into dev 2026-05-19 14:12:50 +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 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 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 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 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