Commit Graph
1167 Commits
Author SHA1 Message Date
maziggy 2139bc5290 Post work PR #1673 2026-06-17 12:08:08 +02:00
maziggy 0e8b5e032b Post work PR #1505 2026-06-17 11:38:07 +02:00
maziggy e8f0698ae1 fix(local-presets): optimistic remove on delete
The Slicer -> Local Profiles page kept showing a just-deleted row for
  the ~hundreds of ms it took invalidateQueries to refetch. A quick
  re-click on the same row opened a second delete-confirm modal that
  resolved to a 404 from the backend.

  Add an optimistic queryClient.setQueryData filter in deleteMutation's
  onSuccess so the row disappears the instant the DELETE returns 200.
  Existing invalidateQueries calls stay in place to reconcile any drift.

  Found while reproducing #1713 (verifying maziggy's setup against the
  reporter's). Unrelated to that investigation but caught here.
2026-06-17 09:15:38 +02:00
maziggy 249dacbd53 chore(frontend): vite 7 -> 8 + plugin-react 5.2
Major version bump for the frontend build:
  - vite ^7.3.2 -> ^8.0.16
  - @vitejs/plugin-react ^5.1.1 -> ^5.2.0

  Vite 8 swaps Rollup for Rolldown as the default bundler
  (Rust-backed, same plugin contract). The bump also lifts the
  transitive esbuild floor to 0.28.1, closing the last open
  advisory in the audit chain.

  vite.config.ts surface audited and unchanged:
  - defineConfig, Connect type
  - serveGcodeViewer configureServer middleware
  - server.proxy with WebSocket upgrade for /api/v1/ws
  - build.outDir / emptyOutDir / chunkSizeWarningLimit
  - resolve.alias for @
  - base: '/' regression guard from #1221

  vitest@4.1.8 already accepts vite 8 in its peer range
  (^6 || ^7 || ^8); no test-runner bump required.

  Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
  line satisfies this.

  Not taken: plugin-react v6 — it requires
  babel-plugin-react-compiler and @rolldown/plugin-babel as
  peers and is a separate scope.
2026-06-17 08:27:48 +02:00
maziggy 861de7a0e6 chore(frontend): dependency bumps
Runtime:
  - dompurify 3.4.0 -> 3.4.10 (package.json floor raised from
    ^3.4.0 to ^3.4.10 so fresh installs cannot land on the
    deprecated 3.4.4 release; release notes 3.4.1 -> 3.4.10
    reviewed — the three call sites (MakerworldPage,
    ProjectDetailPage, ProjectPageModal) use string-output
    sanitisation and are unaffected by 3.4.4's widened default
    allow-list)

  Build / lint / test tooling (transitive, dev-only):
  - @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
    eslint-plugin-react-hooks)
  - vite 7.3.2 -> 7.3.5
  - markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
    -> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
    markdown-it.render directly)
  - js-yaml 4.1.1 -> 4.2.0 (via eslint)
  - form-data 4.0.5 -> 4.0.6 (via jsdom)
  - ws 8.20.1 -> 8.21.0 (via jsdom)
2026-06-17 08:18:33 +02:00
maziggy 2940fbdcf7 feat(auth): admin-configurable session lifetime ceiling (#1706)
The 24h session cap from the M-2 audit finding was hard-coded, so the
  "Remember Me" checkbox could only control storage location, never
  duration. Add session_max_hours setting (default 24, max 720) honoured
  at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
  backup, OIDC.

  - backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
    that clamps to [1h, 720h] and falls back to 24h on missing/blank/
    unparseable. DB errors propagate — the login transaction must abort
    on a broken DB rather than silently extend or shrink the lifetime.
  - backend/app/api/routes/auth.py, mfa.py: all four sites read the
    resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
  - backend/app/schemas/settings.py, routes/settings.py: schema field
    with ge=1 le=720 + int coercion in _build_settings_response.
  - frontend/src/pages/SettingsPage.tsx: half-width card at top of
    Settings -> Users left column with 24h/7d/30d presets, custom input,
    and a yellow warning when value > 24h.
  - frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
    translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
  - backend/tests/integration/test_session_policy.py: 15 tests across
    resolver clamping, login JWT exp end-to-end, settings API round-trip.

  Already-issued tokens keep their original expiry; the new setting only
  affects future logins.
2026-06-16 12:00:27 +02:00
maziggy b97f25d819 fix(spoolbuddy): inventory search matches spool ID + storage location (#1738)
The SpoolBuddy inventory page reimplemented its filter inline and only
  matched material/subtype/brand/color_name/note, while Bambuddy's main
  inventory uses the shared filterSpoolsByQuery helper which also matches
  spool ID, slicer_filament_name, and storage_location. Delegate to the
  shared helper so both pages stay in lockstep.

  - frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
    inline filter with filterSpoolsByQuery
  - frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
    in ID / partial ID / pre-fix fields / parity-gain fields
2026-06-16 11:17:28 +02:00
maziggy eb5154f61a feat(queue): tabbed page, batch grouping, multi-drag, Gantt timeline
Restructures the queue page around three tabs (Queue / History / Timeline)
  and adds first-class batch grouping plus a real time-based timeline.

  Queue tab
  - Layout toggle: Sort by Position (flat list) or Group by Printer (per-
    printer section cards with aggregate count / time / weight headers).
  - Batch grouping: pending items sharing a batch_id render as a single
    collapsible row with aggregate stats; children draggable within the
    batch only. Per-batch collapse state in localStorage.
  - Multi-drag: dragging any selected row moves all selected items as a
    contiguous block via DragOverlay (+N ghost).
  - Selection bar gains a Group as batch action when 2+ ungrouped items
    are selected. Ungroup lives on the batch parent row.

  History tab
  - Two-line rich rows: filament color swatch + weight + type, user
    attribution, inline error message on failed / skipped rows.
  - Responsive 1 / 2 / 3 column grid so a long history uses available
    width instead of stretching one row per line.
  - Batch siblings group into a collapsible parent with status-rollup
    chips (3 OK / 1 failed / etc).
  - Thumbnail hover preview shows the full image at 192x192 next to the
    small thumb.

  Timeline tab
  - Replaces the hourly-list view with a Gantt swimlane: one row per
    printer (plus per target_model and unassigned), horizontal hour
    axis, jobs as bars positioned by start time and sized by duration.
  - Live NOW marker.
  - Only committed schedules are rendered: currently printing items,
    pending items with scheduled_time, and pending ASAP behind an active
    print. Staged (manual_start), waiting (waiting_reason), and ASAP
    jobs on idle printers are filtered out.
  - 24h rolling window with 12h step controls.
  - Per-bar tooltip with start, end, progress, batch name.

  Backend
  - POST /queue/batches creates a batch, optionally assigning existing
    pending item_ids (manual grouping) or returning an empty batch the
    client can attach to subsequent /queue/ POSTs.
  - POST /queue/batches/{id}/ungroup clears batch_id from all members
    (skipping items the caller does not own) and deletes the batch row
    when no members remain.
  - POST /queue/ accepts an optional batch_id and validates that the
    batch exists, is active, and the caller may modify it. The existing
    quantity > 1 auto-batch path still fires when no batch_id is sent.

  PrintModal
  - When N plates from one source are queued in a single submission
    (model assignment or single printer), the modal pre-creates a batch
    and passes its id to each addToQueue call so multi-plate jobs land
    grouped automatically. Falls back to ungrouped items if the batch
    pre-create fails.
2026-06-16 10:45:30 +02:00
maziggy 2d9f87354e Updated CHANGELOG 2026-06-16 08:42:03 +02:00
maziggy 804fe470fa fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755)
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
  Default Operators group is seeded with *_own only (and the migration map flips
  legacy → _own on existing groups), so non-admin users never held the legacy
  permission and the sidebar hid Archives / Queue / Files even though the
  underlying API accepted their requests. Reporter only spotted Files; same bug
  shape applied to Archives and Queue.

  Fix: navPermissions accepts Permission | Permission[]; the three affected
  resources list all three tiers. isHidden checks .some(hasPermission) for
  arrays. Permission type extended with the matching *_own / *_all variants —
  backend already shipped them, the TS type just didn't declare them.
2026-06-16 07:44:22 +02:00
maziggy ead6c37147 fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
  all shipped, but no caller invoked the dispatcher — the offline event was an
  orphan toggle. Edge detection in on_printer_status_change now schedules a
  debounced (60s) background task on the connected→disconnected transition;
  reconnect before the window elapses cancels it. Covers both upstream paths
  (smart-plug power-off via mark_printer_offline, and MQTT staleness via
  check_staleness), both of which already route through the status callback.
  The "back online" channel is the existing print-failure notification on
  firmware FAILED report — no symmetric on_printer_online needed.
2026-06-16 07:34:14 +02:00
maziggy 2cf6f29503 fix(vp): Send All enqueues one item per plate; archive delete cascades to queue
VP queue-mode multi-plate Send All
  ==========================================

  BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
  3MF containing every plate (one FTP STOR, single filename) — slice_info.config
  inside the file lists N <plate> blocks with their own index metadata and
  their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
  called _extract_plate_id which returned only the FIRST plate index, and
  _add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
  silently dropped on the floor. From the user's perspective: Send All of a
  3-plate project produced 1 queue item, indistinguishable from a regular
  single-plate Send, with no log line to explain the discrepancy.

  The wire was confirmed against the live H2D-1 Proxy VP: the same file
  ships whether the user clicked Send or Send All; the only intent signal
  is the count of <plate> blocks inside slice_info.config.

  Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
  (-> list[int]). The list contains every <plate> block's index in order;
  falls back to [1] when slice_info.config is missing / unparseable so the
  single-plate case is preserved. _add_to_print_queue now loops over the
  list and creates one PrintQueueItem per plate, with:

    - plate-specific position = MAX(position) + iteration_number, so the
      items inherit consecutive positions and the slicer's plate order
      becomes the queue execution order.
    - per-plate required_filament_types / filament_overrides via
      extract_filament_requirements(file_path, plate_id) — the plate-aware
      filter shipped with #1697 — so the scheduler's per-printer "Any X"
      matching dispatches each plate onto a printer with the right
      colours loaded for THAT plate, not for plate 1's filament set.
    - shared archive_id across all plates (one upload = one archive row).
    - the VP's auto_dispatch + manual_start posture inherited unchanged.

  Net behaviour: single-plate Send hits the loop once → exactly today's
  result (one queue item, plate_id from the slicer, one archive). Multi-
  plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
  consecutive positions, all referencing the same backing archive.

  Archive delete cascades to queue rows
  =============================================

  Previously the soft-delete path (the default the trash-can button uses)
  called _cancel_pending_queue_items which only flipped queue rows with
  status='pending' to status='cancelled' while leaving every other status
  alone AND leaving every row in the DB. The Send All multi-plate work
  above made this much more visible: deleting an archive backed by N
  queue items now had to clean up N rows, and what users saw instead was
  N "cancelled" rows lingering in the queue history.

  Backend:
    - Replaced _cancel_pending_queue_items with _delete_related_queue_items
      (db, archive_id) -> int. DELETEs every queue row where
      archive_id = X regardless of status. Matches what the hard-delete
      path already did via the ON DELETE CASCADE FK on
      print_queue.archive_id — both paths now produce the same end state.
    - Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
      untouched; Quick Stats / accuracy bands are preserved across both
      delete paths.
    - 409 guard on archives.py::delete_archive when any related queue
      item is currently status='printing'. Both soft and hard delete are
      gated; deleting the archive while a print is live would strip the
      dispatcher's metadata trail (filament / plate / ams_mapping) out
      from under the running print.
    - New GET /archives/{id}/delete-impact endpoint returns
      {related_queue_items: N, currently_printing: M}. Cheap, single
      endpoint, deliberately NOT folded into the archive list response
      so the much larger list endpoint isn't forced to run the same
      query per row.

  Frontend:
    - ArchivesPage delete-confirm modal queries the new endpoint when the
      modal opens (useQuery with enabled: showDeleteConfirm) and renders
      an amber "N queue items linked to this archive will also be removed"
      line when total > 0 AND printing = 0, OR a red "Cannot delete —
      M queue items are currently printing" line when printing > 0
      (confirm button disabled in that case so the user can't bonk the
      409 on submit).
    - ConfirmModal gained an optional confirmDisabled?: boolean prop —
      isLoading was the only disable knob before; this adds the external-
      precondition path.
    - 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
      translated across all 11 locales per feedback_translate_dont_fallback —
      no English fallbacks.

  No DB migration — the CASCADE FK was already in place; only the helper's
  semantics changed.
2026-06-13 15:58:57 +02:00
maziggy 5da5bc0aaf Updated CHANGELOG 2026-06-13 14:22:36 +02:00
maziggy b8a3e7c2c9 fix(print-log): render one swatch per color for multi-color filament rows (#1731 part 1)
The per-archive Print Log table cell at ArchivesPage.tsx:3882 rendered
  filament_color as a single swatch with
  `backgroundColor: entry.filament_color.startsWith('#') ? ... : undefined`.

  For multi-color prints, the backend writes filament_color as a comma-joined
  string ("#FFFFFF,#000000,#FF0000"). The whole string trivially passed the
  startsWith('#') check but isn't a valid CSS color — the browser silently
  drops the declaration and the swatch falls back to its black/20% border,
  which on the dark theme reads as a barely-visible grey dot. Reporter's
  screenshots showed "PLA" text with no visible swatch at all. DB column
  was correct; render dropped the colors.

  The Archive Card view at ArchivesPage.tsx:1072-1083 and :2114-2125 already
  splits on comma and renders one swatch per color — only the Print Log
  table cell had been missed when multi-color support landed elsewhere.

  Fix mirrors the card pattern: wrap swatches in a flex container, split
  on comma, trim, render one w-3 h-3 swatch per color with
  backgroundColor and title={trimmed}. Single-color prints render one
  swatch (no behaviour change). Empty / non-hex entries fall through to
  no backgroundColor rather than poisoning the CSS for siblings.

  Does NOT cover the reporter's second symptom — new multi-color prints
  missing from filament usage history. That's usage_tracker._track_from_3mf
  and the slot-to-tray mapping chain; needs a support bundle (PRINT START
  + PRINT COMPLETE [UsageTracker] log lines + captured ams_mapping) before
  shape can be confirmed. Tracking as #1731 part 2.
2026-06-13 14:10:21 +02:00
maziggy be7e85344c fix(finish-photo): drive capture from stg_cur=22, drop dispatch force-on (#1721)
capture_finish_photo (default-on) was forcing the timelapse MQTT field to
  true on every print, even when the user explicitly unchecked Timelapse in
  the slicer send dialog. On profiles with Timelapse Type = Smooth, that
  flipped the printer's timelapse_record_flag and un-gated the per-layer
  M622 J1 wipe blocks the slicer had baked in — toolhead parked off the
  part every layer, on prints the user opted out of recording.

  Root cause: #1397 implemented the finish-photo feature as a side channel
  of "force the printer into timelapse-recording mode at dispatch" so the
  last-frame extractor had a video to pull from. That conflated recording a
  timelapse with snapping a finish photo, and the per-layer side effects
  were decided at slice time by the user's timelapse_type, which Bambuddy
  has no visibility into post-slice.

  Fix: replace the force-on with a clean MQTT-state-driven trigger.

    bambu_mqtt.py fires a new on_finish_photo_moment callback when
    stg_cur transitions INTO 22 ("Filament unloading") while
    _was_running AND end-of-print gate matches (progress >= 99 OR
    layer_num >= total_layers OR remaining_time <= 0). The gate
    disambiguates from mid-print color swaps (which also transit
    stage 22 but at progress < 99). FINISH-state fallback in the same
    handler fires the callback at the existing transition if stage 22
    never arrived (cancel, external-spool-only, HMS halt, firmware
    variants).

    main.py registers on_finish_photo_moment as a top-level handler.
    It pre-captures one camera frame at the trigger edge (external cam
    → buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and
    caches the JPEG bytes in _stage22_finish_frames[printer_id].
    _background_finish_photo consumes the cached bytes before its
    existing live-grab chain, so the saved photo has the better
    framing (toolhead parked, before bed drop) without restructuring
    the archive-resolution / fallback / notification wiring.

    When a timelapse IS actively recording (user explicitly opted in),
    pre-capture is skipped — _capture_finish_photo_from_timelapse
    still extracts the last frame, which is still the best framing
    and now has no force-on side effects because the user wanted the
    video.

  Removed: resolve_effective_timelapse, _resolve_effective_timelapse
  wrapper, both background_dispatch call sites, the print_scheduler call
  site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse
  (~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video,
  /record, /recording) and its call site. All paths now read
  bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly.
  The archive.bambuddy_forced_timelapse DB column stays defined (default
  False) for back-compat with existing rows — no consumer reads it anymore.
2026-06-13 13:28:58 +02:00
maziggy 168d3cb05c fix(ams): filter Configure AMS Slot profile list by printer model (#1623)
The Filament Profile picker in the Configure AMS Slot modal listed
  profiles for every printer the user had ever imported / cloud-synced.
  On H2D the reporter saw local "Custom" PETG/PLA for A1 mini and P1S
  alongside Bambu Cloud and Orca Cloud profiles named "X1C eSUN PETG-
  Basic Filament" - none of them usable on the slot they were configuring.

  Three filter gaps in the same picker:

  - Local "Custom" imported profiles were unconditionally listed. Now
    parse their compatible_printers JSON and run presetCompatibility()
    against the slot's full slicer preset name ("Bambu Lab H2D 0.4
    nozzle") derived from the printer-model registry + slot nozzle.
    Hide on 'mismatch'; 'match' and 'unknown' keep showing (back-compat
    for hand-edited imports without compatible_printers).

  - Cloud presets with the "@Bambu Lab <long-name>" suffix form (user-
    renamed Bambu Cloud presets, most Orca Cloud profiles) slipped
    through the existing "@BBL <code>" matcher. extractPresetModel now
    handles both, with case-insensitive registry reverse-lookup so
    "A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini
    profiles (#1649 alias match preserved).

  - Cloud presets with the model in the BODY of the name and no @ suffix
    ("X1C eSUN PETG-Basic Filament") returned null from the extractor.
    Added a body-text scan against every known model token from the
    registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro"
    aren't eaten by their shorter siblings, word-boundary regex so
    "PA1" doesn't false-match "A1".

  Fail-open posture preserved: registry not loaded or printerModel empty
  no-ops every filter; saved preset bypass keeps the active selection
  visible; built-in filaments stay unfiltered (generic fallback); free-
  form names with no recognisable token still show.
2026-06-13 10:38:13 +02:00
maziggy 8a63fcbf57 fix(vp): apply tray_exist_bits empty-slot cleanup to slicer-facing cache (#1726)
VP bridges bound to a target printer (Proxy mode, Queue mode with
  specific target) forwarded the printer's raw AMS push_status to the
  slicer untouched. bambu_mqtt.py::_handle_ams_data applies a
  tray_exist_bits-driven cleanup to Bambuddy's internal state
  (promote empty slots to state=9, wipe stale tray_type / tray_color /
  tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders
  empty slots as Empty, but the VP bridge cache never ran the same
  cleanup. Net result on real hardware: a printer with 3 loaded
  filaments and several previously-loaded-now-empty slots had Bambuddy's
  AMS card render those slots correctly as Empty, but BambuStudio after
  Sync painted them as phantom loaded filaments with stale color and
  material from before the slot went empty.

  Root cause: two consumers of the same payload, only one wired to the
  cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py::
  _on_printer_raw merged the ams blob via _merge_ams_dict but copied
  tray_exist_bits through as an opaque scalar without acting on it.

  Fix: factored the bit-clear logic out of _handle_ams_data into a
  module-level helper apply_tray_exist_bits(units, tray_exist_bits_str,
  *, power_on_flag, log_label). Internal path replaced with a single
  call. Bridge calls it after _merge_ams_dict on the merged ams dict,
  before the merged state is stored as the 1 Hz cached-as-base source.

  Shared shutdown guard kept on both sides: all-zero bits +
  power_on_flag=False is the printer-off pattern (#765, would
  propagate phantom empties on every reconnect); nonzero bits +
  power-off is valid idle-printer state (#1365, X1C between prints)
  and still applies. AMS-HT units (id >= 128) skipped on both sides.

  Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper
  contract directly. 3 new bridge regression tests reproduce the
  #1726 wire shape, the shutdown guard, and the AMS-HT skip on the
  cached slicer-facing state. Existing internal-state tests for the
  bit-clear logic (covers state=9 promotion, loaded-slot preserve,
  genuine-removal-with-power-on) continue to pass against the
  refactored path.

  One pre-existing bridge fixture had an inconsistent tray_exist_bits
  ('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The
  shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match
  real-printer wire shape.

  Reported by @needo37 with full code-level analysis including the
  suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to
  verify on a live system.
2026-06-13 08:42:59 +02:00
maziggy f15e54c383 fix(windows): _local_zone falls back to stdlib utc when zoneinfo DB is missing
The Windows installer's embedded Python doesn't carry an IANA tz
  database, and the stdlib zoneinfo has no system DB to read on Windows.
  ZoneInfo("UTC") raises ZoneInfoNotFoundError on those installs, and
  the new /api/local-backup/status endpoint 500s on the resulting
  uncaught exception. Surfaced via a Windows traceback from a user's log:

    File "...\backend\app\services\local_backup.py", line 32, in _local_zone
      return ZoneInfo("UTC")
    zoneinfo._common.ZoneInfoNotFoundError: 'No time zone found with key UTC'

  _local_zone()'s try/except only covered the TZ-env branch — both
  fallbacks unconditionally called ZoneInfo("UTC") and re-raised.

  Fix (two parts):

  1. services/local_backup.py — return type widened from ZoneInfo to
     tzinfo, the UTC fallback is wrapped in its own try, and the
     last-resort fallback returns datetime.timezone.utc (stdlib, no
     IANA DB needed). str(timezone.utc) == "UTC" so the response shape
     on /api/local-backup/status is unchanged. The astimezone call in
     _calculate_next_run accepts any tzinfo — no other call sites
     affected.

  2. requirements.txt — pin tzdata>=2024.1; sys_platform == "win32" so
     the next Windows installer build ships the IANA DB, and any non-
     UTC TZ value (e.g. Europe/Berlin) resolves correctly. The stdlib
     fallback can only ever give UTC. Linux/macOS unaffected by the
     platform marker — they already have the system tz database.
2026-06-13 07:49:31 +02:00
maziggy 0de71ca412 fix(printer): "off" flow_cali / nozzle_offset_cali now actually suppress the stage
The Re-print and Schedule modal toggles for Flow Calibration and Nozzle
  Offset Calibration accepted "off" correctly and flowed it through to the
  project_file MQTT publish — Bambuddy sent extrude_cali_flag: 2 and
  nozzle_offset_cali: 2 per the "1 = run, 2 = skip" reading inherited from
  the #1478 / #1682 work. Live test on H2D 01.x: with both toggles off,
  the stg queue still scheduled stage 8 ("Calibrating dynamic flow") and
  stage 39 ("Nozzle offset calibration"), and the printer ran both at
  print start.

  Root cause: 2 means "skip the explicit pass but still apply / verify
  stored PA via the calibration stage" — close to a no-op K-factor wise
  but the per-print physical sequence still runs. 0 is the encoding that
  actually drops the stage from stg. A BambuStudio Send-dialog capture
  on the same firmware showed 0 for both fields when the user unchecked
  the calibrations — contradicting the #1478 commit's read of "BambuStudio
  never sends 0."

  Fix:
  - extrude_cali_flag = 1 if flow_cali else 0  (was: else 2)
  - nozzle_offset_cali = 1 if (nozzle_offset_cali and is_dual_nozzle) else 0
    (was: else 2)

  Dual-nozzle gate stays; single-nozzle prints continue to force-skip the
  nozzle-offset cali their head doesn't support (#1682). The 1 (run)
  branch is unchanged.

  Verified live on the same H2D after the patch: stg dropped to
  [29, 13, 4, 14, 3] (cooling, homing, filament change, nozzle cleaning,
  vibration comp). Stages 8 and 39 gone.

  Vibration compensation is NOT fixed by this commit: vibration_cali is a
  bool in our and BambuStudio's wire format, and the H2D firmware queues
  stage 3 regardless of the false value. Firmware-side, not solvable at
  the dispatch layer with the current field. Filed as a follow-up.
2026-06-12 16:33:56 +02:00
maziggy 7190fc2d13 fix(logs): demote benign "not connected" + "may linger" warnings
Two warnings polluting every A1 support bundle on healthy prints, both
  unrelated to the timelapse-default behaviour the issue actually reports.

  1. mqtt_bridge.py's post-bind nudge calls request_status_update on the
     real printer's MQTT client to populate the bridge cache without
     waiting for the next periodic pushall. The bind frequently races the
     TLS handshake, especially on A1 firmware. Skip the nudge when
     state.connected is False — the periodic pushall fills the cache
     anyway. The WARNING in bambu_mqtt.py stays for the genuinely-
     actionable callers (refresh-status API, bug reporter).

  2. Post-finish SD-card cleanup (and the symmetric forced-timelapse dir
     walk) used delete_file_async's bool return to drive a WARNING when
     all candidates failed. A1 firmware self-cleans the SD card before
     our cleanup runs — every candidate FTP-DELE returns 550, we burn
     the retry budget, then WARN on a successful print. Introduce
     DeleteResult.{DELETED,NOT_FOUND,FAILED} so the helpers only WARN
     on real network/auth/transient failures. NOT_FOUND advances to the
     next candidate without consuming the 2s backoff. User-facing delete
     endpoint returns 404 on NOT_FOUND.
2026-06-12 15:13:40 +02:00
maziggy 1bcd5c8ba5 feat(support): bundle redacted cached push_status per connected printer
The support bundle shipped support-info.json + bambuddy.log, but the raw
  shape of the printer's MQTT push_status — the field that blocks per-model
  work like AMS Backup detection (deferred in 85fbd7fc) and every vt_tray /
  vir_slot / mapping shape regression — was never captured.

  Each connected printer now contributes push-status/printer-{i}.json with
  {model, firmware_version, captured_at, raw_data}, indexed against
  support-info.json["printers"]. Two-pass redaction: a structural walk
  drops user-private keys (subtask_name, gcode_file, subtask_id, task_id,
  project_id, design_id, profile_id, model_id, gcode_state,
  gcode_file_prepare_percent) and rewrites net.info[*].ip to 0.0.0.0
  (matches the #1429 VP bridge fix); then the JSON runs through the same
  DB-derived sensitive_strings sanitizer the log path uses, catching any
  printer name / serial / access code / cloud email that leaked into a
  nested string field.

  print.cfg, print.option, ams, vt_tray, vir_slot, mapping,
  ams_extruder_map, and hardware fields are all preserved — those are the
  fields per-model work needs.

  Always-on inside the existing debug-logging-required gate; no opt-in
  toggle (the bundle is already user-initiated and downloads locally
  before the user chooses to send).
2026-06-12 14:52:21 +02:00
maziggy 9c4252911b fix(vp): overlay incoming dict-shaped push_status fields onto cache instead of replacing (#1622 round 5)
Right after the slicer picks a filament for the external spool (vt_tray, ams_id=255),
  Bambu firmware pushes a partial vt_tray carrying just {tray_info_idx, tray_color} -
  ~18 fields shorter than the pushall shape the slicer expects. The #1622 round-4
  per-field accumulate (da799447) only carried over prev keys NOT in new, so the
  cached vt_tray was replaced wholesale with the 2-field partial. The next 1 Hz
  cached-as-base push delivered the stripped dict and BambuStudio rendered the
  external slot as invalid (color only, no tray_type / state / k / n / cali_idx /
  nozzle_temp_*). Reload restored it because the reconnect-triggered pushall
  re-seeded vt_tray, then the cycle repeated. AMS slots didn't suffer because
  _merge_ams_dict deep-merged them.

  Fix: for every top-level push_status key whose prev AND new are both dicts,
  overlay incoming keys onto prev rather than replace. ams is excluded (already
  deep-merged). The same shape protects device / online / upgrade_state / ipcam /
  upload / net against future firmware partials. net.info IP rewrite is unaffected -
  _rewrite_net_info_ips runs before caching and overlay lets the freshly-rewritten
  list win over prev when present.
2026-06-12 14:04:15 +02:00
maziggy 61df0b68d2 fix(print-modal): allow cross-extruder AMS slot picks on dual-nozzle (#1722)
Before this change, the Re-print and Schedule modals' per-filament
  slot dropdown hid every slot whose extruder didn't match the filament's
  slicer-assigned nozzle. On H2D with AMS A+C on the left and B on the
  right, an L-assigned filament could only pick A or C and an R-assigned
  one could only pick B — locking the user out of cross-extruder picks
  even when they'd intentionally loaded the required filament into the
  other AMS.

  The slicer wasn't the source of the asymmetry: BambuStudio Desktop,
  OrcaSlicer Desktop, and the Bambuddy sidecar all produced identical
  filament_map values for the same source 3MF. Bambuddy's UI filter was.

  Drop the f.extruderId === item.nozzle_id clause in FilamentMapping's
  loadedFilaments filter. Single-nozzle and FTS short-circuits stay; the
  L/R row badge stays as a hint to the slicer's intent. Printer firmware
  decides at start-print whether the cross-extruder ams_mapping is valid.
2026-06-12 13:12:20 +02:00
maziggy 857a071306 fix(library): preview sidecar-sliced .gcode.3mf rows as G-code, not ZIP bytes (#1709)
slice_and_persist writes a .gcode.3mf ZIP container but persisted the row
  with file_type="gcode". The G-code preview endpoint short-circuits on
  file_type == "gcode" and returns the bytes as text/plain, so the embedded
  viewer received the raw ZIP body instead of the embedded toolpath.

  - Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type
    and external-scan rows).
  - get_gcode also routes to the unzip branch when the filename ends with
    .gcode.3mf, so rows already written under the bug self-heal on first
    preview without a DB migration.
  - Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge
    to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do.
  - Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy
    row preview must be text/plain, contain G28, and NOT start with PK.
2026-06-12 10:42:02 +02:00
maziggy 1c42a9f1fd remove(slicer): drop bundle import; fix cloud preset type/from for CLI (#1712)
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
  strips system processes/filaments, so importing a bundle left users without
  process presets and slicing fell back to embedded settings on STL. Bundle
  mode also hid the standard tier behind a constrained dropdown, the actual
  trap reported here.

  Removed end-to-end:
  - backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
    SliceBundleSpec, dispatch fork in library.py, bundle-context params on
    the filament-requirements endpoints, bundle-fingerprint cache key in
    slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
    slice_with_bundle, BundleSummary / BundleNotFoundError.
  - frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
    branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
    SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
    loses its bundle path; presetCompatibility keeps compatible_printers
    plus the @BBL fallback.
  - SlicerBundlesPanel turns into a permanent static notice explaining the
    removal, alternative import paths, and the new slice-time lookup order
    (Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
  - i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
    translated across all 11 locales; slice.bundle*, slicerBundles.* keys
    removed.

  Fixed (surfaced by removing bundle mode):
  - _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
    from: "system" on the payload before json.dumps. Bambu Cloud ships
    type as "printer"/"print" and routinely empty `from`; the BS CLI's
    --load-settings parser rejects both with return -5 / "input preset
    file invalid". Standard tier already did this; cloud paths now match.
2026-06-12 10:14:06 +02:00
maziggy da799447f6 fix(vp): accumulate cached push_status per-field instead of allowlist (#1622)
Bridge cache replaced prev state wholesale on each incremental, re-merging
  only a 14-key allowlist. Capability/lifecycle fields (cali_version,
  print_type, mc_print_stage, device, ...) drained out within one 1Hz tick,
  greying out BambuStudio's Device-tab UIs (manage-calibration, AMS-slot
  dropdown) once the cache thinned. Most P1S users miss it by timing — they
  click Device tab while the cache is still fat from the connect pushall.

  Switch to per-field accumulate matching bambu_mqtt.py's internal state
  handler: prev keys carry over verbatim when not present in the incoming
  push, new values overwrite when present. _merge_ams_dict for partial AMS
  blobs unchanged (#1387 / #1371 regression guards stay green).
  _SLICER_VISIBLE_STICKY_KEYS removed — new logic is a strict superset.
2026-06-11 17:23:49 +02:00
maziggy 5644f11495 debug(vp): env-flagged bridge-synthesised reply trace for slicer↔printer #1622 round-3 triage
Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
  responses correctly: ams_filament_setting round-trips with result=success on P1S,
  the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
  intact, and the visible "unload" symptom comes from the slicer's choice of
  extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
  (select by id, both H2D and P1S accept). The open question is what makes the
  slicer pick _set vs _sel — likely the info.get_version response Bambuddy
  synthesises or the first cached pushall reply the slicer reads at connect.
  Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
  but no direction for the bridge's own synthesised replies.

  Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
  every bridge-synthesised reply (info.get_version answer, project_file ack,
  on-demand pushall response). Capture is in _publish_to_report — the single
  chokepoint — gated on a new log_event param; the 1Hz periodic push threads
  log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
  dump_wire already covers cache shape per tick.

  Diagnostic-only, no data-path change. Default param preserves every existing
  call site's behaviour.
2026-06-11 15:09:24 +02:00
maziggy 6b477088a2 fix(queue): extend Charcoal-style label fix to Specific-Printer panel (#1718 round 3)
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
  sub-brand, plus a material-disambiguated colour name from a new
  /inventory/colors/by-material endpoint. The printer-mode panel that
  renders the same 3MF (FilamentMapping) was reading the same raw
  fields — item.type for the required label, getColorName(item.color)
  for the swatch tooltip — and was not touched, so picking "Specific
  Printer" still showed "Required: PLA - Black" for a slice the
  "Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".

  Extract the three-query resolution machinery from FilamentOverride
  into a shared hook useFilamentLabels (returns positional
  {resolvedName, colorLabel} per slot). Both panels call it; both
  read the same labels. The hook also owns extractMaterialHint so the
  "strip leading brand token" rule has one source of truth.

  FilamentMapping required-side now reads {resolvedName} instead of
  {item.type}; swatch tooltip reads `Required: {resolvedName} -
  {colorLabel}` instead of `Required: {item.type} -
  getColorName(item.color)`.
2026-06-11 14:49:52 +02:00
maziggy 3b1395e3c0 fix(queue): surface force-color-match checkbox in specific-printer mode (#1717)
The Print Queue's schedule dialog hid the per-slot "Force color match"
  checkbox when a specific printer was picked, even though the scheduler
  in print_scheduler.py:535 honours force_color_match regardless of how
  the queue item was created. Model-mode ("Any A1") rendered
  FilamentOverride which carries the checkbox; printer-mode rendered
  FilamentMapping which had no force-match UI at all. Pure UI gap.

  Extend FilamentMapping to accept optional forceColorMatch +
  onForceColorMatchChange props, mirroring FilamentOverride's signature,
  and render the same <Palette>-iconed checkbox under each filament row
  when a handler is wired. PrintModal/index.tsx passes the existing
  forceColorMatch state through — same object both modes write into so
  toggling between modes preserves the user's selection. No new i18n
  keys (printModal.forceColorMatch already ships in all 11 locales).
2026-06-11 13:51:39 +02:00
maziggy 19eed8eba0 debug(vp): env-flagged command-flow trace for slicer↔printer #1622 round-2 triage
The shape-of-payload dump shipped earlier rules out cache wipes —
  shaddowlink's round-1 captures show AMS data reaches the slicer
  byte-identical to what the printer sent. The remaining symptom
  (picking a generic filament in archive mode "unloads" the slot) lives
  on the command path, which the snapshot dump doesn't see: it writes
  only the cached _latest_print_state and the periodic 1Hz push.

  Add append_event() in _debug.py — same env flag, separate file at
  <log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
  iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
  topic, <channel>.<command> grep handle, and parsed payload. Wired at
  two points: mqtt_server._handle_publish for slicer publishes (after
  JSON decode so the trace matches what the bridge actually parsed) and
  mqtt_bridge._on_printer_raw "everything else" branch for printer
  responses (after serial rewrite so the trace matches what the slicer
  sees on the wire). Pushall / get_version stay out — both are handled
  locally and never round-trip through the bridge.

  Bytes payloads get the same \x00-tolerance fix from #927 so
  OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
  bytes fall back to {"raw": "..."} so every line stays valid JSON.
2026-06-11 13:35:20 +02:00
maziggy 6ef0df6ca0 fix(updater): route every git step through app_dir for separate-mount installs (#1715)
Native installs that follow the systemd template
    WorkingDirectory=/opt/bambuddy
    Environment="DATA_DIR=/srv/bambuddy/data"
  (or any layout where DATA_DIR is not a subdirectory of the install)
  could not apply in-app updates. Every git subprocess in _perform_update
  used cwd=settings.base_dir and safe.directory={base_dir}. On standard
  installs (DATA_DIR=INSTALL_PATH/data) this happened to work by accident
  because git walks up from a subdirectory of the repo to find .git; on
  separate-mount layouts the walk has nowhere to go and every call
  returns "fatal: not a git repository." safe.directory was also wrong
  even on the standard install -- it must equal the repo root git
  discovers, not the data dir.

  Resolve app_dir = settings.app_dir at the top of _perform_update and
  route all four git subprocesses (remote get-url, remote set-url, fetch,
  reset --hard) and the embedded safe.directory through it. Rename the
  base_dir parameter on _origin_points_at_repo to app_dir so the
  signature documents the contract.
2026-06-11 12:40:32 +02:00
maziggy d459b6eabb fix(slicer): preset visibility + lookup precedence + signed-out banner + AMS slot badges (#1712)
Four #1712 issues from the 2026-06-04 Orca Cloud integration:

  (1) Tier order put Orca Cloud above everything across SliceModal,
  auto-pick scoring, dropdown groups, the AMS slot picker, and the
  backend precedence. Bambu-Cloud-only users saw their profiles
  deprioritised behind an empty Orca tier.

  (2) Cross-tier dedup hid a same-named preset in all but the highest-
  priority tier. A user with both a local-imported and an Orca-synced
  "Bambu PLA Basic" couldn't see the Orca copy as a picker option.

  (3) CloudStatusBanner nagged signed-out users with a permanent
  "Sign in to Orca Cloud" line at the top of every slice -- even after
  explicit logout. Bambu Cloud had the symmetric problem.

  (4) ConfigureAmsSlotModal source badges were inconsistent: Orca rows
  showed only "Custom" (no source identity), Bambu Cloud built-in rows
  had no badge at all, and the orthogonal isUser-driven "Custom" badge
  collided with the source badge for cloud user presets.

  Order is local > orca_cloud > cloud > standard everywhere it lives
  (SliceModal SLICE_MODAL_TIER_ORDER + TIER_BONUS + dropdown tier list,
  ConfigureAmsSlotModal sourceOrder, and backend precedence). The order
  drives auto-pick + visual group rendering; it does NOT hide profiles.

  _dedupe_by_name replaced with _enrich_cloud_metadata: every tier
  returns its full list across all three slots (printer / process /
  filament). The function still backfills Bambu Cloud filament metadata
  from same-named local / orca_cloud / standard entries so cloud
  filaments score in pickFilamentForSlot.

  CloudStatusBanner silently no-ops on not_authenticated for both clouds;
  expired / unreachable still surface. The not_authenticated i18n keys
  stay in the locale files dormant.

  ConfigureAmsSlotModal: one source badge per row, one colour per source
  (green Local / purple Orca Cloud / bambu-blue Bambu Cloud / amber
  Built-in). The legacy isUser-driven "Custom" badge is gone; every row
  identifies its tier consistently.
2026-06-11 12:17:45 +02:00
maziggy 4ab7339fe4 debug(vp): env-flagged wire-payload dump for slicer-mirror triage (#1622)
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's
  cached push_status (in) and the 1Hz slicer-facing copy (out) to
  <log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick.

  #1622's symptom — empty filament dropdown in slicer's AMS slot details
  for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into
  the actual wire bytes flowing through the bridge to bisect between
  "cache is missing fields" and "_send_status_report strips them on copy."
  The existing logs prove the bridge is bound and pushing at 1Hz, but
  not what's in the payload.

  Off by default, single env flag, single file per VP per direction
  (bounded disk footprint), failures swallowed at debug so a broken
  dump can never break the 1Hz loop. 21 tests pin the helper contract:
  disabled-by-default, atomic writes, sanitized vp_name (no path
  escape), per-call env check so toggling without restart works.
2026-06-11 10:02:22 +02:00
maziggy bf781e0566 fix(notifications): reprint-from-archive sent original print's finish photo (#1707)
Reprints reuse the source archive row via the expected-print promotion
  branch, but that branch never reset archive.timelapse_path. The stale
  path made _scan_for_timelapse_with_retries early-return (so the new
  run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse
  then extracted the *original* run's last frame and shipped it to Telegram
  as the new run's finish photo. Surface was specific to the
  timelapse-prefer path (data.timelapse_was_active=true and no external
  camera) — fallback live-camera paths were unaffected, which is why this
  took a P2S reporter to surface.

  Clear archive.timelapse_path at promotion and unlink the stale on-disk
  MP4 (best-effort; missing files are logged and skipped). The orphan
  unlink also kills a long-standing file-leak: every reprint used to leave
  its predecessor's timelapse on disk forever. archive.photos is
  deliberately left alone — accumulating one finish photo per run is
  correct.
2026-06-11 09:23:03 +02:00
maziggy 8711c54ec3 feat(installer): scaffold Windows installer build pipeline
Lays down the Inno Setup + embedded Python pipeline for producing a
  self-contained Bambuddy Windows installer .exe. The installer ships
  an embedded Python 3.13, the pre-built React bundle, NSSM (service
  supervisor) and ffmpeg — no host Python or Node required on the
  target machine.

  Architecture:
  - Install: C:\Program Files\Bambuddy (admin install, one-time UAC)
  - Data:    C:\ProgramData\Bambuddy\data (preserved on uninstall)
  - Service: registered via NSSM, runs as LocalSystem, autostart on boot
  - UI:      browser at http://localhost:8000 (Start Menu shortcut)

  Files:
  - installers/windows/build.py            stages embedded Python + deps,
                                           frontend bundle, NSSM, ffmpeg
  - installers/windows/bambuddy.iss        Inno Setup compiler script
  - installers/windows/service/*.bat       NSSM register/deregister
  - .github/workflows/windows-installer.yml CI build on tag push + manual
                                           dispatch, uploads .exe artifact

  build.py hard-fails on non-Windows hosts; Wine cross-build is an
  unsupported escape hatch behind --allow-non-windows. v1 ships unsigned
  (SmartScreen warns on first run) — production signing will be wired up
  via SignPath OSS once the application is approved.

  See installers/windows/README.md for build prerequisites and the
  embedded-Python ._pth gotchas.
2026-06-10 12:13:53 +02:00
maziggy defec334c4 feat(a2l): add Bambu Lab A2L support (#1684)
Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
  serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
  Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
  cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
  port 6000.

  The BambuStudio profile's use_double_extruder_default_texture: true flag
  describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
  must NOT be classified as dual-nozzle or AMS routing will target the deputy
  slot and firmware rejects with 07FF_8012.

  Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
  virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
  12 new test cases in TestA2LModel pin every dimension.
2026-06-10 10:59:27 +02:00
maziggy e737c84c6e fix(diagnostic): skip external_storage check on A1 / A1 Mini (#1703)
A1 and A1 Mini ship without a MicroSD slot at all - there is no
  firmware-side "Store sent files on external storage" toggle and the
  slicers don't surface a slicer-side equivalent either. The connection
  diagnostic was reading state.store_to_sdcard (home_flag bit 11), which
  is never set on these models, so the check fell through to fail for
  every A1-series user. Combined with the absent slicer UI it left users
  thinking Bambuddy was wrong about a setting their hardware does not
  have.

  New NO_EXTERNAL_STORAGE_MODELS frozenset in utils/printer_models.py
  enumerates A1, A1 Mini, and their internal codes (N1, N2S, A04, A11,
  A12). has_external_storage() returns False for those, True for
  everything else. Unknown models default to True so the check stays
  active for future Bambu lineup additions - new no-slot models must be
  added to the set explicitly.

  The diagnostic now short-circuits to skip before reading
  store_to_sdcard when printer.model is in the set. X1, P1, P2S, H2,
  and X2D are unchanged - the bit-off -> fail signal is still the right
  read for them.

  The companion FTP-upload-timeout symptom in the same bug report (ftp
  code 28 from BambuStudio when sending to the proxy VP) is a separate
  Docker-bridge-mode networking constraint, not addressed here.
2026-06-10 08:54:24 +02:00
maziggy 0793022818 fix(ams): keep slot card preset name in sync after spool swaps
The slot card on PrintersPage shows slot_preset_mappings.preset_name
  first in its display fallback chain. Three write paths swap which
  spool occupies a given slot:

  - internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
  - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
  - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)

  Only the first one was reconciling the row. After an RFID-driven
  spool change, the card kept surfacing the previous spool's preset
  name until the user opened Configure Slot manually.

  Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a
  freshly-inserted Bambu PLA-CF spool. The matching row in
  slot_preset_mappings was last written in March when a PLA Silk+
  spool had been in that slot - confirmed live in the database.

  New backend/app/services/slot_preset_writer.py exposes a primitive
  upsert_slot_preset plus two derivation wrappers: one for the
  internal Spool ORM object, one for the Spoolman API dict shape.
  All three call sites now go through the helper, so the row stays
  in lockstep with the assigned spool regardless of inventory mode.

  Bug shape exists in both inventory modes and the patch fixes both
  per feedback_inventory_modes_parity. The Spoolman path was latent
  for users who'd never manually picked a slot preset; the same
  "stale row overrides correct catalog name" symptom appeared for
  those who had.

  Existing stale rows self-heal on the next RFID-driven swap.
2026-06-10 08:39:21 +02:00
maziggy 60b253f98a fix(failure-reason): consistent camelCase keys across UI surfaces
The Stats page's Failure Analysis widget and the per-archive run
  sub-table rendered the raw PrintLogEntry.failure_reason value
  without translating, so the camelCase keys saved by the new
  Print Log row editor (#1687 part 4) surfaced as literal
  "filamentRunout" / "cloggedNozzle" text. The Print Log table did
  translate the value, so the inconsistency was visible from one
  surface to the next.

  EditArchiveModal was also still saving the localised label as the
  column value while the new editor saved the key - same column,
  two formats, two failure modes (group fragmentation on language
  switch, new PATCH validation rejection on round-trip).

  Three surfaces fixed in one drop:

  1. StatsPage.tsx and PrintLogTable.tsx wrap the value in
     t('editArchive.failureReasons.${reason}', { defaultValue: reason }) -
     the defaultValue path keeps legacy translated-text rows rendering
     unchanged.

  2. EditArchiveModal stores the camelCase key on save and reverse-
     looks up any legacy translated-text value against the current
     locale on open. Every save thereafter converts that row forward
     to the key format, so the column self-heals over time.

  3. Added htmlFor/id to the failure-reason label/select pair (a11y
     plus testability).
2026-06-10 08:02:36 +02:00
maziggy 8c326256db fix(system): emit boot_time and generated_at as tz-aware UTC
datetime.fromtimestamp(ts) and datetime.now() return naive local
  datetimes; .isoformat() then emits no tz marker. The frontend's
  parseUTCDate helper appends 'Z' to bare strings, treats the value
  as UTC, then converts to local for display — applying the local
  offset twice. Reporter on UTC+3 saw boot_time +3h ahead while
  uptime was correct (uptime is a backend-side delta of two
  naive-local values, so the missing tz info cancels out).

  Fix: pass tz=timezone.utc to datetime.fromtimestamp and
  datetime.now in system.py's boot_time / uptime path, plus the two
  adjacent generated_at sites in system.py and support.py.
2026-06-10 07:49:46 +02:00
maziggy e01d3a7979 feat(mqtt): one-shot device identification probe for unknown models
Logs device.dev_model_name / dev_product_name / dev_id / project_name
  at INFO level once per client session, falling back to device.keys()
  if none of the known fields are present.

  The MQTT push_status carries the model code in device.dev_model_name
  on every message, but nothing in bambu_mqtt.py reads or logs that
  field — so adding a new printer model meant chasing the code through
  either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the
  case that surfaced this: get_version also failed because the firmware
  disconnected right after request topic subscription, so the support
  bundle had no way to disclose the model.

  INFO level so the line lands in support bundles without enabling
  debug. One-shot via _device_id_logged, mirroring the existing
  _nozzle_fields_logged flag at line 2095, so push_status spam is
  avoided.

  Future-proofs against Bambu renaming the field (the fallback dumps
  device.keys() so a rename like model_name without the dev_ prefix
  is still observable). 3 unit tests in TestDeviceIdentificationProbe
  pin all three branches.
2026-06-10 07:38:02 +02:00
maziggy 2bb69cb079 fix(printer-models): swap A1 / A1 Mini in PRINTER_MODEL_ID_MAP
N1 and N2S were flipped relative to every other registry that names
  them - firmware_check.py (N2S -> "a1"), virtual_printer/manager.py
  (both the model map and the serial-prefix map: N2S -> 039 = A1,
  N1 -> 030 = A1 Mini), and printer_manager.py A1_MODELS all agree on
  N2S = A1, N1 = A1 Mini. Only printer_models.py had it backwards, so
  any path that resolved an A1-family printer by internal code rather
  than serial prefix would silently misclassify.

  Also fixes the matching comments in LINEAR_RAIL_MODELS - cosmetic only
  (both codes were already in the frozenset) but kept the file
  self-consistent.

  New TestA1SeriesModelIds regression test pins both directions so a
  future re-flip fails loudly.
2026-06-10 07:29:31 +02:00
maziggy 4ff4bffe9f fix(restore): pause timer-based DB writers before swap (Postgres deadlock)
close_all_connections() only disposes the engine's connection pool —
  asyncio tasks like print_scheduler.run() and the smart-plug snapshot
  loop wake on their 30 s cadence and lazily reopen pool connections
  holding RowExclusiveLock on print_queue / smart_plug_energy_snapshots.
  The restore's DROP TABLE ... CASCADE pass needs AccessExclusiveLock on
  every public table, producing an AB/BA deadlock that rolls back the
  entire restore transaction.

  Reproduced 2026-06-09 restoring a native install's backup into a fresh
  Docker+Postgres deploy:
    asyncpg.exceptions.DeadlockDetectedError: deadlock detected
    Process X waits for AccessExclusiveLock on relation 109940
    Process Y waits for RowExclusiveLock on relation 110182

  Fix:
  - Layer 1: pause print_scheduler / smart_plug_manager /
    notification_service / background_dispatch via their existing stop
    affordances before close_all_connections(), with a 1.0 s sleep for
    in-flight loop iterations to release sessions. Restore handler
    already requires a container restart on success, so the paused
    services come back via the next lifespan startup.

  - Layer 2: prepend SET LOCAL lock_timeout = '10s' to the begin-block
    in _import_sqlite_to_postgres so any reactive writer (per-printer
    MQTT, hourly AMS history recorder) that slips through the pause
    window fails fast and visibly instead of producing a new deadlock.
2026-06-09 13:54:55 +02:00
maziggy 53b318c797 fix(ams): surface active K-profile when no slot preset is resolvable (#1689 follow-up)
Configure Slot dropped to "default 0.020" on reopen for slots that were
  physically loaded but unconfigured (tray_type="", no slot_preset_mappings
  row). The #1689 cali_idx safety net was unreachable from that path —
  matchingKProfiles early-returned [] on !selectedPresetInfo before the
  safety net ran.

  Split the early return so the cali_idx fallback survives the no-preset
  case: when selectedPresetInfo is null but slotInfo.caliIdx > 0, return
  the active profile as a single-item list (extruder-matched when known).
  Strictly additive; caliIdx == 0/null still returns [], existing matcher
  unchanged when a preset is resolvable.
2026-06-09 12:45:36 +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 60e31634b8 feat(diagnostic): add "Store sent files on external storage" check (install step 4)
Detects the printer-side variant of install step 4 — many users (esp. on
  clean installs) forget to enable this and only notice when their archive
  cards have no thumbnails. The diagnostic now catches it upfront.

  Detection: read state.store_to_sdcard, which Bambuddy already parses from
  MQTT push_status home_flag bit 11 (bambu_mqtt.py:153). Instant, no I/O.

  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, not the
  printer's acceptance policy. Confirmed empirically against X1C + H2D
  with the slicer option toggled off: probe succeeded, home_flag bit 11
  stayed True. So the only reliable signal is what the printer actually
  reports about its own state.

  Limitation: the printer-side variant only exists on newer firmware
  (P2S 01.02 / Bambu Studio 2.6+). On older versions the toggle lives
  only in the slicer and the printer never hears about it, so this check
  will pass even when the user is missing step 4 in BambuStudio. The
  skip-text and the wiki call this out explicitly. A reactive banner on
  the no-3MF archive-fallback path is planned as a follow-up to cover
  that case.

  Statuses:
  - pass:  state.store_to_sdcard is True
  - fail:  state.store_to_sdcard is False (-> overall escalates to problems)
  - skip:  no live state, disconnected, or field never populated
2026-06-09 12:08:37 +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 fdcc063d9f fix(k-profile): match by filament_id, surface active profile in Configure Slot (#1688 + #1689)
Two related bugs in K-profile matching, same root cause.

  #1688 — spool form's PA-profile suggester (PAProfileSection via
  isMatchingCalibration in spool-form/utils.ts) matched K-profiles by
  parsing the profile NAME for material/brand/variant. Spools already
  store slicer_filament (the slicer preset id) and K-profiles already
  carry filament_id, but both were ignored — so a user's custom
  K-profile whose name doesn't agree with the slicer preset got silently
  dropped from suggestions even when the underlying filament_id was
  identical.

  #1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same
  name-only logic on the slot's selected preset. A spool assigned under
  "Generic PLA" with a custom K-profile actively bound on the printer
  landed in the modal as "K profile not assigned, default 0.020 will
  be used", while the printer-card hover-card correctly showed the
  active profile. Two paths, only one was filtering by name.

  Shared root: spool preset ids and K-profile filament_ids look
  different but are equivalent after normalising. Spools store
  slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is
  the variant suffix, the S infix marks setting_id form); K-profiles
  store filament_id as the bare form ("GFG98"). Plain === doesn't
  work; both need normalising. This conversion already existed in the
  other direction at buildFilamentOptions (filament_id → "GFS" +
  filament_id.slice(2)), so the inverse toFilamentId helper is just
  the matching reverse, not new ground.

  Fix — one shared helper, two surfaces:

  - spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN"
    variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98)
    and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids
    which are shared across many filaments and must NOT id-match —
    the name fallback handles those correctly).

  - isMatchingCalibration: gains slicer_filament?: string in formData,
    tries id-match (with generic exclusion) before the existing name
    parse. PAProfileSection already passes the full formData so no
    caller edit needed. Strictly additive precedence.

  - ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId
    field (toFilamentId(cp.setting_id) for cloud presets,
    toFilamentId(builtinFilamentId) for builtin; empty for local /
    orca paths which fall through to name match).

  - ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top
    of the per-profile predicate (preferred when both sides agree
    after normalisation), then the existing name-parse logic, then
    ALWAYS unshifts the slot's currently-active K-profile by
    slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx
    0/null doesn't leak unrelated profiles in), extruder-matched when
    slotInfo.extruderId is known. This is Spionkiller01's #1689 patch
    verbatim with the activeIdx > 0 guard added.

  SpoolBuddy: both kiosk K-profile surfaces reuse the shared
  components. SpoolBuddyWriteTagPage renders PAProfileSection;
  SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes
  propagate automatically, no kiosk-specific edits.

  What this does NOT change: spools without slicer_filament, K-profiles
  without filament_id, and generic GFx99 ids all fall through to the
  existing name-based matching path. Strictly additive precedence; no
  input shape that matched under the old logic fails to match under the
  new. The #1053 cloud-preset PFUS* path is preserved because the
  toFilamentId regex /^GFS/ doesn't match a "PFU" prefix.
2026-06-09 09:53:28 +02:00
maziggy e9b6fefe95 fix(auth): redirect to /login when JWT expires mid-session (#1698)
When the JWT expired on an open tab, the next API request hit a 401 with
  "Token has expired"; client.ts cleared the token from storage but
  AuthContext.user stayed populated from the original mount. ProtectedRoute
  only redirects when user === null, so the protected tree kept rendering
  and every subsequent request silently failed with no Authorization
  header — the UI looked like every list was empty until a manual refresh
  remounted AuthProvider.

  The 3 other setAuthToken(null) sites live inside AuthContext itself and
  already pair with setUser(null), so only the client.ts cross-module
  site needed a React-tree signal.

  - client.ts: after setAuthToken(null) on a token-invalidating 401,
    window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on
    `typeof window !== 'undefined'` for SSR / test safety. Generic
    "Authentication required" 401s still don't clear the token or fire
    the event — treated as transient timing issues per the pre-existing
    comment at client.ts:155.

  - AuthContext.tsx: mount useEffect adds a window listener that calls
    setUser(null) under the mountedRef guard; cleanup removes the
    listener so unmount → remount doesn't double-bind.

  Mirrors the patch the reporter shipped on their fork (deec96d1).
2026-06-09 09:25:12 +02:00
maziggy 72044e3a53 fix(usage): scope 3MF filament tracking to dispatched plate (#1697)
When a print targets a single plate from a multi-plate 3MF, both the
  internal Filament Inventory tracker and the Spoolman-mode tracker parsed
  the 3MF without a plate filter and summed every plate's filament — so a
  single lid print debited the spool the entire file's grey + black totals.

  The 3MF parser already supports plate_id (queue pre-flight uses it at
  print_queue.py:254/:286). Plumbed it through both dispatch paths:

  Queue path:
  - PrintSession gains a plate_id field; on_print_start queries the
    printer's currently-printing queue row and records queue_item.plate_id
    onto the session.
  - _track_from_3mf accepts plate_id and passes it to the extractor.
  - store_print_data moves its existing queue-item lookup above the
    extract and uses queue_item.plate_id as the plate filter.

  Direct-Print path (reprintArchive / printLibraryFile — never goes
  through the queue):
  - _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
  - register_expected_print accepts plate_id and stores it; the 2 sites in
    background_dispatch.py and the 1 site in print_scheduler.py now pass
    it (resolve was already happening, just needed reordering before the
    register call so the value is available).
  - Expected-print promotion in main.py injects _print_plate_ids[archive_id]
    into the session, guarded so a queue capture wins over the dict.
  - _get_start_plate_id helper feeds plate_id into all 3
    _store_spoolman_print_data call sites; spoolman_tracking.store_print_data
    takes the caller value first, falls back to queue_item.plate_id.

  PrintArchive.filament_used_grams stays file-level summed by design
  (#1593's contract — the archive describes the file, not the run); only
  the per-run usage attribution becomes plate-aware. Single-plate direct
  prints resolve to plate_id=1 → plate 1 = whole file, identical to the
  prior no-filter behaviour.
2026-06-09 09:16:36 +02:00