Commit Graph
970 Commits
Author SHA1 Message Date
maziggy 9d74f9281b feat(deficit): backup-aware filament deficit check, colour-strict (#1762)
When the printer reports ams_filament_backup=True,
  compute_deficit_for_queue_item pools remaining_grams across spools
  matching (preset, colour) on the same printer (scoped per extruder on
  dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
  same slicer_filament preset AND same colour (alpha-normalised). Two
  PETG HF spools in different colours are NOT pooled — the firmware would
  swap correctly but the print would change colour mid-run. Spoolman side
  mirrors the rule via filament.id + color_hex. Backup OFF falls back to
  the pre-PR per-slot accounting line-for-line.

  8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
  pool insufficient, different presets, backup-OFF regression, dual-
  extruder side scoping, no-preset never pairs, colour-strict, and
  alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
  cases stay green.

  feat(printers): AMS Filament Backup modal with BS-style ring per pair

  Badge click on the Filaments section header (#1766) now opens a
  modal: filament-colour ring per backup pair, material name + rotation
  count in the centre, slot labels distributed around the colour band on
  contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
  widget. Lone slots are intentionally not listed. R / L badges per ring
  when the extruder map carries two distinct values; collapses to no-
  badge rendering for single-nozzle printers misflagged as dual.

  Esc keypress closes the modal. Theme-aware via CSS variables matching
  AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
  defensively dedupes duplicate ams.id entries observed on switch-VP
  aggregations.

  10 modal render cases pin: Esc closes / unmount nulls the listener /
  ring renders for pairs and omits lone slots / R-L badges only when
  extruder map has distinct values / empty state / toggle gating.
  13 frontend cases pin computeBackupGroups identity rules.

  feat(printers): active-print P-N pill on AMS slot tiles during RUNNING

  While the printer is mid-print, each AMS slot tile referenced by
  status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
  right corner, naming which print-slot is mapped to that AMS slot.
  Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
  staged to a printer with mismatched filament, no way to verify mid-
  print. Same wire data (status.ams_mapping is already on the wire) —
  the addition is purely surface.

  The existing ring-bambu-green highlight for effectiveTrayNow keeps its
  meaning (currently extruding RIGHT NOW); the pill is the per-slot
  static assignment for the active print.

  chore(scheduler): log Print Anyway short-circuit at INFO

  _block_on_filament_deficit logs at INFO when it honours
  item.skip_filament_check, so a future "Print Anyway didn't work" report
  (third commenter on #1762 hit this shape) has actionable evidence in
  the standard support bundle without DEBUG. Bundled because the deficit
  fix makes the original symptom disappear for users with backup ON.
2026-06-22 10:00:02 +02:00
maziggy 4206d675eb feat(notifications): dedicate AI Failure Detection notification event (#1794)
Split Obico failure-detection dispatch out of the multiplexed
  on_printer_error event onto its own on_ai_failure_detection event so
  users can subscribe to AI alerts without also enabling HMS hardware-
  error pages, and so the discoverable label "AI Failure Detection" is
  what subscribes them rather than the unrelated "Printer Error" toggle.

  New column on notification_providers (default False, branched
  SQLite/Postgres migration), new notification_service.on_ai_failure_detection
  method, new ai_failure_detection template, obico_actions._notify swap.
  Frontend gets a summary badge, a toggle row with description, and ntfy
  priority surfacing. 14 new tests pin the routing + the regression guard
  ("Printer Error" alone must NOT receive AI notifications now). 11 locales
  covered.

  Existing providers keep working: HMS hardware errors continue to ride
  on_printer_error unchanged; users who want spaghetti alerts opt in via
  the new toggle.
2026-06-22 08:15:29 +02:00
maziggy 31ee7a5d9a feat(file-manager,archives): page-wide drag-and-drop upload (#1510)
Adds page-wide drag-and-drop file upload to the File Manager
  tab — drop any file anywhere on the page and the upload modal
  opens pre-populated with the dropped files. The Upload Files
  button still works for click-to-browse.

  Also fixes the Archives drag-cancel bug @maikolscripts reported
  in the same issue: cancelling a drag (drag back outside the
  browser, Escape mid-drag, or release outside the page) used to
  leave the overlay stuck until page refresh.

  Both pages share a new hook usePageFileDrop. The fix:
  - relatedTarget containment check (catches drag-out-of-window)
  - document-level drop / dragend / keydown(Escape) listeners
    that only register while isDraggingOver === true, so the
    three cancel paths all reset uniformly

  FileUploadModal gains an optional initialFiles prop so the
  File Manager page can pre-seed the modal from a page-wide
  drop. seededInitialRef guards against re-adding on re-renders.

  Permission gate: File Manager drop zone disabled when the
  user lacks library:upload, so a viewer-tier user doesn't get
  a misleading overlay.
2026-06-21 14:50:29 +02:00
maziggy c930c0e80d feat(printers): sort by ETA (#1609)
Adds an "ETA" option to the Printers page sort dropdown.
  Sorts the fleet by remaining print time so the printer that's
  finishing next sits at the top — useful for staging the next
  job's filament ahead of time.

  Tier ordering:
  - Tier 0: currently printing with remaining_time > 0
    (sorted ascending by remaining minutes)
  - Tier 1: currently printing without an ETA yet
    (post-start_print window before total time is known)
  - Tier 2: idle / finished
  - Tier 3: offline
  Name tiebreaker within every tier. The asc / desc arrow
  still applies after tiers resolve.

  Data source is the cached remaining_time (minutes) on the
  per-printer status query — the same field the per-card ETA
  label and the fleet "next finish" badge already read from.
  No new backend round-trip; the sort consumes data that's
  already in React Query cache and updated on every WebSocket
  push.

  groupedPrinters returns null for ETA too — every printer's
  ETA is unique so section headers would just produce a header
  per row. Flat list, like the existing name sort.
2026-06-21 14:22:20 +02:00
maziggy 4d16faed76 feat(file-manager): sort folder tree by recent activity (#1770)
Reporter has a lot of nested cad / slicer directories and wanted
  "folders that just got a new 3MF" surfaced without scrolling the
  alphabet. Tree was always alphabetical; LibraryFolder.updated_at
  only bumps on rename / move, not on file-add inside the folder.

  Backend exposes latest_activity_at = max(folder.updated_at,
  max(immediate-child file.updated_at)) on FolderResponse +
  FolderTreeItem. The /folders tree route picks up a sibling
  func.max(updated_at) group-by alongside the existing file-count
  subquery; the by-project / by-archive / single-folder routes
  collapse count + max into one trip. Recursion across subfolders
  is intentionally not computed - bubbles immediate parent only,
  keeps the query a single GROUP BY rather than a recursive CTE.

  Frontend adds a folder-sidebar sort dropdown (By name / By recent
  activity) plus an asc / desc arrow, persisted in localStorage.
  sortedFolders memo applies the comparator recursively so order is
  consistent at every depth. Empty folders fall back to name within
  the activity bucket so they never elbow a recently-used folder to
  a random position. Both the desktop sidebar and the mobile selector
  consume the sorted list so order is identical across breakpoints.

  External folders: LibraryFile rows are created for scanned external
  files too, so the aggregate works on them - but the timestamp
  reflects last scan, not filesystem mtime. Documented in the wiki.

  Same change also fixes File Manager list-view column alignment:
  header and body were sibling grids with min-content as the trailing
  column, computed independently. Header empty trailing div resolved
  to 0; body action strip to ~220px. Different trailing widths gave
  the 1fr Name column different remaining space, shifting every fixed
  column to its right. Replaced min-content with fixed 220px in both
  auth-on / auth-off grid templates.
2026-06-21 13:31:50 +02:00
maziggy d6e0c2b1d1 feat(queue): drag-reorder grouped queue items; collapsed batches no longer block
Batches were stuck where they were created. The parent row had no drag
  handle and wasn't registered with dnd-kit's SortableContext, so the
  only way to move a grouped item was to ungroup, drag, and re-group.
  Collapsed batches also acted as unmovable obstacles for adjacent items.

  The batch parent now registers under a synthetic "batch-<id>" string
  id and carries a GripVertical handle in the header (gated by
  queue:reorder). handleDragEnd resolves both endpoints: dragging a
  batch moves all its children as one block, dropping onto a batch
  anchors at the batch's first child so the group lands immediately
  before it. Direction-aware insert uses the first moving id's index
  instead of the previously single dragged id, so multi-row drags and
  batch drags share the same insert math. Within-batch child reorder
  is unchanged. DragOverlay gained a batch ghost showing
  "<name> (<N> copies)".
2026-06-21 12:59:00 +02:00
maziggy 68b9d741d9 feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
  (PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
  threshold (default 60%) was driving both the queue / ambient auto-drying
  trigger AND the hourly humidity alarm uniformly, which is wrong for
  multi-material setups where Nylon wants <10% and PLA is fine at 60%.

  Drying RUN parameters were already per-filament via drying_presets;
  this commit adds the missing per-filament TRIGGER.

  New setting ams_humidity_thresholds — JSON map of filament-type to
  threshold percent with a "default" key for unknown / unmapped types.
  Empty / unset → both consumers fall back to ams_humidity_fair so the
  upgrade is silent.

  Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
  thresholds, fallback) — picks the lowest (most-restrictive) threshold
  across all loaded tray types, matching the conservative-params strategy
  _get_conservative_drying_params already uses for temp / hours. Empty
  tray slots contribute no constraint; all-empty AMS falls through to the
  "default" key. Filament names normalized to uppercase base (so
  "PLA Basic" / "pla basic" both map to PLA).

  Two consumer sites rewired through the same resolver so the scheduler
  and the alarm path can never disagree about whether an AMS is "too
  humid":
    - print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
      for start / stop / skip decisions.
    - main.py AMS sensor / alarm worker — hourly humidity alarm notifier.

  UI: new table in Settings → Workflow → Auto-Drying, below the existing
  Drying Presets table. Default row + 8 default filament types
  (PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
  current ams_humidity_fair value so the editor starts sensibly.

  Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
  state per row). onChange only updates the draft; onBlur (and Enter)
  parses + clamps to [5, 95] + commits. Empty value on blur clears the
  override and falls back to default. Caught mid-PR via a typing test:
  the naive per-keystroke clamp snapped "3" → 5 before the user could
  type the second digit of "30".

  Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
  as drying_presets and ams_humidity_fair — non-sensitive integer map,
  no SETTINGS_READ permission required for badge-color rendering).
2026-06-21 11:55:37 +02:00
maziggy 36a16b8ae4 feat(printers): per-printer Maintenance Mode toggle (#1476)
Operator-flipped out-of-service state per printer for three scenarios:
  parallel Bambuddy installs (dev + prod where the printer rejects all
  but one MQTT client), printers under repair, and temporary suspension.

  The backend Printer.is_active gate has shipped since day one and is
  already honoured by every consumer — MQTT (printer_manager), queue
  dispatch (print_scheduler, print_queue), metrics, scheduler, picker,
  backup, maintenance dashboard. The missing piece was UI exposure.

  Three entry points to flip is_active:
  - Three-dot overflow menu (Enter / Exit maintenance mode, wrench icon)
  - Exit button inside the in-card amber panel
  - Checkbox in EditPrinterModal

  Card UI: expanded mode shows an amber panel (Wrench + "In Maintenance"
  + subtitle + Exit) where the cover/progress container would normally
  render — same height, no layout shift. Compact mode shows an amber
  pill in place of the progress bar. Header pill swaps Connected/Offline
  for "Maintenance" and the diagnostic CTA is suppressed (deliberate
  state, not involuntary offline). HMS / Queue / Firmware pills fall
  away naturally via the existing status?.connected gates.

  Mid-print entry (RUNNING / PAUSE) triggers a confirmation dialog —
  disconnecting MQTT mid-print stops progress tracking and completion
  notifications for the in-flight job. Idle / FINISH / FAILED skip the
  dialog and toggle directly.

  Scope: no backend change, no new permission, no behaviour change for
  any other consumer. PrinterCreate.is_active?: boolean added to the
  TypeScript surface so the field flows through api.updatePrinter.
2026-06-21 11:08:06 +02:00
maziggy 6b517ddc63 fix(printers): hide chamber fan badge on open-frame Bambu models
The Printers page rendered three fan widgets (part / aux / chamber)
  for every printer unconditionally. Open-frame models (A1, A1 Mini,
  A2L, P1P) have no chamber fan — the firmware reports big_fan2_speed
  as 0, so the badge always rendered greyed-out and let users "set" a
  fan speed on hardware that doesn't exist.

  Adds MODELS_WITH_CHAMBER_FAN allowlist (X1C / X1 / X1E / X2D / P1S /
  P2S / H2D / H2D Pro / H2C / H2S) near mapModelCode, and the chamber
  entry is spread into fanItems only when the printer's model is in the
  set. Open-frame printers now show two badges (part + aux), which
  matches their actual hardware.

  Allowlist not denylist: mirrors the file's existing classification
  pattern (the enclosure-door badge gate uses the same shape), and the
  failure mode is preferable — a missing badge on a real chambered
  printer is obvious; a phantom badge on a future open-frame model
  would look correct and silently lie.
2026-06-21 10:35:53 +02:00
maziggy 25a23eadd7 fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
  not find git executable" because (1) _find_executable's fallback paths
  are Unix-only, and (2) the installer stages backend/ via shutil.copytree
  so there is no .git directory — even with Git for Windows installed, the
  fetch would die on "not a git repository". Adding Windows paths would
  only have changed which error users saw.

  Switches the Windows installer path to a fourth update_method
  ("windows_installer") that mirrors the existing docker / ha_addon
  branches — surface a link to the release .exe and let the user re-run
  the installer, matching the Discord / Spotify Windows update model.

  Backend:
  - New _is_windows_installer_install() — true iff sys.platform == "win32"
    AND no .git in app_dir, so Windows devs with a real git clone keep
    the git path.
  - New _find_windows_installer_asset() picks the matching release asset
    (prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
    to the unversioned alias on non-daily tags).
  - /updates/check now returns is_windows_installer / update_method /
    installer_download_url.
  - /updates/apply short-circuits with a friendly message after the
    existing HA / Docker guards — defense in depth, the frontend swaps
    the button so the POST should not fire on Windows.

  Frontend:
  - UpdateCheckResult extended with the new fields and 'windows_installer'
    in the update_method union.
  - SettingsPage renders a Bambu-green styled <a target="_blank"
    rel="noopener"> between the Docker snippet and the in-app Update
    button, with installer_download_url falling back to release_url then
    the tag page so the link is never broken.
  - applyUpdateMutation onSuccess toast guard extended to treat
    is_windows_installer the same as HA / Docker.
2026-06-21 10:25:57 +02:00
maziggy 37b9bbfaaf fix(print-modal): disable printers between dispatch-accept and PRINT_START
Reported off-list by a corporate supporter running a multi-operator
  farm shift. Backend already rejects double-sends with HTTP 409 (see
  background_dispatch._dispatch at lines 283-290), so no double-print
  was possible, but PrinterSelector consulted only PrinterStatus.state
  ({IDLE, FINISH, FAILED}) to decide whether a printer was selectable.
  PRINT_START is the only signal that flips the printer out of IDLE, so
  during the upload + print-command + firmware-ack window the card
  stayed clickable and operators only found out after submitting.

  New useDispatchedPrinterIds hook subscribes to the existing
  background-dispatch WebSocket event (already consumed for the toast
  overlay), collects printer_ids from dispatched_jobs + active_jobs,
  and exposes them via useSyncExternalStore so every PrinterSelector
  instance shares one snapshot. OR'd into isPrinterBusy; badge label
  flips to "Dispatching..." instead of a misleading "Idle".

  No backend change — the reservation already exists, the UI just
  didn't reflect it. No behaviour change for add-to-queue / edit-queue
  modes (disableBusy stays false for those).
2026-06-20 20:45:22 +02:00
maziggy a5fe5cb3d4 feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
  sponsor conversion at 0.08% — roughly an order of magnitude under
  industry-benchmark for OSS with visible CTA. The Settings banner from
  0d4b9d4e gives passive every-visit visibility on one page; this adds
  opt-out-able active visibility at moments where the user has just
  earned something with Bambuddy.

  Five trigger families with a 14-day cross-family cooldown: prints
  (100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
  energy), archives (50/250/1000), anniversary (1 year), version-update
  (re-armable on each major bump). New sponsor_toast_state table with
  nullable user_id so auth-disabled installs get the same trigger logic
  through one code path (NULL-keyed install-default row).
2026-06-20 15:50:58 +02:00
maziggy 0d4b9d4e91 feat(settings): prominent sponsor banner on General tab
Matomo shows only 1.18% of website visitors reach /sponsors despite
  29% hitting /installation. The ask was discoverable on the marketing
  site but invisible in-app where users actually live.

  Full-width gradient banner sits above the three-column layout on the
  default landing tab and links to bambuddy.cool/sponsors.html with a
  ?from=app-settings tracking param so conversion lift is measurable
  in Matomo. Three new sponsors.* keys translated to all 11 locales.
2026-06-20 14:28:56 +02:00
maziggy 090c180ebf feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
  route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
  adds a 10x10 LineChart icon on each heater tile - click body opens the existing
  target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
  AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
  recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
  gets an interaction. Retention configurable via printer_sensor_history_retention_days
  (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
  all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
  6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
2026-06-20 12:55:24 +02:00
maziggy b1cb26f6ee feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
2026-06-20 12:07:20 +02:00
maziggy a39ed5b42e fix(ams-history): respect theme background variant in stats modal
The AMS humidity/temperature stats modal hardcoded color literals
  gated on a light/dark boolean, so it ignored the active background
  variant (neutral / warm / cool / oled / slate / forest) and the
  light-bg variants. Switched modal chrome, stat cards, and the
  recharts grid / axes / tooltip to read --bg-secondary, --bg-primary,
  --border-color, --text-primary, --text-secondary, --text-muted from
  the active theme so the modal follows mode AND background variant.
2026-06-20 10:00:13 +02:00
maziggy ca20342949 Post work PR #1701 2026-06-19 12:41:56 +02:00
maziggy 9e24d8d297 chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) 2026-06-19 11:49:53 +02:00
phieb b414af6b1c feat(gcode-injection): per-VP opt-in auto-print injection toggle (#1516) (#1656) 2026-06-19 11:29:07 +02:00
maziggy 9ca2dd08cf Updated BACKERS 2026-06-19 08:48:13 +02:00
maziggy db63e0b477 fix(printers): post-#1661 cleanup — test fixtures + remove hover-card fly-in
- The Speed and AMS load/unload tests broke after the printer-card
    refactor in #1661 (icon-only Gauge button replaced the "100%" badge,
    hover-card actions replaced the kebab "Slot options" button). Add
    data-testid="speed-control" + data-testid="filament-slot" as stable
    test hooks, rewrite both files around them. Parametrize the four-mode
    speed-selection test. Update the "RUNNING hides menu" assertion to
    "Load/Unload buttons exist but are disabled" — the new UX shows
    actions on hover and disables them rather than hiding the trigger.
  - Drop `animate-in fade-in-0 zoom-in-95 duration-150` from
    FilamentHoverCard and EmptySlotHoverCard. The card briefly painted
    at the offscreen (-9999, -9999) coords during the zoom-in transition,
    reading as a fly-in from the upper-left corner. With the animation
    gone it just appears in place at its computed position.
2026-06-18 12:02:38 +02:00
maziggy a1cd86880c test(printers): repair speed + AMS load/unload tests after #1661 refactor
PR #1661 swapped the visible speed badge ("100%") for an icon-only Gauge
  button and replaced the kebab "Slot options" button with hover-card
  actions inside FilamentHoverCard. The two existing test files weren't
  updated alongside the refactor and stayed broken on dev.

  - Add data-testid="speed-control" to the Gauge button and rewrite the
    Speed tests around it; assertions on the percentage text are gone
    because that text no longer renders. Parametrize the four-mode API
    call test.
  - Add data-testid="filament-slot" to FilamentHoverCard's trigger
    wrapper and rewrite the AMS load/unload tests around
    fireEvent.mouseEnter → portaled actions. Replace the "hides menu
    while RUNNING" assertion with "Load/Unload buttons exist but are
    disabled" — matches the new UX shape.
2026-06-18 11:54:48 +02:00
Ed 0f99b54d7e feat: Update printer card UI for structure and readability (#1661) 2026-06-18 11:46:07 +02:00
Ed 48d3f68ae6 feat: Centralise sidebar ordering and add page visibility options (#1673) 2026-06-17 12:04:57 +02:00
maziggy 0e8b5e032b Post work PR #1505 2026-06-17 11:38:07 +02:00
Poltavtcev af5d24e289 feat(inventory): structured storage locations catalog (#1505) 2026-06-17 11:33:23 +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 e198338b29 Hosekeeping 2026-06-16 10:49:01 +02:00
maziggy eaf641ce31 . 2026-06-16 10:46:58 +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 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