Commit Graph
943 Commits
Author SHA1 Message Date
maziggy f2e8d5ae91 Hosekeeping 2026-06-28 13:57:10 +02:00
maziggy 7f937405be Merge remote-tracking branch 'origin/main' into 0.2.4.8 2026-06-28 13:56:09 +02:00
maziggy 01d688f82d Housekeeping 2026-06-28 12:51:43 +02:00
maziggy 0ebd354384 . 2026-06-28 12:28:16 +02:00
maziggy c254eedfd1 Housekeeping 2026-06-21 09:58:52 +02:00
maziggy fcd43019e3 Updated BACKERS 2026-06-19 08:48:38 +02:00
maziggy e77f97060e Housekeeping 2026-06-18 07:02:29 +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 5fdea009fc fix(auth): preserve original URL across login + OIDC round-trip (#1750)
ProtectedRoute and PermissionRoute now pass the requested location as
  router state when redirecting to /login. LoginPage stashes it in
  sessionStorage before the OIDC provider redirect (since window.location
  kills React state) and consumes it on all three post-login navigations
  (credentials, 2FA, OIDC token exchange). Targets are sanitized to
  same-origin internal paths only — protocol-relative and /login itself
  are rejected to prevent open-redirect.

  QR labels (https://host/inventory?spool=N) now land on the scanned
  spool instead of the printer page after authentik / any OIDC SSO login.
2026-06-15 11:46:32 +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 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 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 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 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 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 f898556941 fix(a2l): transparent printer-card image + getPrinterImage resolver
Drop the off-white background on the A2L marketing render so it composites
  cleanly on the dark theme (every other printer image in public/img/printers/
  is RGBA with transparent corners; A2L shipped as opaque #F7F7F7). Resize to
  320x320 to match the rest of the artwork.

  Also wire A2L into getPrinterImage so the printer card actually shows the new
  artwork -- without this the resolver fell through to default.png for both
  the A2L display name and the N9 internal SSDP code.

  - frontend/public/img/printers/a2l.png: new, 320x320 RGBA, transparent
  - frontend/src/utils/printer.ts: A2L / N9 -> a2l.png, placed above the a1mini
    branch
  - frontend/src/__tests__/utils/printer.test.ts: 4 cases mirroring the X2D
    shape -- display name, case-insensitive variants, N9 internal code,
    regression guard against accidentally matching A2M / A1 / A1 Mini
2026-06-10 11:09:02 +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 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 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 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 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 529155413f fix(ams): show "?" for loaded-but-unconfigured AMS slots (#1694)
Reporter on a 3-AMS P1S saw slots labelled "Empty" even though spools
  were physically loaded - OrcaSlicer's Device view showed the same
  slots as loaded.

  Root cause: the compact label below the AMS slot circle rendered
  tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever
  the printer firmware hadn't been told which material is in the slot.
  getEmptySlotKind already distinguishes 'physical' (firmware confirmed
  empty via state 9/10) from 'reset' (tray_type absent but firmware
  hasn't confirmed empty - spool loaded, just unassigned). The hover
  card and circle border already used that distinction; the compact
  label did not.

  Fix: label branches on emptyKind - 'physical' keeps "Empty", 'reset'
  shows "?" matching the slicer's own convention. External / VT tray
  label is unchanged (external trays have no "configured/unconfigured"
  distinction - they're either loaded or not). SpoolBuddy AmsUnitCard
  carried the same bug and got the same fix plus a tooltip.
2026-06-09 08:41:46 +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 432080d500 feat(queue): show build plate type on queue items + print modal (#1281)
Reporter on a multi-printer farm with 40+-plate runs needed to walk to
  the printer with the right physical plate, but the queue and the
  scheduling modal didn't surface curr_bed_type the way the archive card
  already did.

  New utils/threemf_tools.extract_bed_type_from_3mf helper (per-plate) so
  both the queue API and the /plates endpoint can return per-plate values.
  PrintQueueItemResponse.bed_type populated from archive.bed_type /
  library_file.file_metadata['bed_type'] as the default, then overridden
  per-plate via the helper when item.plate_id is set. /archives/{id}/plates
  (and library equivalent) include bed_type in each plate object.

  Per-plate accuracy matters because archive.bed_type is captured at
  ingest as only the first plate's value (services/archive.py:235) -
  a 40-plate 3MF mixing PEI + Engineering returns PEI at the archive
  level for every plate. The helper re-reads the 3MF and returns the
  truth.

  Frontend: queue card meta row + PlateSelector per-plate row + PrintModal
  header all render bed icon + canonical label via the existing
  getBedTypeInfo() helper, same as the archive card.
2026-06-08 10:23:02 +02:00
maziggy 5fbbcb6077 Post work PR #1659 2026-06-08 09:35:24 +02:00
maziggy 2c2725cb53 fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
  giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
  BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
  calibration off.

  start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
  1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
  always send 2 even if a stale flag arrives. The kwarg threads through
  printer_manager, both background_dispatch sites, and print_scheduler so
  every dispatch path respects the per-item setting.

  print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
  branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
  defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
  library FilePrintRequest, archive ReprintRequest, settings.

  PrintModal renders the new toggle only when the selected printer is dual-
  nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
  SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
  hide unless any registered printer is dual-nozzle. Labels reuse the existing
  settings.default* keys so the only new i18n strings are
  settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
  - real translations in all 11 locales.
2026-06-08 09:20:19 +02:00
maziggy 8957cfaeef fix(inventory): assign-spool toast no longer claims AMS configured when slot was empty (#1680)
Backend correctly defers MQTT configuration when the AMS slot is empty
  at assign time (state ∈ {9, 10}) — firmware drops the push silently;
  on_ams_change replays the config once a spool is detected. The
  response carries pending_config=true to communicate that. The
  printer-card AssignSpoolModal was ignoring the flag and always
  showing "Spool assigned and AMS slot configured" — the SpoolBuddy
  modal has handled this since it shipped. Now mirrors the same
  branch and shows "Assigned. Slot will configure when you insert
  the spool." when pending_config is true.
2026-06-08 08:34:33 +02:00
maziggy a104ccc95c fix(pwa): don't force-navigate first-install clients in SW activate
Repro on every fresh demo subdomain: visitor lands on Printers OK, first
  sidebar click sticks on a spinner (Chromium) or trips "Corrupted Content
  Error" with sw.js stuck `activating` (Firefox). Manual reload recovers.

  Root cause is the `client.navigate(client.url)` call added to the activate
  handler in 18d534c9 (intended to force kiosks to pick up new bundles).
  Its guard — `client.url && typeof client.navigate === 'function'` — does
  not distinguish first install from upgrade. On any fresh origin (every
  demo session, every first-time visitor, every cleared profile) it still
  fired: Chromium raced it against the in-flight SPA mount; Firefox
  deadlocked the activate's waitUntil on `await client.navigate(...)`
  because the SW intercepts its own document fetch while still activating.

  Split the lifecycle correctly:
  - sw.js activate handler: just cache cleanup + clients.claim().
  - sw-register.js: capture `hadController = !!serviceWorker.controller`
    at load, listen for `controllerchange`, reload only when hadController
    was true. Returning kiosk hits a new deploy -> had controller -> reloads
    as before; first-install visitor -> no controller -> no forced nav ->
    React mount completes.

  Bump CACHE_NAME v29->v30 and STATIC_CACHE v28->v29 so existing browsers
  fetching the new sw.js drop the old CacheStorage in the same pass.

  SpoolBuddy unregister branch and notificationclick's client.navigate are
  unrelated and unchanged.
2026-06-07 15:40:22 +02:00
maziggy 96ce403554 fix(archive): HTML-unescape 3MF Title metadata; correct VP name tooltip (#1658)
ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of
  "PCB Vise & Solder Station" landed in the DB as the literal "&amp;" and
  React re-escaped it on render to "&amp;amp;". Apply the same
  loop-until-stable html.unescape() the sibling ProjectPageParser already
  uses, uniformly across all <metadata> values.

  Same drop: rewrite the VP archive-name-source tooltip in all 11 locales.
  BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed
  Send-dialog name with the slugified 3MF Title field whenever one is
  present, so the previous "handy if you renamed the job in the send dialog"
  copy was false. New text spells out the actual behavior; both Filename
  and Metadata modes often produce the same string for that reason.
2026-06-07 11:43:19 +02:00
maziggy ea4d5eb794 fix(ui): drying popover footer no longer clipped by iOS Safari URL bar (#1669)
100vh and window.innerHeight report the layout viewport on iOS Safari, so
  the popover's Start Drying button rendered behind the bottom toolbar overlay.
  Switch the popover maxHeight to 100dvh and default computePopoverPosition's
  viewportHeight to visualViewport.height (with innerHeight fallback). The
  flip-above decision and the body-scroll fallback now both run against the
  actually-visible area, so the footer button stays reachable on iPhone.
2026-06-07 08:08:44 +02:00
maziggy b8916ac3de fix(presets): match Bambu cloud @BBL A1M as A1 Mini (#1649)
Reporter on an A1 Mini saw the AMS slot Configure dropdown render no
  Bambu / Generic filament profiles, and saw the Profiles tab strip
  A1 Mini results when filtering by that model. Bambu rolled out a
  profile rename mid-2026: the @BBL <code> suffix on 106 cloud profiles
  shifted from the long display form to a terse model code -- e.g.
  "Bambu PLA Basic @BBL A1 Mini ..." is now
  "Bambu PLA Basic @BBL A1M ...". User-authored profiles still use the
  long form. Bambuddy's filters did a verbatim uppercase compare
  ("A1M" vs "A1 MINI"), so every renamed cloud profile silently
  disappeared from the picker.

  Centralize the alias check in slicerPrinterMatch.ts. New
  PRINTER_MODEL_SUFFIX_ALIASES table maps "A1 Mini" <-> "A1M"
  bidirectionally; exported matchesPrinterModelSuffix() does the
  case-insensitive compare with the alias fallback. Two consumer
  sites swap to the helper:

    * ConfigureAmsSlotModal.tsx (Orca cloud and Bambu cloud filter
      branches) -- the AMS slot picker, hit directly and reached from
      SpoolBuddy's AMS page via mapModelCode(printer?.model)
    * slicerPrinterMatch.ts:classifyByBambuName -- the SliceModal
      Process / Filament compatibility check

  Backend printer_models.py also gets a "Bambu Lab A1M" -> "A1 Mini"
  entry so server-side 3MF model normalization stays consistent if a
  3MF ever embeds the short form.

  Kept the alias table narrow on purpose. Wide-net aliasing (e.g.
  "X1" <-> "X1C") would silently collapse physically distinct
  printers. When Bambu introduces the next rename, it is one new row
  in the table -- /api/v1/cloud/settings is the place to grep, called
  out in the source comment.
2026-06-06 09:10:27 +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 12d17bfbe7 fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
  live-camera grab captured the bed already dropped, ruining the photo
  framing. Source the photo from a brief Bambu timelapse instead —
  firmware stops timelapse recording AFTER toolhead parks but BEFORE
  bed-drop runs, so the last frame frames the finished print correctly.

  When capture_finish_photo is on AND the user did not opt in to
  timelapse for this print, force timelapse=True at dispatch + mark the
  new PrintArchive.bambuddy_forced_timelapse column. After extraction
  (success or failure), cleanup deletes the locally-attached file,
  clears archive.timelapse_path, and walks the four scanner directories
  (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
  against the original filename. User-opted-in timelapses pass through
  unchanged.

  Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
  (module-level so the print queue can reuse it). Both dispatch paths
  wired: background_dispatch.py (Print Now / Reprint) AND
  print_scheduler.py:_start_print (the queue). Field testing caught the
  scheduler gap on the first round — AST regression test now asserts
  start_print(timelapse=...) references effective_timelapse, not the raw
  item.timelapse, so a future refactor can't silently drop it.

  Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
  frames overwrite the same output file, so the file left on disk is the
  literal last frame regardless of duration. Bambu records one frame per
  layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
  original -sseof -1.0 approach seeked before the start of the file and
  returned frame 0 (empty bed). Decoding every frame is fine; Bambu
  timelapses are short by construction even on hours-long prints.

  Migration adds bambuddy_forced_timelapse branched on is_sqlite()
  (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
  Verified live on postgres:16-alpine.

  Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
  so the notification carries the bed-up photo instead of falling back
  to the live-cam grab on slow links.

  Scope limit, documented in the camera wiki: prints started directly
  on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
  both dispatch paths, so the override doesn't fire there. Future
  option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.

  Setting description rewritten in all 11 locales to drop the "only
  works when timelapse enabled" caveat (Bambuddy now forces it) and
  explain the kept-or-deleted behaviour.
2026-06-05 13:53:26 +02:00
maziggy eb418a5b37 feat(discovery): scan a custom subnet for cross-router printers (#1564)
SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so a
  printer behind a router on a different L3 segment was invisible to
  "Discover Printers on Network". Docker mode had a CIDR text input but
  only as a fallback when zero interface subnets were detected; native
  mode had no subnet field at all.

  AddPrinterModal now surfaces an always-visible subnet picker. Detected
  interface subnets stay as dropdown options, plus a "Custom subnet..."
  sentinel reveals a CIDR text input. When custom is picked, discovery
  routes through POST /discovery/scan with the typed CIDR instead of
  SSDP (which would no-op against a foreign subnet anyway). Last custom
  CIDR persisted to localStorage so VLAN users don't retype every time.

  Scan button label and scanning / no-printers-found strings all key off
  (isDocker || useCustomSubnet) so wording stays "Scan Subnet..." /
  "Scanning subnet..." whether the user is on Docker or just picked
  Custom.

  Backend unchanged: SubnetScanner.scan_subnet() already accepts any
  CIDR, already caps at /22 (1024 hosts), POST /discovery/scan already
  takes user-supplied input.
2026-06-05 11:52:07 +02:00
maziggy 342ad31489 fix(projects): make edit modal scrollable so Save is reachable on short screens (#1642)
Reporter on a 1508x831 Pi display couldn't mark a project as Completed
  because the edit modal's height exceeded the viewport: the outer wrapper
  centers vertically and the inner card had no max-h and no overflow, so
  the top half scrolled above and the bottom half (Status dropdown +
  Save/Cancel) scrolled below. Workaround was a full page reload.

  Standard flex-modal-scroll fix: max-h-[calc(100vh-2rem)] + flex flex-col
  on the card; a flex-1 overflow-y-auto min-h-0 wrapper around the form
  fields; Cancel/Save moved into a flex-shrink-0 sibling with a border-t
  separator so they're always visible regardless of scroll position.
  Buttons stay inside <form> so type="submit" still works.
2026-06-05 10:52:10 +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 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00
maziggy 24ce250176 fix(labels): single PDF per click (#1628)
window.open(url, '_blank', 'noopener,noreferrer') returns null even on
  success per the WindowFeatures spec — `noopener` deliberately suppresses
  the return reference. The label-print modal treated null as "popup
  blocked → fall back to <a download> click", so the fallback fired on
  every click. Result: window.open opened the blob tab (downloading a
  random-named PDF on systems without an inline viewer) AND the fallback
  downloaded bambuddy-labels.pdf — two identical PDFs per click.

  Drop noopener,noreferrer. The blob is same-origin, the destination is a
  passive PDF preview tab with no script context, and noreferrer is no-op
  for blob URLs. window.open now returns a real window reference on
  success and the if (!win) fallback only fires on genuine popup-block.
2026-06-04 11:04:57 +02:00
maziggy c571ad86dd feat(diagnostic): printer_publishing check + countdown UI (#1622)
The existing connection diagnostic proved TCP + TLS + auth + SUBSCRIBE but
  not that the printer was actually publishing reports. A wrong-cased serial
  passes mqtt_auth because the broker accepts the subscription regardless;
  the user-visible symptom is empty AMS / no K-profiles / no custom filaments
  in the slicer Device tab because the VP cached state is empty. Bambuddy
  already logged the actionable hint at bambu_mqtt.py:498 but only to
  container logs.

  New printer_publishing check turns that warning into a structured
  diagnostic result. Pass = bridge has seen at least one report since the
  last (re)connect; fail = zero reports across the wait window with fix-text
  pointing at the case-sensitive serial. Bounded 10s poll on the on-demand
  UI route, no wait on the support-package gathering path so bundling stays
  fast. Exits the moment a message arrives — typical wall-clock is 1-2s.

  Frontend renders an elapsed-seconds counter plus a "Listening for status
  report — up to 10s" hint during the pending state so the wait doesn't look
  hung. PUBLISH_WAIT_DEFAULT_SECONDS pinned on both sides.

  report_messages_since_connect exposed as a public property on
  BambuMQTTClient so the diagnostic doesn't reach into private state.
2026-06-04 10:54:04 +02:00
maziggy a1cb5d5b4d fix(backup): interpret scheduled-backup HH:MM as local time, not UTC (#1602 follow-up)
The Scheduled Local Backups time-of-day picker was interpreted as UTC by
  _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00
  local backup. The UI labeled the field "UTC" but it was still surprising.

  Picker is now interpreted in the container's local timezone, resolved
  from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's
  environment.timezone shows). UTC fallback when TZ is unset or
  unrecognised. The /local-backup/status endpoint exposes the resolved
  zone, and the UI renders it next to the field via a new
  backup.localTimeHint i18n key with real translations in all 10
  non-English locales.

  One-time behaviour change for users who entered a UTC time as a
  workaround: the first scheduled cycle after upgrade will run at their
  local TZ offset earlier than expected. Re-enter the time as local once
  and it is correct from then on. No migration is shipped; migrating
  around a DST boundary would be ambiguous.
2026-06-04 10:17:10 +02:00