Browse Source

v0.2.2b1 (#602)

* feat(queue): show spool grams left in filament slot mapping

* Bumped version

* Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo

* Fix SpoolBuddy daemon crash when read_tag module is missing

NFCReader.__init__ imported read_tag and instantiated PN5180() outside
the try/except block, so a missing module crashed the entire daemon.
Moved the import inside the existing try/except so the daemon gracefully
skips NFC polling — matching the scale reader's existing behavior.

* Fix SpoolBuddy daemon failing to import hardware drivers

The daemon imports read_tag and scale_diag as bare modules, but they
live in spoolbuddy/scripts/ which isn't on sys.path when systemd runs
the daemon. Added scripts/ to sys.path at startup, resolved relative
to the module file. Also moved the read_tag import inside NFCReader's
try/except (was crashing the daemon instead of skipping gracefully)
and demoted hardware-not-available messages from ERROR to INFO.

* Increase scale moving average window to reduce weight bouncing

5 samples at 100ms (500ms window) wasn't enough to smooth NAU7802 ADC
noise — the averaged value still varied by >2g between 1s report
intervals, and the stability state kept flipping, triggering a report
every cycle. Increased to 20 samples (2s window) so noise is smoothed
before reaching the reporting layer.

* Remove stability flipping as scale report trigger

When ADC noise kept the spread hovering around the 2g stability
threshold, the stable flag toggled every cycle, forcing a report with
a slightly different weight each time. Now only actual weight changes
of >=2g trigger reports. The stable flag is still included in each
report for consumers that need it.

* Fix formatting of option elements in FilamentMapping

* Minor Spoolbuddy frontend improvements

* Updated Spoolbuddy install script to strip down Raspbian

* Updated Spoolbuddy install script

* Add API key auth support to /auth/me for SpoolBuddy kiosk

When Bambuddy auth is enabled, the SpoolBuddy kiosk gets redirected to
the login page because ProtectedRoute requires a user from GET /auth/me,
which only handled JWT tokens. The kiosk daemon already has an API key
but couldn't use it to satisfy the frontend auth check.

- Backend: /auth/me now accepts API keys (Bearer bb_xxx or X-API-Key)
  and returns a synthetic admin UserResponse with all permissions
- Frontend: AuthContext reads ?token= from URL on first load, stores in
  localStorage, and strips from URL (prevents history/referrer leakage)
- Install script: kiosk URL now includes ?token=${API_KEY}
- Tests: 3 new integration tests (Bearer API key, X-API-Key header,
  invalid key rejection)

* SpoolBuddy touch-friendly UI overhaul for 1024x600 kiosk display

Enlarge all interactive elements across 9 SpoolBuddy components to meet
44px minimum tap targets on the RPi touchscreen. Increase nav icons
(20→24px), labels (10→12px), bar heights, section headers, printer
buttons, spool visualizations, fill bars, and status indicators.
Compact the dashboard stats bar and remove the printers card. Add
fullScreen prop to ConfigureAmsSlotModal with two-column layout
(filament list left, K-profile + color right) to eliminate scrolling.

* Updated CI

* Fix naive-vs-aware datetime crash from 0.2.1 timezone migration

The timezone fix (ed36eaf) replaced datetime.utcnow() with
datetime.now(timezone.utc) across ~80 call sites, but SQLAlchemy's
SQLite DateTime columns strip tzinfo on read, returning naive datetimes.
Any Python-side comparison (subtraction, <, >) between an aware "now"
and a naive DB value raises TypeError.

Add `if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc)`
guards at all 8 affected comparison sites:
- maintenance.py: last_performed_at subtraction (500 on /maintenance/overview)
- auth.py: API key expires_at check (2 locations)
- print_scheduler.py: scheduled_time comparison
- smart_plug_manager.py: auto_off_pending_since elapsed calc
- smart_plugs.py: power_alert_last_triggered cooldown
- main.py: last_runtime_update elapsed calc
- archives.py: timelapse completed_at fallback

* Fix naive-vs-aware datetime crash from 0.2.1 timezone migration

The timezone fix (ed36eaf) replaced datetime.utcnow() with
datetime.now(timezone.utc) across ~80 call sites, but SQLAlchemy's
SQLite DateTime columns strip tzinfo on read, returning naive datetimes.
Any Python-side comparison (subtraction, <, >) between an aware "now"
and a naive DB value raises TypeError.

Add `if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc)`
guards at all 9 affected comparison sites:
- maintenance.py: last_performed_at subtraction (2 code paths — days-based
  and hours-based intervals both crashed on /maintenance/overview)
- auth.py: API key expires_at check (2 locations)
- print_scheduler.py: scheduled_time comparison
- smart_plug_manager.py: auto_off_pending_since elapsed calc
- smart_plugs.py: power_alert_last_triggered cooldown
- main.py: last_runtime_update elapsed calc
- archives.py: timelapse completed_at fallback

* Fix queue badge showing on printers without matching filament (#486)

The purple queue counter badge in the printer card header used the raw
unfiltered queue item count, so it appeared on ALL printers of the same
model when a job was scheduled for "any [model]" — even printers without
the matching filament color. The PrinterQueueWidget below it (which
shows "Clear Plate & Start") already filtered by filament type + color.

Apply the same filament compatibility filter (type check + color override
check) to the badge count so it only shows on printers that can actually
run the queued jobs.

* Fix Windows install syntax error from LF line endings (#544)

The start_bambuddy.bat launcher had Unix (LF) line endings. When a
user's git is configured with core.autocrlf=false or input, the file
is checked out with LF endings and cmd.exe fails with "the syntax of
the command is incorrect" before reaching the hash check.

Add .gitattributes forcing CRLF for *.bat files so they always get
correct line endings on checkout regardless of git config.

* Add SpoolBuddy tag detection modal and fix NFC reader silent failure

  Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
  TagDetectedModal that auto-opens on NFC tag detection. Known spools show
  remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
  printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
  "Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
  won't re-open for dismissed tags, reopens on re-place after removal.

  Daemon: Fix PN5180 NFC reader silently stopping tag detection when the
  reader drifts into a stuck state. Add auto-recovery (full hardware reset
  after 10 consecutive errors), preventive RF cycling every 60s when idle,
  and periodic status logging. Heartbeat now reports actual nfc_ok/scale_ok
  from reader instances instead of hardcoded True.

* Add SpoolBuddy tag detection modal and fix NFC reader silent failure

  Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
  TagDetectedModal that auto-opens on NFC tag detection. Known spools show
  remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
  printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
  "Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
  won't re-open for dismissed tags, reopens on re-place after removal.

  Daemon: Fix PN5180 NFC reader silently stopping tag detection. The reader
  drifts into a stuck state where activate_type_a() returns None without
  raising exceptions, making error-based recovery useless. Changed the
  preventive idle maintenance from a lightweight RF off/on cycle to a full
  hardware reset (RST pin toggle + RF re-init) every 60s — the same reset
  that a service restart performs. Also added error-based auto-recovery
  after 10 consecutive exceptions, promoted poll errors to WARNING, added
  periodic status logging, and fixed heartbeat to report actual nfc_ok/
  scale_ok instead of hardcoded True.

* Add SpoolBuddy tag detection modal and fix NFC reader polling

  Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
  TagDetectedModal that auto-opens on NFC tag detection. Known spools show
  remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
  printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
  "Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
  won't re-open for dismissed tags, reopens on re-place after removal.

  Daemon: Fix PN5180 NFC reader failing to maintain tag detection. After a
  successful SELECT the card stays in ACTIVE state and ignores subsequent
  WUPA/REQA, causing immediate false "tag removed" events. Added a brief
  RF off/on cycle (13ms) before each poll to force cards back to IDLE
  state. Also added a preventive full hardware reset every 60s when idle
  to recover from deeper stuck states where activate_type_a() silently
  returns None without exceptions. Heartbeat now reports actual nfc_ok/
  scale_ok instead of hardcoded True.

* Add SpoolBuddy tag detection modal and fix NFC reader polling

  Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
  TagDetectedModal that auto-opens on NFC tag detection. Known spools show
  remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
  printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
  "Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
  won't re-open for dismissed tags, reopens on re-place after removal.

  Daemon: Fix PN5180 NFC reader failing to maintain tag detection. After a
  successful SELECT the card stays in ACTIVE state and ignores subsequent
  WUPA/REQA. Added a conditional RF off/on cycle (13ms) before each poll,
  but only when a tag is present — resets card from ACTIVE to IDLE for
  re-selection. The cycle is skipped when idle to avoid degrading reader
  state with continuous RF toggling, which prevented new tags from being
  detected after removal. Also added a preventive full hardware reset every
  60s when idle to recover from deeper stuck states, error-based recovery
  after 10 consecutive exceptions, and accurate heartbeat reporting of
  NFC/scale health.

* Add SpoolBuddy tag detection modal and fix NFC reader polling

  Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
  TagDetectedModal that auto-opens on NFC tag detection. Known spools show
  remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
  printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
  "Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
  won't re-open for dismissed tags, reopens on re-place after removal.

  Daemon: Fix PN5180 NFC reader failing to detect tags. Each
  activate_type_a() call returning None corrupts the PN5180 transceive
  state, silently preventing all subsequent tag detection. Fixed by
  performing a full hardware reset (RST pin toggle + RF re-init) before
  every idle poll (~240ms, ~1.8 Hz rate). When a tag is present, a light
  RF off/on cycle (13ms) resets the card from ACTIVE to IDLE state for
  continuous re-selection. Also added error-based recovery, periodic
  status logging, and accurate heartbeat NFC/scale health reporting.

* Fix SpoolBuddy scale tare & calibration not being applied

  The tare and calibrate buttons on the Settings page queued commands
  but never executed them due to three broken links:

  1. Daemon received tare command via heartbeat but never called
     scale.tare() — the ScaleReader was available in shared dict
     but unused
  2. No API endpoint for the daemon to report the new tare offset
     back to the backend DB, so tare results were lost
  3. Heartbeat updated config but never called
     scale.update_calibration(), so ScaleReader kept initial values

  Added set-tare endpoint + API client method, and fixed heartbeat
  loop to execute tare, persist the result, and propagate calibration
  changes to the ScaleReader instance.

* Fix SpoolBuddy scale tare & calibration not being applied

  The tare and calibrate buttons on the Settings page queued commands
  but never executed them due to three broken links:

  1. Daemon received tare command via heartbeat but never called
     scale.tare() — the ScaleReader was available in shared dict
     but unused
  2. No API endpoint for the daemon to report the new tare offset
     back to the backend DB, so tare results were lost
  3. Heartbeat updated config but never called
     scale.update_calibration(), so ScaleReader kept initial values

  Added set-tare endpoint + API client method, and fixed heartbeat
  loop to execute tare, persist the result, and propagate calibration
  changes to the ScaleReader instance. Skip calibration sync on the
  heartbeat cycle that delivers a tare command, since the response
  predates the tare and would overwrite it with stale values.

* Fix SpoolBuddy scale tare & calibration not being applied

  The tare and calibrate buttons on the Settings page queued commands
  but never executed them due to four broken links:

  1. Daemon received tare command via heartbeat but never called
     scale.tare() — the ScaleReader was available in shared dict
     but unused
  2. No API endpoint for the daemon to report the new tare offset
     back to the backend DB, so tare results were lost
  3. Heartbeat updated config but never called
     scale.update_calibration(), so ScaleReader kept initial values
  4. The heartbeat response delivering the tare command still had
     pre-tare values, immediately overwriting the new offset to zero

  Added set-tare endpoint + API client method, and fixed heartbeat
  loop to execute tare, persist the result, propagate calibration
  changes to the ScaleReader, and skip calibration sync on the
  heartbeat cycle that delivers a tare command.

  Also replaced the calibration weight input with a touch-friendly
  numpad since the RPi kiosk has no physical keyboard.

* Fix SpoolBuddy scale tare & calibration not being applied

  The tare and calibrate buttons on the Settings page queued commands
  but never executed them due to five broken links:

  1. Daemon received tare command but never called scale.tare()
  2. No endpoint to persist tare offset back to backend DB
  3. Heartbeat never called scale.update_calibration()
  4. Heartbeat response with stale values overwrote new tare to zero
  5. set-factor used DB tare_offset (stale/zero), producing wrong
     calibration factor — empty scale showed ~5000g

  Fixed daemon to execute tare, persist result, and propagate
  calibration. Calibration step now captures raw ADC at tare time
  and sends it with step 2, making factor computation self-contained.

  Replaced calibration weight input with compact touch numpad for
  the RPi kiosk's 1024x600 touchscreen (no physical keyboard).

* Fix A1 Mini "unknown" status from non-UTF-8 MQTT payload (#549)

  Some firmware versions send MQTT payloads with non-UTF-8 bytes.
  UnicodeDecodeError was uncaught, silently dropping the entire message
  and leaving printer status as "unknown" with 0°C temps and no AMS.

  Fall back to decode(errors="replace") to keep JSON parseable.

* Add H2C dual nozzle variant O1C2 model support (#489)

  The H2C dual nozzle variant reports model code O1C2 via MQTT, but only
  O1C was recognized. This caused the camera to use the wrong protocol
  (chamber image on port 6000 instead of RTSP on port 322), producing a
  reconnect loop. Added O1C2 to all model ID maps across 8 files.

* Updated commit message (now includes the test fix):

  Fix camera button permissions & ffmpeg process leak (#550)

  Camera button on printer card was clickable without camera:view
  permission. ffmpeg processes (~240MB each) accumulated after closing
  camera streams because: (1) stop endpoint called terminate() without
  wait()/kill(), (2) HTTP disconnect detection only ran between frames
  so was blocked when the generator was stuck on stdout read, and
  (3) no mechanism caught processes orphaned by generator abandonment
  or app restarts.

  - Add camera:view permission check + tooltip to camera button
  - Fix stop endpoint: terminate() → wait(2s) → kill() → wait()
  - Add background disconnect monitor (polls every 2s, kills ffmpeg
    directly on disconnect)
  - Add periodic /proc scan (every 60s) that SIGKILLs any ffmpeg
    with rtsps://bblp: not in an active stream
  - Add noCamera i18n key to all 6 locales
  - Fix camera API test mocks for async wait() and pid attribute

* Fix sidebar navigation not respecting user permissions

  Sidebar nav items (Archives, Queue, Stats, Profiles, Maintenance,
  Projects, Inventory, Files) were visible to all users regardless of
  role permissions — only Settings was gated. Now each item is hidden
  when the user lacks the corresponding read permission. Printers
  remains always visible as the home page.

  Also adds missing inventory:read|create|update|delete to the frontend
  Permission type (existed in backend but was absent from the frontend
  type definition).

* Uodated issue template

* Updated README

* Fix Windows install syntax error from multi-line for /f command (#544)

  The Python hash verification in start_bambuddy.bat used a multi-line
  `for /f "usebackq"` with a backtick-delimited command split across
  lines. Windows CMD cannot parse line breaks inside backtick-delimited
  for /f commands, causing "The syntax of the command is incorrect" at
  step 1/6. Removed the entire redundant verification block — the
  verify_sha256 subroutine already checks the archive against the
  pinned hash. The removed block also had a secondary bug: it always
  downloaded the amd64 checksum from python.org even on arm64 systems.

* Separate firmware and software sections in Updates settings card

  The Updates card on the Settings page mixed printer firmware and
  Bambuddy software update toggles with no visual distinction. Now
  groups them into labeled sections ("Printer Firmware" at top,
  "Bambuddy Software" below) with a divider between them.

* Fix support package: mask subnet IPs, detect host mode, parse top-level fun, add virtual printers

  Four support package improvements:

  1. Mask first two octets of subnet IPs in support info
     (192.168.1.0/24 → x.x.1.0/24) to avoid leaking private network
     addresses.

  2. Fix Docker network_mode_hint detection. The old heuristic
     (interface count > 2) always reported "bridge" on single-NIC
     hosts because get_network_interfaces() excludes Docker
     interfaces. Now checks for docker0/br-*/veth* visibility via
     socket.if_nameindex() — these are only visible in host mode.

  3. Parse MQTT "fun" field at top level of payload (not just inside
     "print" key). Some firmware versions send it there, which
     explains why developer_mode was null for most users.

  4. Add virtual_printers section to support info with mode, model,
     enabled/running status, and pending file count.

* Fix virtual printer config changes ignored on running instances

  sync_from_db() skipped VPs already in self._instances without checking
  if their config had changed. Mode, model, access code, bind IP, remote
  interface IP, and target printer changes were silently ignored until
  manual toggle off/on or full restart. Now detects config drift and
  restarts affected instances.

* Fix queue 500 error when cancelled print exists (#558)

  The MQTT completion handler stored "aborted" as the queue item status
  when a print was cancelled mid-print, but the response schema only
  allows "cancelled". Pydantic validation failed on the invalid status
  when listing all queue items, returning 500. Filtering by specific
  status excluded the bad row so those still worked.

  Normalise "aborted" → "cancelled" before storing. A startup fixup
  also converts any existing "aborted" rows in the database.

* Redesign SpoolBuddy dashboard: inline spool cards, full-screen AMS assign modal, filament ID normalization

  - Replace TagDetectedModal with inline SpoolInfoCard/UnknownTagCard
    in dashboard right panel (known spools show assign/sync/close,
    unknown tags show add-to-inventory/link/close)
  - Rewrite AssignToAmsModal as full-screen overlay reusing AmsUnitCard,
    with AMS-HT and external slot support, single assignSpool API call
  - Remove printer selector from assign modal (uses top bar selection)
  - Extract filament_id <-> setting_id conversion to shared utility
    (backend/app/utils/filament_ids.py), used by inventory + cloud routes
  - Normalize slicer_filament in assign_spool to derive proper
    tray_info_idx and setting_id for MQTT (was sending setting_id="")
  - Rename SpoolBuddy top bar status label "Online" -> "Backend"
  - Remove weightStable guard from sync weight button

* Added delete tag button to inventory's edit spool modal

* Fix VP bind server rejecting TLS connections on port 3002 (#559)

  BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1
  Mini / N1). The bind server only spoke plain TCP on both ports, so the
  TLS ClientHello was rejected as "invalid frame" and the slicer could
  never discover or connect to the virtual printer.

  Port 3002 now uses TLS (reusing the VP's existing certificate), port
  3000 remains plain TCP. Also updated proxy-mode to use TLSProxy for
  the port 3002 bind proxy instead of raw TCPProxy.

* Fix SpoolBuddy scale calibration lost after reboot

  _get_mac_id() used Path.iterdir() to find a network interface for the
  device ID, but iteration order is non-deterministic. On different boots
  it could pick eth0 (MAC …3100) or wlan0 (MAC …3102), producing a
  different device_id each time. Since calibration is stored per device ID
  in the backend DB, a new ID meant registering as an uncalibrated device.

  Sort interfaces alphabetically so the same one is always selected.

* fix(stats): calculate success rate from completed and failed prints only

Previously divided successful prints by total_prints which includes
cancelled/stopped prints, causing an incorrect percentage.

* feat(stats): add new widgets, shared MetricToggle, and timeframe filtering

Add Print Time of Day, Color Distribution, and Records widgets to the
Stats page. Extract shared MetricToggle component for consistent
weight/prints/time toggles. Add date range filtering to archives and
stats API endpoints. Reorganize widgets into parent cards (Printer Stats
and Filament Trends) and fix Dashboard layout persistence for new widgets.

* test(stats): update StatsPage tests to match current widget structure

Enrich mock archives with full fields, fix failure analysis endpoint,
remove outdated Filament Types test, and add coverage for new widgets
(Records, Printer Stats sub-cards, Filament Trends sub-cards).

* refactor(stats): improve Color Distribution card layout and toggle

Use MetricToggle component with new exclude prop instead of inline
buttons, default to weight-first order, and redesign chart layout
with centered donut, total label, and 2-column legend grid.

* feat(stats): add /archives/slim endpoint and fix dashboard bugs

- Add lightweight GET /archives/slim endpoint with column-level SELECT
  (13 fields vs 46), skipping duplicate detection for ~70-80% payload
  reduction on the stats dashboard
- Add ArchiveSlim Pydantic schema and TypeScript type
- Switch StatsPage and FilamentTrends to use ArchiveSlim
- Fix critical bug in failure_analysis.py: use effective_days for week
  count, separate non-date filters, build fresh week_filter per loop
- Fix busiestDay timezone bug: parse YYYY-MM-DD with split() + local
  Date constructor instead of new Date() which creates UTC midnight
- Fix success streak ordering: sort by completed_at || created_at
  instead of created_at alone
- Add 6 integration tests for /archives/slim endpoint

* feat(stats): add hourly heatmap for short timeframes and fix timezone bugs

Print Activity now shows an hourly heatmap (hours x days) for timeframes
≤7 days and dynamically adjusts calendar months for longer ranges.
Adds hourly granularity to Filament Trends, persists timeframe selection,
fixes optional chaining in QuickStatsWidget, and fixes UTC/local timezone
mismatches in date key generation. Also hardens the /archives/slim limit
param and fixes empty query string handling in API client.

* Fix SpoolBuddy "Assign to AMS" slot showing empty fields in slicer

  The assign_spool endpoint sent wrong MQTT field values for user presets,
  causing the slicer's AMS slot detail card to show all fields empty.

  Two bugs: (1) cloud API was called with the raw slicer_filament value
  including its version suffix (e.g. PFUS9ac902733670a9_07), returning 404;
  the silent fallback sent setting_id as tray_info_idx instead of the real
  filament_id; (2) no SlotPresetMapping was saved after assignment.

  Now strips version suffixes before cloud lookup, resolves the real
  filament_id via cloud API (with local preset and generic fallbacks),
  includes brand in tray_sub_brands, and saves slot preset mapping.

* Add SpoolBuddy backend + frontend test coverage

  21 backend integration tests for all 12 SpoolBuddy API endpoints
  (device register/re-register/list, heartbeat status/commands/404/
  offline broadcast, NFC tag match/unmatch/removal, scale reading/
  weight calculation/missing spool, calibration tare/set-tare/
  set-factor/zero-delta/get) and 20 frontend component tests for
  WeightDisplay, SpoolInfoCard, UnknownTagCard, and TagDetectedModal.

* Fix printer card losing print info when paused (#562)

  The printer card only showed progress bar, print name, ETA, layers,
  and cover image when gcode_state was RUNNING. A paused print (state
  PAUSE) fell through to the idle display, hiding all print context.
  Added PAUSE to the display conditions in both compact and expanded
  view modes, and replaced the hardcoded "Printing" status label with
  getStatusDisplay() so it correctly shows "Paused".

* Fix num_weeks calculation to ensure at least one week

* Updated README

* Add daily beta build publish script

  Automates the full beta release workflow: version bump in config.py,
  CHANGELOG date update, git commit + tag, multi-arch Docker build+push
  to GHCR and Docker Hub, and GitHub prerelease creation with changelog
  notes. Supports --parallel, --ghcr-only, --dockerhub-only, and
  --skip-release flags. Documents daily beta builds in README and
  DOCKERHUB.md.

* Updated README

* Add daily beta build publish script

  Builds multi-arch Docker image from the current APP_VERSION in config.py
  and pushes to GHCR + Docker Hub, overwriting the same beta tag. Creates
  or updates a GitHub prerelease with changelog notes. Users re-pull the
  tag or use Watchtower to get daily updates. Supports --parallel,
  --ghcr-only, --dockerhub-only, and --skip-release flags. Documents
  daily beta builds in README and DOCKERHUB.md.

* Fix SpoolBuddy tag_type for linked spools + add inventory weight check column

  SpoolBuddy's "Link to Spool" used the generic updateSpool API which only
  set tag_uid, leaving tag_type and data_origin empty. Now uses linkTagToSpool
  with tag_type='generic' and data_origin='nfc_link'.

  Added a "Weight Check" inventory column (hidden by default) that compares
  each spool's last scale measurement against calculated gross weight with
  ±50g tolerance. Shows green check for match, yellow warning + sync button
  for mismatch. Backend stores last_scale_weight and last_weighed_at on each
  spool when weight is synced via SpoolBuddy. Includes edge case handling
  when scale weight < core weight. i18n keys added for all 6 locales.

* Fix SpoolBuddy AMS page fill levels, ext-R active bug, and layout

  - Show inventory-based fill levels for non-BL spools in AMS slots by
    fetching spool assignments and computing fill from weight_used/label_weight,
    falling back to AMS remain when no assignment exists
  - Fix ext-R slot falsely highlighted as active when idle: tray_now=255
    (no tray loaded) matched ext-R's id=255 without a guard
  - Improve single-slot card layout: use responsive grid aligned with AMS
    card columns, fix vertical stretching with few AMS units
  - Remove redundant L/R nozzle badges from ext slots (label already shows
    Ext-L/Ext-R)
  - Add 16 tests for ext active state logic and fill override fallback

* Updated Spoolbuddy install script

* Add on-screen virtual keyboard for SpoolBuddy kiosk UI

  The Raspberry Pi kiosk has no physical keyboard and system-level virtual
  keyboards (squeekboard, wvkbd) don't auto-show/hide with labwc/Chromium.
  Add a react-simple-keyboard QWERTY keyboard that auto-shows on input
  focus, with dark theme, shift/caps/backspace, email keys (@, .), and a
  two-phase close that prevents ghost-click passthrough to elements below.
  Inputs with data-vkb="false" opt out (e.g. SpoolBuddySettingsPage numpad).

* Spoolbuddy frontend fixes

* Display Controls (brightness + blanking)

  Root cause: The daemon used wlopm for screen blanking, but wlopm was never installed. Additionally, the daemon runs as the spoolbuddy system user which has no access to
   the Wayland socket, so Wayland-based tools can't work.

  Fix in display_control.py:
  - Replaced wlopm --off/--on with vcgencmd display_power 0/1 — this is a Raspberry Pi firmware-level command that's pre-installed and works without Wayland socket access
  - Added shutil.which("vcgencmd") check at init to avoid repeated failures on non-RPi hardware
  - Added explicit PermissionError handling for brightness writes with a helpful message about the video group

  Fix in install.sh:
  - Added video group to the spoolbuddy service user's groups (was: gpio, spi, i2c → now: gpio, spi, i2c, video), which grants access to both vcgencmd and sysfs backlight
   files

  Scale Tab Numpad

  Root cause: On the 1024x600 kiosk screen (~376px available content height), the weight info card + numpad + action buttons exceeded the space, causing tiny buttons and
  overlapping.

  Fix in SpoolBuddySettingsPage.tsx:
  - Hide the weight info card during weight entry step (calStep !== 'weight'), reclaiming ~70px
  - Compact inline weight reading in the step header (small dot + monospace text) so users can still see the live scale value
  - Larger numpad buttons: min-h-[56px] with text-lg font size (was no min-height, text-sm)
  - Added active:scale-95 for tactile touch feedback
  - mt-auto on action buttons to push them to the bottom, preventing overlap
  - Removed the wrapping card around the calibration flow to save vertical padding

* Spoolbuddy

  1. Device tab — repo URL unlinked

  Replaced the <a> tag with a <span> so the GitHub URL is displayed as plain text (no clickable link in kiosk mode).

  2. Display controls — moved to frontend

  Root cause: The daemon runs as a system user without Wayland access, and the kiosk uses an HDMI display (no sysfs backlight). Neither wlopm, vcgencmd, nor sysfs writes
  can work in this setup.

  Fix — frontend-controlled display in SpoolBuddyLayout.tsx:
  - Brightness: CSS filter: brightness(X) applied to the layout root. Immediate visual dimming that works on any display type.
  - Screen blanking: Full-screen black overlay (z-[9999]) shown after the configured inactivity timeout. Touch anywhere to wake.
  - Activity tracking: Resets on pointerdown/keydown events AND on WebSocket-driven NFC/scale changes (weight update, tag scan).
  - Queries device data every 15s to read the current brightness and blank timeout values.

  Daemon display_control.py — Simplified:
  - Removed vcgencmd/wlopm/subprocess usage entirely
  - _blank/_unblank are now state-tracking no-ops (log only)
  - DSI backlight brightness via sysfs is preserved as a bonus for DSI displays
  - Updated docstring to explain the architecture

  3. Scale tab — redesigned with step indicator

  Idle state: Same as before — weight card with tare/calibrate buttons.

  Calibration wizard: Two-column layout:
  - Left column (~64px): Vertical step indicator with numbered circles, checkmark for completed steps, and a connecting line
  - Right column: Live weight bar (with stable/settling indicator), step-specific content (instructions or numpad), and action buttons pinned to bottom via mt-auto

  The numpad buttons are min-h-[52px] with text-lg — large enough for touch on 1024x600. The weight info card is hidden during the weight entry step to maximize vertical
  space.

* Redesign SpoolBuddy settings page, add language/time format support

  - Redesign settings page with tabbed layout (Device, Display, Scale, Updates)
  - Add screen blank timeout: blanks after touch inactivity, tap to wake
  - Add CSS brightness filter for HDMI displays (no sysfs on HDMI)
  - Add backend `language` field to app settings for server-side persistence
  - Sync UI language from backend on kiosk load (separate Chromium instance)
  - Top bar clock respects user's time format setting (system/12h/24h)
  - Add SpoolBuddy settings translations for all 6 languages (en/de/fr/ja/it/pt-BR)
  - Disable Chromium swipe-to-navigate in kiosk install script
  - Add `video` group for DSI backlight access

* Add printer status badges, remove SpoolBuddy inventory page

  - Add printer status badges to dashboard left column (green/gray
    online/offline dots, compact wrapping pills, overflow-hidden)
  - Remove SpoolBuddy inventory page — inventory management belongs
    in the main Bambuddy frontend, not the kiosk
  - Remove inventory nav item from bottom nav (now 3 tabs)
  - Replace "Add to Inventory" navigate with quick-add modal that
    creates a basic PLA spool entry via API, with a hint to use
    the main Bambuddy UI for full spool details instead

* Add SpoolBuddy NFC tag writing with OpenTag3D format

  Write NTAG213/215/216 tags for third-party spools via the SpoolBuddy
  kiosk UI. New "Write" page with three workflows: existing spool, new
  spool creation, and tag replacement. Backend encodes 133-byte OpenTag3D
  NDEF payloads (material, color, brand, weight, temp). Daemon writes
  page-by-page via PN5180 NTAG WRITE command with read-back verification.
  Write commands flow through heartbeat polling with WebSocket status
  updates. Includes 39 new tests and translations for all 6 languages.

* Fix K-profile greenlet error on auto-created RFID spools

  create_spool_from_tray() didn't eagerly load the k_profiles
  relationship, so auto_assign_spool() triggered a lazy load in async
  context when iterating spool.k_profiles. Set k_profiles=[] on new
  spools since they can never have K-profiles yet.

* Fix spurious error notifications from print_error status codes

  Some firmware sends non-zero print_error values (e.g. 0x03000002 →
  0300_0002) during normal printing as status/phase indicators. The
  parser treated any non-zero value as a real error, triggering
  notifications. All known real errors have codes >= 0x4000 (fatal,
  warning, prompt ranges). Now skips print_error values where the
  low 16 bits are below 0x4000.

  Also fix K-profile greenlet error on auto-created RFID spools by
  eagerly setting k_profiles=[] after flush in create_spool_from_tray().

* Updated docker-publish-daily-beta.sh

* Added Simplified Chinese translation zh-CN (Issue #571)

* Add customizable low stock threshold, add low stock filter (#531)

* feat(queue): show spool grams left in filament slot mapping

* Bumped version

* Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo

* Fix SpoolBuddy daemon crash when read_tag module is missing

NFCReader.__init__ imported read_tag and instantiated PN5180() outside
the try/except block, so a missing module crashed the entire daemon.
Moved the import inside the existing try/except so the daemon gracefully
skips NFC polling — matching the scale reader's existing behavior.

* Fix SpoolBuddy daemon failing to import hardware drivers

The daemon imports read_tag and scale_diag as bare modules, but they
live in spoolbuddy/scripts/ which isn't on sys.path when systemd runs
the daemon. Added scripts/ to sys.path at startup, resolved relative
to the module file. Also moved the read_tag import inside NFCReader's
try/except (was crashing the daemon instead of skipping gracefully)
and demoted hardware-not-available messages from ERROR to INFO.

* Increase scale moving average window to reduce weight bouncing

5 samples at 100ms (500ms window) wasn't enough to smooth NAU7802 ADC
noise — the averaged value still varied by >2g between 1s report
intervals, and the stability state kept flipping, triggering a report
every cycle. Increased to 20 samples (2s window) so noise is smoothed
before reaching the reporting layer.

* Remove stability flipping as scale report trigger

When ADC noise kept the spread hovering around the 2g stability
threshold, the stable flag toggled every cycle, forcing a report with
a slightly different weight each time. Now only actual weight changes
of >=2g trigger reports. The stable flag is still included in each
report for consumers that need it.

* Fix formatting of option elements in FilamentMapping

* Make low stock threshold editable

* Add new filter for low spools

* Update bug report template to require additional fields

* Added toast for invalid imputs with locales, updated inputb field restrictions

* Minor Spoolbuddy frontend improvements

* Updated test_backend.sh

* Updated Spoolbuddy install script to strip down Raspbian

* Updated Spoolbuddy install script

* Add API key auth support to /auth/me for SpoolBuddy kiosk

When Bambuddy auth is enabled, the SpoolBuddy kiosk gets redirected to
the login page because ProtectedRoute requires a user from GET /auth/me,
which only handled JWT tokens. The kiosk daemon already has an API key
but couldn't use it to satisfy the frontend auth check.

- Backend: /auth/me now accepts API keys (Bearer bb_xxx or X-API-Key)
  and returns a synthetic admin UserResponse with all permissions
- Frontend: AuthContext reads ?token= from URL on first load, stores in
  localStorage, and strips from URL (prevents history/referrer leakage)
- Install script: kiosk URL now includes ?token=${API_KEY}
- Tests: 3 new integration tests (Bearer API key, X-API-Key header,
  invalid key rejection)

* SpoolBuddy touch-friendly UI overhaul for 1024x600 kiosk display

Enlarge all interactive elements across 9 SpoolBuddy components to meet
44px minimum tap targets on the RPi touchscreen. Increase nav icons
(20→24px), labels (10→12px), bar heights, section headers, printer
buttons, spool visualizations, fill bars, and status indicators.
Compact the dashboard stats bar and remove the printers card. Add
fullScreen prop to ConfigureAmsSlotModal with two-column layout
(filament list left, K-profile + color right) to eliminate scrolling.

* Minor changes, CSS fixes

* Refactor usageFilter state to remove 'lowstock' option for clarity

* Move var saving to API, add test coverage

* fix: threshold validation and cleanup

* Change test input from '150' to '0'

---------

Co-authored-by: tridev <c.tripod@gmx.ch>
Co-authored-by: MartinNYHC <mz@v8w.de>

* Add i18n keys for printer card drag-drop print overlay

  PR #569 introduces drag-drop printing on printer cards with
  inline fallback strings for common.uploading, common.uploadFailed,
  printers.dropToPrint, and printers.cannotPrint. Add these keys
  to all 7 locale files so they render in the correct language.

* [Feature]: Printer Page - Add a print button & Drop zone on the printer card (#569)

* Add printer card print flow with drag-drop and reusable LibraryUploadModal

Extract inline upload modal from FileManagerPage into a reusable
LibraryUploadModal component. Add a Print button and drag-drop zone
to printer cards that upload files to the library and open PrintModal
with the printer pre-selected. Only .gcode/.gcode.3mf files are
accepted for printing. PrintModal hides printer selector when a
printer is provided via initialSelectedPrinterIds prop.

* Add printer compatibility check for drag-drop and upload-to-print flows

Validates sliced_for_model from file metadata against the target printer model
before opening the print modal, preventing users from printing with incompatible files.

* Improve FileUploadModal: auto-close on success, inline errors, file validation, and i18n

- Close modal automatically after successful upload instead of showing summary
- Show printer compatibility errors inline in the modal instead of closing
- Add validateFile and accept props to restrict file types for print flow
- Add onFileUploaded error return to prevent modal close on incompatible files
- Internationalize all hardcoded error/warning strings across 6 locales

* Clean up incompatible files from library and fix print button i18n

- Delete uploaded file from library when printer compatibility check fails
  in both drag-drop and modal upload flows
- Fix print button tooltip to use existing common.print i18n key
- Fix accept attribute to use .gcode,.3mf for proper browser support

* Add printer information modal accessible from card 3-dot menu

Replace inline IP/serial display at bottom of printer card with a
dedicated "Printer Information" modal opened via the card menu. The
modal shows model, status, state, IP address (copyable), serial number
(copyable), WiFi signal, firmware, developer mode, nozzle count, SD
card, auto-archive, total print hours, location, and added date, along
with the printer image. Includes full i18n support across all 6 locales
and accessibility attributes (role="dialog", aria-modal).

* Extract getPrinterImage and getWifiStrength into shared printer utils

---------

Co-authored-by: MartinNYHC <mz@v8w.de>

* Post work PR #569

* Fix AMS slot modal infinite scroll loop on Windows

Inline callback refs on preset buttons called scrollIntoView on every
re-render, creating a scroll → re-render → scrollIntoView loop on
Windows. Replace with a useEffect + data attribute approach that only
scrolls when the selected preset actually changes.

* Add energy cost to archive card (#573)

* Add energy cost to archive card

* Replace Disc3 icon with Coins in ArchivesPage

---------

Co-authored-by: MartinNYHC <mz@v8w.de>

* Revert "Merge pull request #578 from aneopsy/fix-ams-slot"

This reverts commit d4aa77f50240f461e4f24e7e72a7d03aee05ee55, reversing
changes made to 82178f8abd3ff3ca55f79de4c0549f251e13ab69.

* Reverted commit d4aa77f50240f461e4f24e7e72a7d03aee05ee55

* Remove obsolete slicer_binary_path setting from database

  The slicer_binary_path key was left in the settings table from earlier
  slicer integration research but is no longer referenced anywhere in the
  codebase. Add a startup migration step that deletes orphaned settings
  keys so they don't persist in backups.

* Add Hungarian Forint (HUF) currency for filament cost tracking

  Closes #579 — adds HUF with symbol "Ft" to the supported currencies
  list so Hungarian users can track filament costs in their local currency.

* Fix AMS slot showing wrong material for "Support for" profiles

  Profiles like "PLA Support for PETG PETG Basic @Bambu Lab H2D" have
  filament_type PETG, but both the frontend and backend name parsers
  found "PLA" first (iterating material types in order). The MQTT command
  sent tray_type=PLA and tray_info_idx=GFL99 (PLA generic), so the
  slicer displayed PLA instead of PETG.

  - Frontend parsePresetName(): detect "X Support for Y" pattern, extract
    material after "Support for"
  - Frontend ConfigureAmsSlotModal: prefer corrected parsed material over
    stored localPreset.filament_type for tray_type, tray_info_idx, and
    temperature fallback
  - Backend _parse_material_from_name(): same "Support for" handling for
    future profile imports
  - Backend assign_spool: prioritize spool.material over lp.filament_type
    for generic filament ID lookup

* Improve FTP upload progress and widen print modal

  FTP upload: reduce chunk size from 1MB to 64KB for smooth progress bar
  updates (~1s intervals instead of 20+ second gaps). Skip voidresp() for
  all printer models — H2D delays the 226 response by 30+ seconds after
  data transfer, causing a hang at 100%. Add transfer speed and TLS
  handshake timing to logs for diagnosing slow connections.

  Print/Schedule modal: widen from max-w-lg (512px) to max-w-2xl (672px)
  to accommodate long filament profile names like "PLA Support for PETG
  PETG Basic @Bambu Lab H2D 0.4 nozzle".

* Fix archive card showing "Source" badge for sliced .3mf files

  The isSlicedFile() check only matched .gcode or .gcode.3mf extensions,
  so archives from .3mf prints (the standard Bambu slicer output) showed
  a "SOURCE" badge instead of "GCODE". Since .3mf can be either sliced or
  a raw CAD export, now checks the archive's total_layers and
  print_time_seconds metadata to distinguish them. Also passes the
  original filename when creating archives from file manager prints.

* Refactor date and currency utility functions

- Extract shared detectSystemDateFormat() helper to eliminate
  duplicated system locale detection in date.ts
- Derive SUPPORTED_CURRENCIES from CURRENCY_SYMBOLS map instead
  of maintaining a duplicate hardcoded list
- Consolidate redundant time-parsing regex paths into one
- Use applyTimeFormat() consistently in formatETA()
- Add exhaustive switch default for type safety

* Consolidate duplicate utility functions into shared modules

Extract repeated helper functions from components into shared utils
(colors.ts, date.ts, file.ts) to reduce duplication:
- isLightColor, parseFilamentColor, hexToColorName → utils/colors.ts
- formatMediaTime, formatDurationFromHours → utils/date.ts
- formatFileSize guard hardened for NaN/negative → utils/file.ts
- formatBytes (ToastContext), formatTime (Timelapse*) deduplicated

Functions with subtly different behavior are intentionally kept local
(formatFilament, formatStorageSize, formatBytes, formatUptime,
formatDateTime, formatDate, formatTimeAgo) to preserve exact UI output.

* Fix spurious 0300_0002 error notification via HMS array path (#583)

  The previous fix only filtered status codes (< 0x4000) from the
  print_error field. Firmware can also send the same false positive
  through the hms array in MQTT, which had no such filter. Apply the
  same < 0x4000 check to the HMS parser so status/phase indicators
  are skipped regardless of which MQTT field carries them.

* Add SSDP model codes to firmware check mapping (#584)

  The firmware check only mapped display names (e.g., "H2D Pro") to
  API keys but not raw SSDP device codes (e.g., "O1E", "O2D"). If a
  printer's model was stored as the raw SSDP code, the firmware check
  either failed or matched the wrong model — H2D Pro matched against
  H2D firmware, showing a false update-available badge.

  Add all known SSDP device codes to MODEL_TO_API_KEY so firmware
  versions resolve correctly regardless of how the model was stored.

* Show ethernet indicator instead of WiFi signal for wired printers (#585)

  Parse home_flag bit 18 (0x00040000) from MQTT to detect ethernet
  connections. When set, the printer card shows a green "Ethernet"
  badge with a cable icon instead of WiFi signal strength in dBm.
  The printer info modal also displays "Ethernet" instead of WiFi
  signal details.

* Post work PR #581

* Add in-app bug reporting with relay, debug log collection, and privacy controls

  Floating bug report button submits issues via bambuddy.cool relay (no GitHub
  token needed locally). Collects 30s debug logs with printer push_all, sanitizes
  all sensitive data, uploads logs as files to GitHub. Screenshot upload/paste/drag
  with JPEG compression. Translated into all 7 languages. Includes 21 tests.

* [Fix]: AMS slot modal infinite scroll loop on Windows (#580)

* Fix AMS slot modal not scrolling to preselected filament

The scroll useEffect fired before the loading spinner was replaced
by the preset list, so the DOM element didn't exist yet. The ref
guard was then set, preventing any retry once buttons appeared.

- Move isLoading computation before the scroll effect and add it
  as a dependency so the effect re-runs when loading completes
- Gate scroll on !isLoading so it only runs when buttons exist
- Only mark scrolledToRef after the element is actually found
- Use requestAnimationFrame to scroll after browser layout
- Use block:'center' so the selected item is clearly visible

Preselect filament for printer-configured AMS slots

When an AMS slot was configured directly on the printer (no
savedPresetId), the modal didn't preselect the filament even though
it was in the list. Add a third fallback that matches trayInfoIdx
against the full filteredPresets list (cloud and builtin sources).

* Restore scrolledToRef lost during merge

The useRef import, scrolledToRef declaration, and its reset were
dropped during the 0.2.2b1 merge, causing a ReferenceError in the
scroll-to-preselected-filament effect.

* Fix preset scroll loop and wire up useEffect scroll logic

- Remove inline ref callbacks with scrollIntoView on both preset
  button lists (fullscreen + standard) to stop the infinite scroll
  loop on Windows
- Add data-preset-id attribute so the existing useEffect/querySelector
  scroll logic can actually find the elements
- Replace filteredPresets dependency with builtinFilaments in the
  selection useEffect to prevent search keystrokes from overriding
  manual preset selection

* Adjust scroll behavior in ConfigureAmsSlotModal

---------

Co-authored-by: MartinNYHC <mz@v8w.de>

* Housekeeping

---------

Co-authored-by: tridev <c.tripod@gmx.ch>
Co-authored-by: AneoPsy <paultevatheis@gmail.com>
Co-authored-by: Keybored <78739882+Keybored02@users.noreply.github.com>
MartinNYHC 2 months ago
parent
commit
3dd1ffb485
100 changed files with 9290 additions and 594 deletions
  1. 61 1
      CHANGELOG.md
  2. 1 0
      README.md
  3. 115 13
      backend/app/api/routes/archives.py
  4. 89 7
      backend/app/api/routes/auth.py
  5. 95 0
      backend/app/api/routes/bug_report.py
  6. 3 26
      backend/app/api/routes/cloud.py
  7. 110 30
      backend/app/api/routes/inventory.py
  8. 1 0
      backend/app/api/routes/printers.py
  9. 1 0
      backend/app/api/routes/settings.py
  10. 282 3
      backend/app/api/routes/spoolbuddy.py
  11. 101 4
      backend/app/api/routes/support.py
  12. 2 1
      backend/app/core/config.py
  13. 48 0
      backend/app/core/database.py
  14. 22 0
      backend/app/main.py
  15. 20 0
      backend/app/models/bug_report.py
  16. 2 0
      backend/app/models/spool.py
  17. 8 1
      backend/app/models/spoolbuddy_device.py
  18. 21 0
      backend/app/schemas/archive.py
  19. 1 0
      backend/app/schemas/printer.py
  20. 11 0
      backend/app/schemas/settings.py
  21. 2 0
      backend/app/schemas/spool.py
  22. 40 0
      backend/app/schemas/spoolbuddy.py
  23. 11 1
      backend/app/services/archive.py
  24. 1 0
      backend/app/services/background_dispatch.py
  25. 26 16
      backend/app/services/bambu_ftp.py
  26. 47 23
      backend/app/services/bambu_mqtt.py
  27. 142 0
      backend/app/services/bug_report.py
  28. 34 14
      backend/app/services/failure_analysis.py
  29. 16 0
      backend/app/services/firmware_check.py
  30. 103 0
      backend/app/services/opentag3d.py
  31. 14 1
      backend/app/services/orca_profiles.py
  32. 1 0
      backend/app/services/printer_manager.py
  33. 4 0
      backend/app/services/spool_tag_matcher.py
  34. 42 8
      backend/app/services/virtual_printer/bind_server.py
  35. 26 0
      backend/app/services/virtual_printer/manager.py
  36. 24 12
      backend/app/services/virtual_printer/tcp_proxy.py
  37. 78 0
      backend/app/utils/filament_ids.py
  38. 6 2
      backend/tests/conftest.py
  39. 150 0
      backend/tests/integration/test_archives_api.py
  40. 62 0
      backend/tests/integration/test_auth_api.py
  41. 22 1
      backend/tests/integration/test_print_lifecycle.py
  42. 247 0
      backend/tests/integration/test_print_queue_api.py
  43. 21 0
      backend/tests/integration/test_settings_api.py
  44. 743 0
      backend/tests/integration/test_spoolbuddy.py
  45. 29 48
      backend/tests/unit/services/test_bambu_ftp.py
  46. 188 0
      backend/tests/unit/services/test_virtual_printer.py
  47. 336 0
      backend/tests/unit/test_bug_report.py
  48. 142 0
      backend/tests/unit/test_opentag3d.py
  49. 8 0
      backend/tests/unit/test_orca_profiles.py
  50. 4 2
      backend/tests/unit/test_support_helpers.py
  51. 392 0
      docker-publish-daily-beta.sh
  52. 10 0
      frontend/package-lock.json
  53. 1 0
      frontend/package.json
  54. BIN
      frontend/public/img/spoolbuddy_logo_dark.png
  55. BIN
      frontend/public/img/spoolbuddy_logo_dark_small.png
  56. BIN
      frontend/public/spoolbuddy_logo_dark.png
  57. 4 3
      frontend/src/App.tsx
  58. 177 0
      frontend/src/__tests__/components/BugReportBubble.test.tsx
  59. 654 0
      frontend/src/__tests__/components/FileUploadModal.test.tsx
  60. 116 0
      frontend/src/__tests__/components/SpoolInfoCard.test.tsx
  61. 110 0
      frontend/src/__tests__/components/TagDetectedModal.test.tsx
  62. 80 0
      frontend/src/__tests__/components/WeightDisplay.test.tsx
  63. 112 12
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  64. 396 0
      frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx
  65. 6 2
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  66. 114 0
      frontend/src/__tests__/pages/SpoolBuddyAmsPageLogic.test.ts
  67. 137 0
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  68. 204 14
      frontend/src/__tests__/pages/StatsPage.test.tsx
  69. 2 2
      frontend/src/__tests__/utils/currency.test.ts
  70. 102 6
      frontend/src/api/client.ts
  71. 362 0
      frontend/src/components/BugReportBubble.tsx
  72. 350 39
      frontend/src/components/ConfigureAmsSlotModal.tsx
  73. 8 0
      frontend/src/components/Dashboard.tsx
  74. 1 11
      frontend/src/components/FilamentHoverCard.tsx
  75. 290 131
      frontend/src/components/FilamentTrends.tsx
  76. 0 1
      frontend/src/components/FileManagerModal.tsx
  77. 352 0
      frontend/src/components/FileUploadModal.tsx
  78. 6 6
      frontend/src/components/GitHubBackupSettings.tsx
  79. 2 0
      frontend/src/components/Layout.tsx
  80. 39 0
      frontend/src/components/MetricToggle.tsx
  81. 29 5
      frontend/src/components/PrintModal/FilamentMapping.tsx
  82. 33 24
      frontend/src/components/PrintModal/index.tsx
  83. 2 0
      frontend/src/components/PrintModal/types.ts
  84. 256 0
      frontend/src/components/PrinterInfoModal.tsx
  85. 29 3
      frontend/src/components/SpoolFormModal.tsx
  86. 4 5
      frontend/src/components/SpoolUsageHistory.tsx
  87. 5 10
      frontend/src/components/TimelapseEditorModal.tsx
  88. 3 8
      frontend/src/components/TimelapseViewer.tsx
  89. 82 0
      frontend/src/components/VirtualKeyboard.css
  90. 187 0
      frontend/src/components/VirtualKeyboard.tsx
  91. 175 24
      frontend/src/components/spoolbuddy/AmsUnitCard.tsx
  92. 329 0
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  93. 12 11
      frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx
  94. 106 15
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  95. 2 2
      frontend/src/components/spoolbuddy/SpoolBuddyStatusBar.tsx
  96. 38 26
      frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx
  97. 30 11
      frontend/src/components/spoolbuddy/SpoolInfoCard.tsx
  98. 362 0
      frontend/src/components/spoolbuddy/TagDetectedModal.tsx
  99. 14 0
      frontend/src/contexts/AuthContext.tsx
  100. 2 9
      frontend/src/contexts/ToastContext.tsx

+ 61 - 1
CHANGELOG.md

@@ -2,6 +2,67 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.2.2b1] - 2026-03-03
+
+### Improved
+- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
+- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).
+- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.
+- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
+
+- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.
+
+### New Features
+- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
+- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
+- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
+- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
+- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
+- **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
+- **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
+- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`.
+- **Inventory Scale Weight Check Column** — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.
+
+### Fixed
+- **Archive Card Shows "Source" Badge for Sliced .3mf Files** — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).
+- **AMS Slot Shows Wrong Material for "Support for" Profiles** — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).
+- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
+- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.
+- **K-Profile Apply Fails With Greenlet Error on Auto-Created Spools** — When a Bambu Lab spool was detected via RFID for the first time (auto-creating a new inventory entry), the K-profile application step logged `WARNING greenlet_spawn has not been called; can't call await_only() here`. The `create_spool_from_tray()` function flushed the new spool to the database but didn't eagerly load the `k_profiles` relationship. When `auto_assign_spool()` then iterated `spool.k_profiles` to find a matching K-profile, SQLAlchemy attempted a lazy load — which requires a synchronous DB call that's illegal inside an async context. The K-profile step was silently skipped (caught by `except Exception`), so spool assignment still worked but without K-profile selection. Now eagerly sets `k_profiles = []` on newly created spools since they can never have K-profiles yet.
+- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
+- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.
+- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.
+- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
+- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
+- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
+- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
+- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
+- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.
+- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
+- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
+- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f "usebackq"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.
+- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
+- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
+- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.
+- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
+- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
+- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported "bridge" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.
+- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.
+- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
+
+### Improved
+- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
+- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
+- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
+- **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
+- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
+- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
+- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
+- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).
+- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
+- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.
+- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").
+
 ## [0.2.1.1] - 2026-02-28
 
 
@@ -13,7 +74,6 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
 - **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
 
-
 ## [0.2.1] - 2026-02-27
 
 ### Fixed

+ 1 - 0
README.md

@@ -191,6 +191,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
+- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), automatic diagnostic log collection (30s debug capture with printer push), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time

+ 115 - 13
backend/app/api/routes/archives.py

@@ -2,6 +2,7 @@ import io
 import json
 import logging
 import zipfile
+from datetime import date, datetime, time, timezone
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
@@ -21,7 +22,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
@@ -122,6 +123,8 @@ def archive_to_response(
 async def list_archives(
     printer_id: int | None = None,
     project_id: int | None = None,
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
@@ -132,6 +135,8 @@ async def list_archives(
     archives = await service.list_archives(
         printer_id=printer_id,
         project_id=project_id,
+        date_from=date_from,
+        date_to=date_to,
         limit=limit,
         offset=offset,
     )
@@ -149,6 +154,78 @@ async def list_archives(
     return result
 
 
+@router.get("/slim", response_model=list[ArchiveSlim])
+async def list_archives_slim(
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
+    limit: int = Query(default=10000, le=50000),
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Lightweight archive listing for stats/dashboard widgets.
+
+    Returns only the fields needed for client-side aggregation,
+    skipping duplicate detection, file paths, and extra_data.
+    """
+    filters = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at <= dt_to)
+
+    query = (
+        select(
+            PrintArchive.printer_id,
+            PrintArchive.print_name,
+            PrintArchive.print_time_seconds,
+            PrintArchive.started_at,
+            PrintArchive.completed_at,
+            PrintArchive.filament_used_grams,
+            PrintArchive.filament_type,
+            PrintArchive.filament_color,
+            PrintArchive.status,
+            PrintArchive.cost,
+            PrintArchive.quantity,
+            PrintArchive.created_at,
+        )
+        .where(*filters)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    result = await db.execute(query)
+    rows = result.all()
+
+    return [
+        {
+            "printer_id": r.printer_id,
+            "print_name": r.print_name,
+            "print_time_seconds": r.print_time_seconds,
+            "actual_time_seconds": (
+                int((r.completed_at - r.started_at).total_seconds())
+                if r.started_at
+                and r.completed_at
+                and r.status == "completed"
+                and (r.completed_at - r.started_at).total_seconds() > 0
+                else None
+            ),
+            "filament_used_grams": r.filament_used_grams,
+            "filament_type": r.filament_type,
+            "filament_color": r.filament_color,
+            "status": r.status,
+            "started_at": r.started_at,
+            "completed_at": r.completed_at,
+            "cost": r.cost,
+            "quantity": r.quantity,
+            "created_at": r.created_at,
+        }
+        for r in rows
+    ]
+
+
 @router.get("/search", response_model=list[ArchiveResponse])
 async def search_archives(
     q: str = Query(..., min_length=2, description="Search query"),
@@ -277,7 +354,9 @@ async def rebuild_search_index(
 
 @router.get("/analysis/failures")
 async def analyze_failures(
-    days: int = 30,
+    days: int | None = None,
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
@@ -297,6 +376,8 @@ async def analyze_failures(
     service = FailureAnalysisService(db)
     return await service.analyze_failures(
         days=days,
+        date_from=date_from,
+        date_to=date_to,
         printer_id=printer_id,
         project_id=project_id,
     )
@@ -440,25 +521,42 @@ async def export_stats(
 
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(
+    date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
+    date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Get statistics across all archives."""
+    # Build date filter conditions
+    base_conditions = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        base_conditions.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        base_conditions.append(PrintArchive.created_at <= dt_to)
+
     # Total counts
-    total_result = await db.execute(select(func.count(PrintArchive.id)))
+    total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
     total_prints = total_result.scalar() or 0
 
-    successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
+    successful_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
+    )
     successful_prints = successful_result.scalar() or 0
 
-    failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
+    failed_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
+    )
     failed_prints = failed_result.scalar() or 0
 
     # Totals - use actual print time from timestamps (not slicer estimates)
     # For archives with both started_at and completed_at, calculate actual duration
     # Fall back to print_time_seconds only for archives without timestamps
     archives_for_time = await db.execute(
-        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds)
+        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
+            *base_conditions
+        )
     )
     total_seconds = 0
     for started_at, completed_at, print_time_seconds in archives_for_time.all():
@@ -473,15 +571,17 @@ async def get_archive_stats(
     total_time = total_seconds / 3600  # Convert to hours
 
     # Sum filament directly - filament_used_grams already contains the total for the print job
-    filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    filament_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
+    )
     total_filament = filament_result.scalar() or 0
 
-    cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
+    cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
 
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
-        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None))
+        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
     )
     prints_by_filament: dict[str, int] = {}
     for (filament_types,) in filament_type_result.all():
@@ -493,7 +593,9 @@ async def get_archive_stats(
 
     # By printer
     printer_result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        .where(*base_conditions)
+        .group_by(PrintArchive.printer_id)
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
@@ -501,7 +603,7 @@ async def get_archive_stats(
     # Get all completed archives with both estimated and actual times
     accuracy_result = await db.execute(
         select(PrintArchive)
-        .where(PrintArchive.status == "completed")
+        .where(PrintArchive.status == "completed", *base_conditions)
         .where(PrintArchive.print_time_seconds.isnot(None))
         .where(PrintArchive.started_at.isnot(None))
         .where(PrintArchive.completed_at.isnot(None))
@@ -575,10 +677,10 @@ async def get_archive_stats(
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
     else:
         # Print mode: sum up per-print energy from archives
-        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
+        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
-        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)))
+        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
         total_energy_cost = energy_cost_result.scalar() or 0
 
     return ArchiveStats(

+ 89 - 7
backend/app/api/routes/auth.py

@@ -1,6 +1,8 @@
 from datetime import timedelta
+from typing import Annotated
 
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, Header, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -8,8 +10,11 @@ from sqlalchemy.orm import selectinload
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
+    ALGORITHM,
+    SECRET_KEY,
     Permission,
     RequirePermissionIfAuthEnabled,
+    _validate_api_key,
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
@@ -17,8 +22,10 @@ from backend.app.core.auth import (
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
+    security,
 )
 from backend.app.core.database import get_db
+from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -61,6 +68,21 @@ def _user_to_response(user: User) -> UserResponse:
     )
 
 
+def _api_key_to_user_response(api_key) -> UserResponse:
+    """Create a synthetic admin UserResponse for a valid API key."""
+    return UserResponse(
+        id=0,
+        username=f"api-key:{api_key.key_prefix}",
+        email=None,
+        role="admin",
+        is_active=True,
+        is_admin=True,
+        groups=[],
+        permissions=sorted(ALL_PERMISSIONS),
+        created_at=api_key.created_at.isoformat(),
+    )
+
+
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
@@ -308,14 +330,74 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
 
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
-    current_user: User = Depends(get_current_active_user),
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get current user information."""
-    # Reload user with groups for proper permission calculation
-    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
-    user = result.scalar_one()
-    return _user_to_response(user)
+    """Get current user information.
+
+    Accepts JWT tokens (via Authorization: Bearer header) and API keys
+    (via X-API-Key header or Authorization: Bearer bb_xxx).
+    API keys return a synthetic admin user with all permissions.
+    """
+    import jwt
+    from jwt.exceptions import PyJWTError as JWTError
+
+    # Check for API key via X-API-Key header
+    if x_api_key:
+        api_key = await _validate_api_key(db, x_api_key)
+        if api_key:
+            return _api_key_to_user_response(api_key)
+
+    # Check for Bearer token (could be JWT or API key)
+    if credentials is not None:
+        token = credentials.credentials
+        # Check if it's an API key (starts with bb_)
+        if token.startswith("bb_"):
+            api_key = await _validate_api_key(db, token)
+            if api_key:
+                return _api_key_to_user_response(api_key)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Invalid API key",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+        # Otherwise treat as JWT
+        try:
+            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+            username: str = payload.get("sub")
+            if username is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+        except JWTError:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+        user = await get_user_by_username(db, username)
+        if user is None or not user.is_active:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+        # Reload with groups for proper permission calculation
+        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+        user = result.scalar_one()
+        return _user_to_response(user)
+
+    # No credentials provided
+    raise HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Authentication required",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
 
 
 @router.post("/logout")

+ 95 - 0
backend/app/api/routes/bug_report.py

@@ -0,0 +1,95 @@
+"""Bug report endpoint for submitting user bug reports to GitHub."""
+
+import asyncio
+import logging
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from backend.app.api.routes.support import (
+    _apply_log_level,
+    _collect_support_info,
+    _get_debug_setting,
+    _get_recent_sanitized_logs,
+    _set_debug_setting,
+)
+from backend.app.core.database import async_session
+from backend.app.services.bug_report import submit_report
+from backend.app.services.printer_manager import printer_manager
+
+router = APIRouter(prefix="/bug-report", tags=["bug-report"])
+logger = logging.getLogger(__name__)
+
+LOG_COLLECTION_SECONDS = 30
+
+
+class BugReportRequest(BaseModel):
+    description: str
+    email: str | None = None
+    screenshot_base64: str | None = None
+    include_support_info: bool = True
+
+
+class BugReportResponse(BaseModel):
+    success: bool
+    message: str
+    issue_url: str | None = None
+    issue_number: int | None = None
+
+
+async def _collect_debug_logs() -> str:
+    """Enable debug logging, push all printers, wait, then collect logs."""
+    # Check if debug was already enabled
+    async with async_session() as db:
+        was_debug, _ = await _get_debug_setting(db)
+
+    # Enable debug logging
+    if not was_debug:
+        async with async_session() as db:
+            await _set_debug_setting(db, True)
+        _apply_log_level(True)
+        logger.info("Bug report: temporarily enabled debug logging")
+
+    # Send push_all to all connected printers
+    for printer_id in list(printer_manager._clients.keys()):
+        try:
+            printer_manager.request_status_update(printer_id)
+        except Exception:
+            logger.debug("Failed to push_all for printer %s", printer_id)
+
+    # Wait for logs to accumulate
+    await asyncio.sleep(LOG_COLLECTION_SECONDS)
+
+    # Collect logs
+    logs = await _get_recent_sanitized_logs()
+
+    # Restore previous log level if it wasn't debug before
+    if not was_debug:
+        async with async_session() as db:
+            await _set_debug_setting(db, False)
+        _apply_log_level(False)
+        logger.info("Bug report: restored normal logging")
+
+    return logs
+
+
+@router.post("/submit", response_model=BugReportResponse)
+async def submit_bug_report(report: BugReportRequest):
+    """Submit a bug report. No auth required — anyone should be able to report bugs."""
+    support_info = None
+    if report.include_support_info:
+        try:
+            support_info = await _collect_support_info()
+            logs = await _collect_debug_logs()
+            if logs:
+                support_info["recent_logs"] = logs
+        except Exception:
+            logger.exception("Failed to collect support info for bug report")
+
+    result = await submit_report(
+        description=report.description,
+        reporter_email=report.email,
+        screenshot_base64=report.screenshot_base64,
+        support_info=support_info,
+    )
+    return BugReportResponse(**result)

+ 3 - 26
backend/app/api/routes/cloud.py

@@ -38,6 +38,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     get_cloud_service,
 )
+from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 logger = logging.getLogger(__name__)
 
@@ -503,32 +504,8 @@ async def _enrich_from_local_presets(
     return result
 
 
-def _filament_id_to_setting_id(filament_id: str) -> str:
-    """
-    Convert filament_id to setting_id format for Bambu Cloud API.
-
-    Printers report filament_id (e.g., GFA00, GFG02) but the API expects
-    setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
-
-    User presets (starting with "P") and already-correct IDs are returned unchanged.
-    """
-    if not filament_id:
-        return filament_id
-
-    # User presets start with "P" - leave unchanged
-    if filament_id.startswith("P"):
-        return filament_id
-
-    # Official Bambu presets: GFx## -> GFSx##
-    # Check if it matches the filament_id pattern (GF followed by letter and digits)
-    if filament_id.startswith("GF") and len(filament_id) >= 4:
-        # Check if it's already a setting_id (has S after GF)
-        if filament_id[2] == "S":
-            return filament_id
-        # Insert "S" after "GF": GFA00 -> GFSA00
-        return f"GFS{filament_id[2:]}"
-
-    return filament_id
+# _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
+_filament_id_to_setting_id = filament_id_to_setting_id
 
 
 @router.post("/filament-info")

+ 110 - 30
backend/app/api/routes/inventory.py

@@ -30,6 +30,7 @@ from backend.app.schemas.spool import (
     SpoolUpdate,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.utils.filament_ids import normalize_slicer_filament
 
 logger = logging.getLogger(__name__)
 
@@ -731,18 +732,15 @@ async def assign_spool(
         if client:
             # Build filament setting from spool data
             tray_type = spool.material
-            tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
+            tray_sub_brands = (
+                f"{spool.brand} {spool.material} {spool.subtype}".strip()
+                if spool.brand
+                else f"{spool.material} {spool.subtype}"
+                if spool.subtype
+                else spool.material
+            )
             tray_color = spool.rgba or "FFFFFFFF"
-            tray_info_idx = spool.slicer_filament or ""
-            setting_id = ""
 
-            # Resolve tray_info_idx for the MQTT command.
-            # Priority:
-            #   1. Use the spool's own slicer_filament if set (including
-            #      cloud-synced custom presets like PFUS* / P*).
-            #   2. Reuse the slot's existing tray_info_idx if it's a specific
-            #      (non-generic) preset for the same material.
-            #   3. Fall back to a generic Bambu filament ID.
             _GENERIC_IDS = {
                 "PLA": "GFL99",
                 "PETG": "GFG99",
@@ -761,26 +759,108 @@ async def assign_spool(
             }
             _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
 
-            if tray_info_idx:
-                logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
-            elif (
-                current_tray_info_idx
-                and current_tray_info_idx not in _GENERIC_ID_VALUES
-                and fingerprint_type
-                and fingerprint_type.upper() == tray_type.upper()
-            ):
-                logger.info(
-                    "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
-                    current_tray_info_idx,
-                    tray_type,
-                )
-                tray_info_idx = current_tray_info_idx
-            elif tray_type:
-                material = tray_type.upper().strip()
-                generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
-                if generic:
-                    logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
-                    tray_info_idx = generic
+            # Resolve tray_info_idx + setting_id for the MQTT command.
+            # Three sources in priority order:
+            #   1. Cloud profile (if cloud connected) — resolve filament_id
+            #      from setting_id via cloud API
+            #   2. Local profile — use generic filament ID for material
+            #   3. Hard-coded fallback — generic Bambu filament IDs
+            tray_info_idx = ""
+            setting_id = ""
+            sf = spool.slicer_filament or ""
+
+            if sf:
+                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
+                base_sf = sf.split("_")[0] if "_" in sf else sf
+                if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+                    # Cloud setting_id — need to resolve real filament_id
+                    # Use base_sf (version suffix stripped) for cloud API + MQTT
+                    setting_id = base_sf
+                    try:
+                        from backend.app.services.bambu_cloud import get_cloud_service
+
+                        cloud = get_cloud_service()
+                        if cloud.is_authenticated:
+                            detail = await cloud.get_setting_detail(base_sf)
+                            if detail.get("filament_id"):
+                                tray_info_idx = detail["filament_id"]
+                                logger.info(
+                                    "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
+                                    tray_info_idx,
+                                    sf,
+                                )
+                                # Use cloud preset name for tray_sub_brands if available
+                                cloud_name = detail.get("name", "")
+                                if cloud_name:
+                                    tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                            elif detail.get("base_id"):
+                                # Derive from base_id (e.g. "GFSL05" → "GFL05")
+                                bid = detail["base_id"].split("_")[0]
+                                if bid.startswith("GFS") and len(bid) >= 5:
+                                    tray_info_idx = f"GF{bid[3:]}"
+                                else:
+                                    tray_info_idx = bid
+                                logger.info(
+                                    "Spool assign: derived filament_id=%r from base_id=%r",
+                                    tray_info_idx,
+                                    detail["base_id"],
+                                )
+                    except Exception as e:
+                        logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
+
+                    if not tray_info_idx:
+                        # Cloud lookup failed — use normalize as fallback
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                elif base_sf.startswith("GF"):
+                    # Official Bambu filament_id (e.g. "GFL05")
+                    tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                    logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
+                else:
+                    # Could be a local preset ID or material type — try local DB
+                    try:
+                        local_id = int(sf)
+                        from backend.app.models.local_preset import LocalPreset as LP
+
+                        lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+                        lp = lp_result.scalar_one_or_none()
+                        if lp:
+                            mat = (spool.material or lp.filament_type or "").upper().strip()
+                            tray_info_idx = (
+                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
+                            )
+                            # Use local preset name for tray_sub_brands
+                            if lp.name:
+                                tray_sub_brands = lp.name.split("@")[0].strip()
+                            logger.info(
+                                "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
+                                local_id,
+                                mat,
+                                tray_info_idx,
+                            )
+                    except (ValueError, TypeError):
+                        # Not a numeric ID — treat as material type string
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+            if not tray_info_idx:
+                # Fallback: reuse slot's existing tray_info_idx or generic ID
+                if (
+                    current_tray_info_idx
+                    and current_tray_info_idx not in _GENERIC_ID_VALUES
+                    and fingerprint_type
+                    and fingerprint_type.upper() == tray_type.upper()
+                ):
+                    logger.info(
+                        "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
+                        current_tray_info_idx,
+                        tray_type,
+                    )
+                    tray_info_idx = current_tray_info_idx
+                elif tray_type:
+                    material = tray_type.upper().strip()
+                    generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
+                    if generic:
+                        logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
+                        tray_info_idx = generic
 
             # Temperature: use spool overrides if set, else material defaults
             temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))

+ 1 - 0
backend/app/api/routes/printers.py

@@ -561,6 +561,7 @@ async def get_printer_status(
         timelapse=state.timelapse,
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
+        wired_network=state.wired_network,
         nozzles=nozzles,
         nozzle_rack=nozzle_rack,
         print_options=print_options,

+ 1 - 0
backend/app/api/routes/settings.py

@@ -105,6 +105,7 @@ async def get_settings(
                 "ams_temp_good",
                 "ams_temp_fair",
                 "library_disk_warning_gb",
+                "low_stock_threshold",
             ]:
                 settings_dict[setting.key] = float(setting.value)
             elif setting.key in [

+ 282 - 3
backend/app/api/routes/spoolbuddy.py

@@ -17,13 +17,17 @@ from backend.app.schemas.spoolbuddy import (
     CalibrationResponse,
     DeviceRegisterRequest,
     DeviceResponse,
+    DisplaySettingsRequest,
     HeartbeatRequest,
     HeartbeatResponse,
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
+    SetTareRequest,
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
+    WriteTagRequest,
+    WriteTagResultRequest,
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
 
@@ -53,6 +57,12 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         has_scale=device.has_scale,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
+        nfc_reader_type=device.nfc_reader_type,
+        nfc_connection=device.nfc_connection,
+        display_brightness=device.display_brightness,
+        display_blank_timeout=device.display_blank_timeout,
+        has_backlight=device.has_backlight,
+        last_calibrated_at=device.last_calibrated_at,
         last_seen=device.last_seen,
         pending_command=device.pending_command,
         nfc_ok=device.nfc_ok,
@@ -84,6 +94,9 @@ async def register_device(
         device.firmware_version = req.firmware_version
         device.has_nfc = req.has_nfc
         device.has_scale = req.has_scale
+        device.nfc_reader_type = req.nfc_reader_type
+        device.nfc_connection = req.nfc_connection
+        device.has_backlight = req.has_backlight
         device.last_seen = now
         logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
     else:
@@ -96,6 +109,9 @@ async def register_device(
             has_scale=req.has_scale,
             tare_offset=req.tare_offset,
             calibration_factor=req.calibration_factor,
+            nfc_reader_type=req.nfc_reader_type,
+            nfc_connection=req.nfc_connection,
+            has_backlight=req.has_backlight,
             last_seen=now,
         )
         db.add(device)
@@ -150,10 +166,25 @@ async def device_heartbeat(
         device.firmware_version = req.firmware_version
     if req.ip_address:
         device.ip_address = req.ip_address
+    if req.nfc_reader_type:
+        device.nfc_reader_type = req.nfc_reader_type
+    if req.nfc_connection:
+        device.nfc_connection = req.nfc_connection
 
     # Return and clear pending command
     pending = device.pending_command
-    device.pending_command = None
+    pending_write = None
+    if pending == "write_tag" and device.pending_write_payload:
+        # Parse the stored JSON payload to include in response
+        import json
+
+        try:
+            pending_write = json.loads(device.pending_write_payload)
+        except (json.JSONDecodeError, TypeError):
+            pending_write = None
+        # Don't clear write_tag command — it gets cleared by write-result
+    else:
+        device.pending_command = None
 
     await db.commit()
 
@@ -168,8 +199,11 @@ async def device_heartbeat(
 
     return HeartbeatResponse(
         pending_command=pending,
+        pending_write_payload=pending_write,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
+        display_brightness=device.display_brightness,
+        display_blank_timeout=device.display_blank_timeout,
     )
 
 
@@ -236,6 +270,121 @@ async def nfc_tag_removed(
     return {"status": "ok"}
 
 
+@router.post("/nfc/write-tag")
+async def nfc_write_tag(
+    req: WriteTagRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Queue an NFC tag write command for a SpoolBuddy device."""
+    import json
+
+    from backend.app.models.spool import Spool
+    from backend.app.services.opentag3d import encode_opentag3d
+
+    # Find the spool
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(status_code=404, detail="Spool not found")
+
+    # Find the device
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    # Encode OpenTag3D NDEF data
+    ndef_data = encode_opentag3d(spool)
+
+    # Store write payload and set pending command
+    device.pending_write_payload = json.dumps(
+        {
+            "spool_id": spool.id,
+            "ndef_data_hex": ndef_data.hex(),
+        }
+    )
+    device.pending_command = "write_tag"
+    await db.commit()
+
+    logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
+    return {"status": "queued"}
+
+
+@router.post("/nfc/write-result")
+async def nfc_write_result(
+    req: WriteTagResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Handle NFC tag write result from SpoolBuddy daemon."""
+    # Find the device and clear pending state
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.pending_command = None
+    device.pending_write_payload = None
+
+    if req.success:
+        # Link the tag to the spool
+        from backend.app.models.spool import Spool
+
+        result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+        spool = result.scalar_one_or_none()
+        if spool:
+            spool.tag_uid = req.tag_uid.upper()
+            spool.tag_type = "ntag"
+            spool.data_origin = "opentag3d"
+            spool.encode_time = datetime.now(timezone.utc)
+            logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
+
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_written",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "tag_uid": req.tag_uid,
+            }
+        )
+    else:
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_write_failed",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "message": req.message,
+            }
+        )
+        logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
+
+    return {"status": "ok"}
+
+
+@router.post("/devices/{device_id}/cancel-write")
+async def cancel_write(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Cancel a pending write-tag command."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if device.pending_command == "write_tag":
+        device.pending_command = None
+        device.pending_write_payload = None
+        await db.commit()
+        logger.info("Write tag cancelled for device %s", device_id)
+
+    return {"status": "ok"}
+
+
 # --- Scale endpoints ---
 
 
@@ -274,6 +423,8 @@ async def update_spool_weight(
     # net weight = total on scale minus empty spool core
     net_filament = max(0, req.weight_grams - spool.core_weight)
     spool.weight_used = max(0, spool.label_weight - net_filament)
+    spool.last_scale_weight = req.weight_grams
+    spool.last_weighed_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info(
@@ -305,6 +456,30 @@ async def tare_scale(
     return {"status": "ok", "message": "Tare command queued"}
 
 
+@router.post("/devices/{device_id}/calibration/set-tare")
+async def set_tare_offset(
+    device_id: str,
+    req: SetTareRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Store tare offset reported by the daemon after executing a tare."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.tare_offset = req.tare_offset
+    device.last_calibrated_at = datetime.now(timezone.utc)
+    await db.commit()
+
+    logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
+    return CalibrationResponse(
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
 @router.post("/devices/{device_id}/calibration/set-factor")
 async def set_calibration_factor(
     device_id: str,
@@ -318,11 +493,15 @@ async def set_calibration_factor(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    raw_delta = req.raw_adc - device.tare_offset
+    tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
+    raw_delta = req.raw_adc - tare
     if raw_delta == 0:
         raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
 
     device.calibration_factor = req.known_weight_grams / raw_delta
+    if req.tare_raw_adc is not None:
+        device.tare_offset = tare
+    device.last_calibrated_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info(
@@ -331,7 +510,7 @@ async def set_calibration_factor(
         device.calibration_factor,
         req.known_weight_grams,
         req.raw_adc,
-        device.tare_offset,
+        tare,
     )
     return CalibrationResponse(
         tare_offset=device.tare_offset,
@@ -357,6 +536,106 @@ async def get_calibration(
     )
 
 
+# --- Display settings ---
+
+
+@router.put("/devices/{device_id}/display")
+async def update_display_settings(
+    device_id: str,
+    req: DisplaySettingsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update display brightness and screen blank timeout for a device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.display_brightness = req.brightness
+    device.display_blank_timeout = req.blank_timeout
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
+        device_id,
+        req.brightness,
+        req.blank_timeout,
+    )
+    return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
+
+
+# --- Update check ---
+
+
+@router.get("/devices/{device_id}/update-check")
+async def check_daemon_update(
+    device_id: str,
+    include_beta: bool = False,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Check if a newer daemon version is available on GitHub."""
+    import httpx
+
+    from backend.app.api.routes.updates import is_newer_version, parse_version
+    from backend.app.core.config import GITHUB_REPO
+
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    current = device.firmware_version or "0.0.0"
+
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
+                headers={"Accept": "application/vnd.github.v3+json"},
+                timeout=10.0,
+            )
+            response.raise_for_status()
+            releases = response.json()
+
+            release_data = None
+            for release in releases:
+                tag = release.get("tag_name", "")
+                if include_beta:
+                    release_data = release
+                    break
+                else:
+                    parsed = parse_version(tag)
+                    if parsed[4] == 0:  # is_prerelease == 0
+                        release_data = release
+                        break
+
+            if not release_data:
+                return {
+                    "current_version": current,
+                    "latest_version": None,
+                    "update_available": False,
+                    "release_url": None,
+                }
+
+            latest = release_data.get("tag_name", "").lstrip("v")
+            return {
+                "current_version": current,
+                "latest_version": latest,
+                "update_available": is_newer_version(latest, current),
+                "release_url": release_data.get("html_url"),
+            }
+    except Exception as e:
+        logger.warning("Failed to check for daemon updates: %s", e)
+        return {
+            "current_version": current,
+            "latest_version": None,
+            "update_available": False,
+            "release_url": None,
+            "error": str(e),
+        }
+
+
 # --- Background watchdog ---
 
 

+ 101 - 4
backend/app/api/routes/support.py

@@ -325,6 +325,37 @@ def _sanitize_path(path: str) -> str:
     return path
 
 
+def _detect_docker_network_mode() -> str:
+    """Detect Docker network mode by checking for host-level interfaces.
+
+    In host mode the container shares the host network namespace, so Docker
+    infrastructure interfaces (docker0, br-*, veth*) are visible.  In bridge
+    mode the container is isolated and only sees its own veth (named eth0).
+    """
+    try:
+        import socket
+
+        for _idx, name in socket.if_nameindex():
+            if name.startswith(("docker", "br-", "veth", "virbr")):
+                return "host"
+    except Exception:
+        pass
+    return "bridge"
+
+
+def _mask_subnet(subnet: str) -> str:
+    """Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'."""
+    try:
+        parts = subnet.split(".")
+        if len(parts) >= 4:
+            parts[0] = "x"
+            parts[1] = "x"
+            return ".".join(parts)
+    except Exception:
+        pass
+    return subnet
+
+
 def _anonymize_mqtt_broker(broker: str) -> str:
     """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
     if not broker:
@@ -418,11 +449,10 @@ async def _collect_support_info() -> dict:
     if in_docker:
         try:
             mem_limit = _get_container_memory_limit()
-            interfaces = get_network_interfaces()
             info["docker"] = {
                 "container_memory_limit_bytes": mem_limit,
                 "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
-                "network_mode_hint": "host" if len(interfaces) > 2 else "bridge",
+                "network_mode_hint": _detect_docker_network_mode(),
             }
         except Exception:
             logger.debug("Failed to collect Docker info", exc_info=True)
@@ -500,6 +530,34 @@ async def _collect_support_info() -> dict:
                 }
             )
 
+        # Virtual printers
+        try:
+            from backend.app.models.virtual_printer import VirtualPrinter
+            from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+
+            result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))
+            vps = result.scalars().all()
+            info["virtual_printers"] = []
+            for vp in vps:
+                instance = virtual_printer_manager.get_instance(vp.id)
+                status = instance.get_status() if instance else None
+                model_code = vp.model or "C12"
+                info["virtual_printers"].append(
+                    {
+                        "index": vp.id,
+                        "enabled": vp.enabled,
+                        "mode": vp.mode,
+                        "model": model_code,
+                        "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
+                        "has_target_printer": vp.target_printer_id is not None,
+                        "has_bind_ip": bool(vp.bind_ip),
+                        "running": status.get("running", False) if status else False,
+                        "pending_files": status.get("pending_files", 0) if status else 0,
+                    }
+                )
+        except Exception:
+            logger.debug("Failed to collect virtual printer info", exc_info=True)
+
         # Non-sensitive settings
         result = await db.execute(select(Settings))
         all_settings = result.scalars().all()
@@ -642,12 +700,12 @@ async def _collect_support_info() -> dict:
     except Exception:
         logger.debug("Failed to collect log file info", exc_info=True)
 
-    # Network interfaces (subnets only — already anonymized)
+    # Network interfaces (subnets with first two octets masked)
     try:
         interfaces = get_network_interfaces()
         info["network"] = {
             "interface_count": len(interfaces),
-            "interfaces": [{"name": iface["name"], "subnet": iface["subnet"]} for iface in interfaces],
+            "interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces],
         }
     except Exception:
         logger.debug("Failed to collect network info", exc_info=True)
@@ -721,6 +779,45 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
     return content.encode("utf-8")
 
 
+async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
+    """Get recent log lines, sanitized for inclusion in bug reports."""
+    # Collect sensitive strings from DB for redaction
+    sensitive_strings: dict[str, str] = {}
+    async with async_session() as db:
+        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address))
+        for name, serial, ip_address in result.all():
+            if name:
+                sensitive_strings[name] = "[PRINTER]"
+            if serial:
+                sensitive_strings[serial] = "[SERIAL]"
+            if ip_address:
+                sensitive_strings[ip_address] = "[IP]"
+
+        result = await db.execute(select(User.username))
+        for (username,) in result.all():
+            if username:
+                sensitive_strings[username] = "[USER]"
+
+        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
+        cloud_email = result.scalar_one_or_none()
+        if cloud_email:
+            sensitive_strings[cloud_email] = "[EMAIL]"
+
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return ""
+
+    # Read last portion of log file
+    try:
+        content = log_file.read_text(encoding="utf-8", errors="replace")
+        lines = content.splitlines()
+        recent = "\n".join(lines[-max_lines:])
+        return _sanitize_log_content(recent, sensitive_strings)
+    except Exception:
+        logger.debug("Failed to read logs for bug report", exc_info=True)
+        return ""
+
+
 @router.get("/bundle")
 async def generate_support_bundle(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),

+ 2 - 1
backend/app/core/config.py

@@ -5,8 +5,9 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.1.1"
+APP_VERSION = "0.2.2b1"
 GITHUB_REPO = "maziggy/bambuddy"
+BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 # App directory - where the application is installed (for static files)
 _app_dir = Path(__file__).resolve().parent.parent.parent.parent

+ 48 - 0
backend/app/core/database.py

@@ -76,6 +76,7 @@ async def init_db():
         ams_history,
         api_key,
         archive,
+        bug_report,
         color_catalog,
         external_link,
         filament,
@@ -1230,6 +1231,16 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add SpoolBuddy scale weight tracking columns to spool table
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add cost tracking fields to spool table
     try:
         await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL"))
@@ -1318,6 +1329,43 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add NFC reader and display control columns to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add NFC tag write payload column to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Cleanup: Remove obsolete settings keys that are no longer used
+    obsolete_keys = ["slicer_binary_path"]
+    for key in obsolete_keys:
+        await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 22 - 0
backend/app/main.py

@@ -16,6 +16,7 @@ from backend.app.api.routes import (
     archives,
     auth,
     background_dispatch as background_dispatch_routes,
+    bug_report,
     camera,
     cloud,
     discovery,
@@ -2208,6 +2209,10 @@ async def on_print_complete(printer_id: int, data: dict):
             queue_item = printing_items[0] if printing_items else None
             if queue_item:
                 queue_status = data.get("status", "completed")
+                # MQTT sends "aborted" for cancelled prints; normalise to
+                # "cancelled" so it matches the queue schema Literal.
+                if queue_status == "aborted":
+                    queue_status = "cancelled"
                 queue_item.status = queue_status
                 queue_item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
@@ -3256,6 +3261,22 @@ async def lifespan(app: FastAPI):
     # Startup
     await init_db()
 
+    # Fix queue items stuck with invalid "aborted" status (should be "cancelled").
+    # This can happen when a print was cancelled mid-print on versions before this fix.
+    try:
+        async with async_session() as db:
+            from backend.app.models.print_queue import PrintQueueItem
+
+            result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
+            aborted_items = result.scalars().all()
+            if aborted_items:
+                for item in aborted_items:
+                    item.status = "cancelled"
+                await db.commit()
+                logging.info("Fixed %d queue item(s) with invalid 'aborted' status → 'cancelled'", len(aborted_items))
+    except Exception as e:
+        logging.warning("Failed to fix aborted queue items: %s", e)
+
     # Restore debug logging state from previous session
     await init_debug_logging()
 
@@ -3563,6 +3584,7 @@ async def auth_middleware(request, call_next):
 
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
+app.include_router(bug_report.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)

+ 20 - 0
backend/app/models/bug_report.py

@@ -0,0 +1,20 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class BugReport(Base):
+    __tablename__ = "bug_reports"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    description: Mapped[str] = mapped_column(Text)
+    reporter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    github_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    github_issue_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    status: Mapped[str] = mapped_column(String(20), default="submitted")
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 2 - 0
backend/app/models/spool.py

@@ -24,6 +24,8 @@ class Spool(Base):
     )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
+    last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
+    last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp

+ 8 - 1
backend/app/models/spoolbuddy_device.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -20,8 +20,15 @@ class SpoolBuddyDevice(Base):
     has_scale: Mapped[bool] = mapped_column(Boolean, default=True)
     tare_offset: Mapped[int] = mapped_column(Integer, default=0)
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
+    nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
+    nfc_connection: Mapped[str | None] = mapped_column(String(20))
+    display_brightness: Mapped[int] = mapped_column(Integer, default=100)
+    display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
+    has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)
+    last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     pending_command: Mapped[str | None] = mapped_column(String(50))
+    pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)

+ 21 - 0
backend/app/schemas/archive.py

@@ -110,6 +110,27 @@ class ArchiveResponse(BaseModel):
         from_attributes = True
 
 
+class ArchiveSlim(BaseModel):
+    """Lightweight archive response for stats/dashboard widgets."""
+
+    printer_id: int | None
+    print_name: str | None
+    print_time_seconds: int | None
+    actual_time_seconds: int | None = None
+    filament_used_grams: float | None
+    filament_type: str | None
+    filament_color: str | None
+    status: str
+    started_at: datetime | None
+    completed_at: datetime | None
+    cost: float | None
+    quantity: int = 1
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
 class ArchiveStats(BaseModel):
     total_prints: int
     successful_prints: int

+ 1 - 0
backend/app/schemas/printer.py

@@ -205,6 +205,7 @@ class PrinterStatus(BaseModel):
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
+    wired_network: bool = False  # Ethernet connection detected
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     nozzle_rack: list[NozzleRackSlot] = []  # H2C 6-nozzle tool-changer rack
     print_options: PrintOptionsResponse | None = None  # AI detection and print options

+ 11 - 0
backend/app/schemas/settings.py

@@ -38,6 +38,7 @@ class AppSettings(BaseModel):
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
 
     # Language
+    language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
     # Bed cooled notification threshold
@@ -148,6 +149,14 @@ class AppSettings(BaseModel):
         default="", description="Bearer token for Prometheus metrics authentication (optional)"
     )
 
+    # Inventory low stock threshold
+    low_stock_threshold: float = Field(
+        default=20.0,
+        ge=0.1,
+        le=99.9,
+        description="Low stock threshold percentage (%) for inventory filtering and display",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -167,6 +176,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
     ams_humidity_good: int | None = None
@@ -208,3 +218,4 @@ class AppSettingsUpdate(BaseModel):
     preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
+    low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

+ 2 - 0
backend/app/schemas/spool.py

@@ -24,6 +24,8 @@ class SpoolBase(BaseModel):
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool = False
+    last_scale_weight: int | None = None
+    last_weighed_at: datetime | None = None
 
 
 class SpoolCreate(SpoolBase):

+ 40 - 0
backend/app/schemas/spoolbuddy.py

@@ -14,6 +14,9 @@ class DeviceRegisterRequest(BaseModel):
     has_scale: bool = True
     tare_offset: int = 0
     calibration_factor: float = 1.0
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    has_backlight: bool = False
 
 
 class DeviceResponse(BaseModel):
@@ -26,6 +29,12 @@ class DeviceResponse(BaseModel):
     has_scale: bool
     tare_offset: int
     calibration_factor: float
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    display_brightness: int = 100
+    display_blank_timeout: int = 0
+    has_backlight: bool = False
+    last_calibrated_at: datetime | None = None
     last_seen: datetime | None = None
     pending_command: str | None = None
     nfc_ok: bool
@@ -45,12 +54,17 @@ class HeartbeatRequest(BaseModel):
     uptime_s: int = 0
     firmware_version: str | None = None
     ip_address: str | None = None
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
 
 
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
+    pending_write_payload: dict | None = None
     tare_offset: int
     calibration_factor: float
+    display_brightness: int = 100
+    display_blank_timeout: int = 0
 
 
 # --- NFC schemas ---
@@ -92,11 +106,37 @@ class TareRequest(BaseModel):
     pass
 
 
+class SetTareRequest(BaseModel):
+    tare_offset: int
+
+
 class SetCalibrationFactorRequest(BaseModel):
     known_weight_grams: float = Field(..., gt=0)
     raw_adc: int
+    tare_raw_adc: int | None = None
 
 
 class CalibrationResponse(BaseModel):
     tare_offset: int
     calibration_factor: float
+
+
+# --- Display schemas ---
+
+
+class WriteTagRequest(BaseModel):
+    device_id: str
+    spool_id: int
+
+
+class WriteTagResultRequest(BaseModel):
+    device_id: str
+    spool_id: int
+    tag_uid: str
+    success: bool
+    message: str | None = None
+
+
+class DisplaySettingsRequest(BaseModel):
+    brightness: int = Field(ge=0, le=100)
+    blank_timeout: int = Field(ge=0)

+ 11 - 1
backend/app/services/archive.py

@@ -4,7 +4,7 @@ import logging
 import re
 import shutil
 import zipfile
-from datetime import datetime, timezone
+from datetime import date, datetime, time, timezone
 from pathlib import Path
 
 from defusedxml import ElementTree as ET
@@ -996,6 +996,8 @@ class ArchiveService:
         self,
         printer_id: int | None = None,
         project_id: int | None = None,
+        date_from: date | None = None,
+        date_to: date | None = None,
         limit: int = 50,
         offset: int = 0,
     ) -> list[PrintArchive]:
@@ -1014,6 +1016,14 @@ class ArchiveService:
         if project_id:
             query = query.where(PrintArchive.project_id == project_id)
 
+        if date_from:
+            dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+            query = query.where(PrintArchive.created_at >= dt_from)
+
+        if date_to:
+            dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+            query = query.where(PrintArchive.created_at <= dt_to)
+
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         return list(result.scalars().all())

+ 1 - 0
backend/app/services/background_dispatch.py

@@ -715,6 +715,7 @@ class BackgroundDispatchService:
             archive = await archive_service.archive_print(
                 printer_id=job.printer_id,
                 source_file=file_path,
+                original_filename=lib_file.filename,
             )
             if not archive:
                 raise RuntimeError("Failed to create archive")

+ 26 - 16
backend/app/services/bambu_ftp.py

@@ -4,6 +4,7 @@ import logging
 import os
 import socket
 import ssl
+import time
 from collections.abc import Awaitable, Callable
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
@@ -81,11 +82,10 @@ class BambuFTPClient:
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # These models have varying FTP SSL behavior depending on firmware version
     A1_MODELS = ("A1", "A1 Mini")
-    # Chunk size for manual upload transfer (1MB)
-    # Larger chunks reduce overhead and work better with A1 printers
-    CHUNK_SIZE = 1024 * 1024
-    # Per-chunk data socket timeout during upload.
-    UPLOAD_CHUNK_TIMEOUT = 120
+    # Chunk size for manual upload transfer (64KB)
+    # Smaller chunks provide smoother progress reporting — at typical printer FTP
+    # speeds (~50-100KB/s) this gives a progress update roughly every second.
+    CHUNK_SIZE = 64 * 1024
 
     # Cache for working FTP modes per printer IP
     # Maps IP -> "prot_p" or "prot_c"
@@ -368,11 +368,16 @@ class BambuFTPClient:
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             with open(local_path, "rb") as f:
                 logger.debug("FTP STOR command starting for %s", remote_path)
+                t0 = time.monotonic()
                 conn = self._ftp.transfercmd(f"STOR {remote_path}")
+                logger.info(
+                    "FTP data channel ready in %.1fs (PASV + TLS handshake)",
+                    time.monotonic() - t0,
+                )
 
                 # Set explicit socket options for reliable transfer
                 conn.setblocking(True)
-                conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
+                conn.settimeout(self.timeout)
 
                 try:
                     while True:
@@ -408,14 +413,11 @@ class BambuFTPClient:
                     except OSError:
                         pass
 
-            # Skip voidresp() for A1 models — they hang after transfercmd uploads
-            if self.printer_model not in self.A1_MODELS:
-                try:
-                    self._ftp.voidresp()
-                except (OSError, ftplib.Error) as e:
-                    # Data transfer already completed — voidresp() failure is just a noisy
-                    # 226 acknowledgment issue, not an actual upload failure. Log and continue.
-                    logger.warning("FTP upload response for %s was not clean (data already sent): %s", remote_path, e)
+            # Skip voidresp() for all models — the data transfer is already complete.
+            # A1 models hang indefinitely on voidresp(). H2D printers (vsFTPd) delay
+            # the 226 response by 30+ seconds after data is fully sent. Even X1C/P1S
+            # gain nothing from waiting — the file is on the SD card once sendall() returns.
+            # Verified via direct curl upload: 226 arrives ~32s after data channel closes.
 
             if callback_exception is not None:
                 cleanup_ok = False
@@ -432,7 +434,15 @@ class BambuFTPClient:
                     f"Upload cancelled but failed to remove partial file {remote_path} from printer"
                 ) from callback_exception
 
-            logger.info("FTP upload complete: %s", remote_path)
+            elapsed = time.monotonic() - t0
+            speed_kbs = (file_size / 1024) / elapsed if elapsed > 0 else 0
+            logger.info(
+                "FTP upload complete: %s (%s bytes in %.1fs, %.0f KB/s)",
+                remote_path,
+                file_size,
+                elapsed,
+                speed_kbs,
+            )
             return True
         except ftplib.error_perm as e:
             # Permanent FTP error (4xx/5xx response)
@@ -462,7 +472,7 @@ class BambuFTPClient:
             # Use manual transfer instead of storbinary() for A1 compatibility
             conn = self._ftp.transfercmd(f"STOR {remote_path}")
             conn.setblocking(True)
-            conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
+            conn.settimeout(self.timeout)
 
             try:
                 # Send data in chunks

+ 47 - 23
backend/app/services/bambu_mqtt.py

@@ -113,6 +113,7 @@ class PrinterState:
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
+    wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
@@ -538,6 +539,16 @@ class BambuMQTTClient:
                 except ValueError:
                     pass  # Ignore unparseable wifi_signal strings; field is non-critical
 
+        # Parse developer LAN mode from top-level "fun" field
+        # Some firmware versions send "fun" at the top level, others inside "print"
+        if "fun" in payload and self.state.developer_mode is None:
+            try:
+                fun_val = payload["fun"]
+                fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)
+                self.state.developer_mode = (fun_int & 0x20000000) == 0
+            except (ValueError, TypeError):
+                pass
+
         if "print" in payload:
             print_data = payload["print"]
 
@@ -1844,6 +1855,11 @@ class BambuMQTTClient:
                         severity = (attr >> 8) & 0xF
                         # Module is in attr byte 3 (bits 24-31)
                         module = (attr >> 24) & 0xFF
+                        # Skip non-error status codes — all real HMS errors
+                        # have code >= 0x4000. Lower values are status/phase
+                        # indicators that some firmware sends during normal printing.
+                        if code < 0x4000:
+                            continue
                         self.state.hms_errors.append(
                             HMSError(
                                 code=f"0x{code:x}" if code else "0x0",
@@ -1865,32 +1881,38 @@ class BambuMQTTClient:
                 module = (print_error >> 16) & 0xFFFF  # High 16 bits (e.g., 0x0500)
                 error = print_error & 0xFFFF  # Low 16 bits (e.g., 0x8061)
 
-                # Store in a format that matches the community error database
-                # attr stores the full 32-bit value for reconstruction
-                # code stores the short format string for lookup
-                short_code = f"{module:04X}_{error:04X}"
+                # Values below 0x4000 are status/phase indicators, not real errors.
+                # All known HMS errors use 0x4xxx (fatal), 0x8xxx (warning), 0xCxxx (prompt).
+                # Some firmware sends low values like 0x0002 during normal printing.
+                if error < 0x4000:
+                    pass  # Skip — not a real error
+                else:
+                    # Store in a format that matches the community error database
+                    # attr stores the full 32-bit value for reconstruction
+                    # code stores the short format string for lookup
+                    short_code = f"{module:04X}_{error:04X}"
 
-                logger.debug(
-                    f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
-                )
+                    logger.debug(
+                        f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
+                    )
+
+                    # Only add if not already in HMS errors (avoid duplicates)
+                    existing_short_codes = set()
+                    for e in self.state.hms_errors:
+                        # Extract short code from existing errors
+                        e_module = (e.attr >> 16) & 0xFFFF
+                        e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
+                        existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
 
-                # Only add if not already in HMS errors (avoid duplicates)
-                existing_short_codes = set()
-                for e in self.state.hms_errors:
-                    # Extract short code from existing errors
-                    e_module = (e.attr >> 16) & 0xFFFF
-                    e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
-                    existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
-
-                if short_code not in existing_short_codes:
-                    self.state.hms_errors.append(
-                        HMSError(
-                            code=f"0x{error:x}",
-                            attr=print_error,  # Store full value for display
-                            module=module >> 8,  # High byte of module (e.g., 0x05)
-                            severity=3,  # Warning level for print_error
+                    if short_code not in existing_short_codes:
+                        self.state.hms_errors.append(
+                            HMSError(
+                                code=f"0x{error:x}",
+                                attr=print_error,  # Store full value for display
+                                module=module >> 8,  # High byte of module (e.g., 0x05)
+                                severity=3,  # Warning level for print_error
+                            )
                         )
-                    )
 
         # Parse SD card status
         if "sdcard" in data:
@@ -1909,6 +1931,8 @@ class BambuMQTTClient:
                     f"[{self.serial_number}] store_to_sdcard changed: {self.state.store_to_sdcard} -> {store_to_sdcard}"
                 )
             self.state.store_to_sdcard = store_to_sdcard
+            # Bit 18 (0x00040000) indicates wired/ethernet connection
+            self.state.wired_network = bool((home_flag >> 18) & 1)
 
         # Parse timelapse status (recording active during print)
         if "timelapse" in data:

+ 142 - 0
backend/app/services/bug_report.py

@@ -0,0 +1,142 @@
+"""Bug report service — posts to the bambuddy.cool relay which holds the GitHub PAT."""
+
+import logging
+import time
+
+import httpx
+
+from backend.app.core.config import BUG_REPORT_RELAY_URL
+from backend.app.core.database import async_session
+from backend.app.models.bug_report import BugReport
+
+logger = logging.getLogger(__name__)
+
+# Rate limiting: max 5 reports per hour
+_rate_limit_window = 3600
+_rate_limit_max = 5
+_rate_limit_timestamps: list[float] = []
+
+
+def _check_rate_limit() -> bool:
+    """Check if rate limit allows a new report. Returns True if allowed."""
+    now = time.time()
+    _rate_limit_timestamps[:] = [t for t in _rate_limit_timestamps if now - t < _rate_limit_window]
+    if len(_rate_limit_timestamps) >= _rate_limit_max:
+        return False
+    _rate_limit_timestamps.append(now)
+    return True
+
+
+async def submit_report(
+    description: str,
+    reporter_email: str | None,
+    screenshot_base64: str | None,
+    support_info: dict | None,
+) -> dict:
+    """Submit a bug report via the bambuddy.cool relay."""
+    if not _check_rate_limit():
+        return {
+            "success": False,
+            "message": "Rate limit exceeded. Please try again later.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    if not BUG_REPORT_RELAY_URL:
+        return {
+            "success": False,
+            "message": "Bug reporting is not configured. BUG_REPORT_RELAY_URL is not set.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    # Build relay payload — email is sent to relay for maintainer notification + issue body
+    payload: dict = {"description": description}
+    if reporter_email:
+        payload["reporter_email"] = reporter_email
+    if screenshot_base64:
+        payload["screenshot_base64"] = screenshot_base64
+    if support_info:
+        payload["support_info"] = support_info
+
+    try:
+        async with httpx.AsyncClient(timeout=60.0) as client:
+            resp = await client.post(BUG_REPORT_RELAY_URL, json=payload)
+            if resp.status_code != 200:
+                error_msg = f"Relay returned HTTP {resp.status_code}"
+                logger.error("%s at %s", error_msg, BUG_REPORT_RELAY_URL)
+                async with async_session() as db:
+                    report = BugReport(
+                        description=description,
+                        reporter_email=reporter_email,
+                        status="failed",
+                        error_message=error_msg,
+                    )
+                    db.add(report)
+                    await db.commit()
+                return {
+                    "success": False,
+                    "message": "Bug report relay is not available. Please try again later.",
+                    "issue_url": None,
+                    "issue_number": None,
+                }
+            relay_data = resp.json()
+    except Exception:
+        logger.exception("Failed to reach bug report relay at %s", BUG_REPORT_RELAY_URL)
+        async with async_session() as db:
+            report = BugReport(
+                description=description,
+                reporter_email=reporter_email,
+                status="failed",
+                error_message="Failed to reach bug report relay",
+            )
+            db.add(report)
+            await db.commit()
+
+        return {
+            "success": False,
+            "message": "Failed to submit bug report. Please try again later.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    if not relay_data.get("success"):
+        async with async_session() as db:
+            report = BugReport(
+                description=description,
+                reporter_email=reporter_email,
+                status="failed",
+                error_message=relay_data.get("message", "Relay returned failure"),
+            )
+            db.add(report)
+            await db.commit()
+
+        return {
+            "success": False,
+            "message": relay_data.get("message", "Failed to create bug report."),
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    issue_number = relay_data["issue_number"]
+    issue_url = relay_data["issue_url"]
+
+    # Save to DB
+    async with async_session() as db:
+        report = BugReport(
+            description=description,
+            reporter_email=reporter_email,
+            github_issue_number=issue_number,
+            github_issue_url=issue_url,
+            status="submitted",
+            email_sent=True,
+        )
+        db.add(report)
+        await db.commit()
+
+    return {
+        "success": True,
+        "message": "Bug report submitted successfully!",
+        "issue_url": issue_url,
+        "issue_number": issue_number,
+    }

+ 34 - 14
backend/app/services/failure_analysis.py

@@ -1,5 +1,5 @@
 from collections import defaultdict
-from datetime import datetime, timedelta, timezone
+from datetime import date, datetime, time, timedelta, timezone
 
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,28 +16,47 @@ class FailureAnalysisService:
 
     async def analyze_failures(
         self,
-        days: int = 30,
+        days: int | None = None,
+        date_from: date | None = None,
+        date_to: date | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
     ) -> dict:
         """Analyze failure patterns across archives.
 
         Args:
-            days: Number of days to analyze
+            days: Number of days to analyze (fallback when no date range)
+            date_from: Start date filter (inclusive)
+            date_to: End date filter (inclusive)
             printer_id: Optional filter by printer
             project_id: Optional filter by project
 
         Returns:
             Dictionary with failure analysis results
         """
-        cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
-
-        # Build base query
-        base_filter = [PrintArchive.created_at >= cutoff_date]
+        # Build base query — separate date vs non-date filters for trend reuse
+        base_filter = []
+        non_date_filter = []
+        if date_from or date_to:
+            if date_from:
+                dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+                base_filter.append(PrintArchive.created_at >= dt_from)
+            if date_to:
+                dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+                base_filter.append(PrintArchive.created_at <= dt_to)
+            # Compute effective span for trend
+            range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
+            range_end = dt_to if date_to else datetime.now(timezone.utc)
+            effective_days = max((range_end - range_start).days, 1)
+        else:
+            effective_days = days if days is not None else 30
+            cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
+            base_filter.append(PrintArchive.created_at >= cutoff_date)
         if printer_id:
-            base_filter.append(PrintArchive.printer_id == printer_id)
+            non_date_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:
-            base_filter.append(PrintArchive.project_id == project_id)
+            non_date_filter.append(PrintArchive.project_id == project_id)
+        base_filter.extend(non_date_filter)
 
         # Total counts
         total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
@@ -141,15 +160,16 @@ class FailureAnalysisService:
 
         # Failure rate trend (by week)
         trend_data = []
-        for i in range(min(days // 7, 12)):  # Up to 12 weeks
+        num_weeks = max(effective_days // 7, 1)
+        for i in range(num_weeks):
             week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_start = week_end - timedelta(weeks=1)
 
-            week_filter = base_filter.copy()
-            week_filter[0] = and_(
+            week_filter = [
                 PrintArchive.created_at >= week_start,
                 PrintArchive.created_at < week_end,
-            )
+                *non_date_filter,
+            ]
 
             week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
@@ -174,7 +194,7 @@ class FailureAnalysisService:
         trend_data.reverse()  # Oldest first
 
         return {
-            "period_days": days,
+            "period_days": effective_days,
             "total_prints": total_prints,
             "failed_prints": failed_prints,
             "failure_rate": round(failure_rate, 1),

+ 16 - 0
backend/app/services/firmware_check.py

@@ -49,6 +49,22 @@ MODEL_TO_API_KEY = {
     "H2D Pro": "h2d-pro",
     "H2D-Pro": "h2d-pro",
     "H2DPRO": "h2d-pro",
+    # SSDP model codes (DevModel header) — in case raw codes are stored
+    "O1D": "h2d",
+    "O1E": "h2d-pro",
+    "O2D": "h2d-pro",
+    "O1C": "h2c",
+    "O1C2": "h2c",
+    "O1S": "h2s",
+    "BL-P001": "x1",
+    "BL-P002": "x1",
+    "BL-P003": "x1e",
+    "C11": "p1",
+    "C12": "p1",
+    "C13": "p2s",
+    "N2S": "a1",
+    "N1": "a1-mini",
+    "N7": "p2s",
 }
 
 # Reverse mapping: API key to model codes

+ 103 - 0
backend/app/services/opentag3d.py

@@ -0,0 +1,103 @@
+"""OpenTag3D NDEF encoder for NTAG tags.
+
+Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
+starting at page 4 (after the manufacturer pages).
+
+NDEF structure:
+  [CC: E1 10 12 00]              - Capability Container (4 bytes, page 4)
+  [TLV: 03 len]                  - NDEF Message TLV (2 bytes)
+  [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
+  [Type: "application/opentag3d"] - 21 bytes
+  [Payload: OpenTag3D fields]    - 102 bytes
+  [Terminator: FE]               - 1 byte
+"""
+
+import struct
+
+from backend.app.models.spool import Spool
+
+OPENTAG3D_MIME_TYPE = b"application/opentag3d"
+PAYLOAD_SIZE = 102
+TAG_VERSION = 1000  # v1.000
+
+
+def _build_payload(spool: Spool) -> bytes:
+    """Build 102-byte OpenTag3D core payload from spool fields."""
+    buf = bytearray(PAYLOAD_SIZE)
+
+    # 0x00: Tag Version (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x00, TAG_VERSION)
+
+    # 0x02: Base Material (5 bytes, UTF-8, space-padded)
+    material = (spool.material or "")[:5].ljust(5)
+    buf[0x02:0x07] = material.encode("utf-8")[:5]
+
+    # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
+    modifiers = (spool.subtype or "")[:5].ljust(5)
+    buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
+
+    # 0x0C: Reserved (15 bytes, zero-fill) — already zero
+
+    # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
+    brand = (spool.brand or "")[:16].ljust(16)
+    buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
+
+    # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
+    color_name = (spool.color_name or "")[:32].ljust(32)
+    buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
+
+    # 0x4B: Color 1 RGBA (4 bytes)
+    rgba_hex = spool.rgba or "00000000"
+    try:
+        rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
+    except ValueError:
+        rgba_bytes = b"\x00\x00\x00\x00"
+    buf[0x4B:0x4F] = rgba_bytes[:4]
+
+    # 0x4F: Colors 2-4 (12 bytes, zero-fill) — already zero
+
+    # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
+    struct.pack_into(">H", buf, 0x5C, 1750)
+
+    # 0x5E: Target Weight (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0)
+
+    # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5
+    buf[0x60] = (spool.nozzle_temp_min or 0) // 5
+
+    # 0x61: Bed Temp (1 byte) — not tracked
+    # 0x62: Density (2 bytes) — not tracked
+    # 0x64: Transmission Distance (2 bytes) — not tracked
+    # All zero — already zero
+
+    return bytes(buf)
+
+
+def encode_opentag3d(spool: Spool) -> bytes:
+    """Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator).
+
+    Returns raw bytes ready to write to NTAG starting at page 4.
+    """
+    payload = _build_payload(spool)
+    mime_type = OPENTAG3D_MIME_TYPE
+
+    # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
+    # Type length = 21
+    # Payload length = 102 (fits in SR single byte)
+    record_header = bytes([0xD2, len(mime_type), len(payload)])
+    ndef_record = record_header + mime_type + payload
+
+    # TLV: type=0x03 (NDEF Message), length
+    ndef_len = len(ndef_record)
+    if ndef_len < 0xFF:
+        tlv = bytes([0x03, ndef_len])
+    else:
+        tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
+
+    # Capability Container (page 4)
+    cc = bytes([0xE1, 0x10, 0x12, 0x00])
+
+    # Terminator TLV
+    terminator = bytes([0xFE])
+
+    return cc + tlv + ndef_record + terminator

+ 14 - 1
backend/app/services/orca_profiles.py

@@ -230,10 +230,23 @@ MATERIAL_TYPES = [
 
 
 def _parse_material_from_name(name: str) -> str | None:
-    """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'."""
+    """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'.
+
+    Handles 'X Support for Y' patterns where the filament type is Y, not X.
+    e.g. 'PLA Support for PETG PETG Basic @Bambu Lab H2D' -> 'PETG'.
+    """
     import re
 
     upper = name.upper()
+
+    # Handle "X Support for Y" pattern: the filament type is Y, not X.
+    support_match = re.search(r"\bSUPPORT\s+FOR\s+", upper)
+    if support_match:
+        after_support = upper[support_match.end() :]
+        for mat in MATERIAL_TYPES:
+            if re.search(rf"\b{mat}\b", after_support):
+                return mat
+
     for mat in MATERIAL_TYPES:
         if re.search(rf"\b{mat}\b", upper):
             return mat

+ 1 - 0
backend/app/services/printer_manager.py

@@ -664,6 +664,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
+        "wired_network": state.wired_network,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state, model),

+ 4 - 0
backend/app/services/spool_tag_matcher.py

@@ -145,6 +145,10 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     db.add(spool)
     await db.flush()
 
+    # Eagerly set k_profiles so callers (auto_assign_spool) don't trigger
+    # a lazy load in async context (greenlet_spawn error).
+    spool.k_profiles = []
+
     logger.info(
         "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
         spool.id,

+ 42 - 8
backend/app/services/virtual_printer/bind_server.py

@@ -2,9 +2,12 @@
 
 Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
 or 3002 to perform the "bind with access code" handshake before using
-MQTT/FTP. The port varies by slicer version, so we listen on both.
+MQTT/FTP.
 
-Protocol:
+Port 3000: plain TCP (legacy / some printer models).
+Port 3002: TLS (newer firmware, e.g. A1 Mini 01.07.x).
+
+Protocol (same on both ports, only transport differs):
   - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
   - Slicer sends: {"login":{"command":"detect","sequence_id":"20000"}}
   - Printer replies: {"login":{"bind":"free","command":"detect","connect":"lan",
@@ -16,11 +19,15 @@ Protocol:
 import asyncio
 import json
 import logging
+import ssl
 import struct
+from pathlib import Path
 
 logger = logging.getLogger(__name__)
 
-BIND_PORTS = [3000, 3002]
+BIND_PORT_PLAIN = 3000
+BIND_PORT_TLS = 3002
+BIND_PORTS = [BIND_PORT_PLAIN, BIND_PORT_TLS]
 FRAME_HEADER = b"\xa5\xa5"
 FRAME_TRAILER = b"\xa7\xa7"
 HEADER_SIZE = 4  # 2 bytes magic + 2 bytes length
@@ -33,8 +40,8 @@ class BindServer:
     In server mode, Bambuddy IS the printer — it responds with its own
     identity so the slicer can discover and bind to it.
 
-    Different BambuStudio versions connect on different ports (3000 or 3002),
-    so we listen on both to ensure compatibility.
+    Port 3000 is plain TCP, port 3002 is TLS.  BambuStudio chooses which
+    port to use based on the printer model discovered via SSDP.
     """
 
     def __init__(
@@ -44,39 +51,66 @@ class BindServer:
         name: str,
         version: str = "01.00.00.00",
         bind_address: str = "0.0.0.0",  # nosec B104
+        cert_path: Path | None = None,
+        key_path: Path | None = None,
     ):
         self.serial = serial
         self.model = model
         self.name = name
         self.version = version
         self.bind_address = bind_address
+        self.cert_path = cert_path
+        self.key_path = key_path
 
         self._servers: list[asyncio.Server] = []
         self._running = False
 
+    def _create_tls_context(self) -> ssl.SSLContext | None:
+        """Create SSL context for the TLS bind port (3002)."""
+        if not self.cert_path or not self.key_path:
+            return None
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        ctx.verify_mode = ssl.CERT_NONE
+        return ctx
+
     async def start(self) -> None:
-        """Start the bind server on ports 3000 and 3002."""
+        """Start the bind server on ports 3000 (plain) and 3002 (TLS)."""
         if self._running:
             return
 
         self._running = True
+
+        tls_ctx = self._create_tls_context()
+        if not tls_ctx:
+            logger.warning("Bind server: no TLS cert provided, port %s will be plain TCP", BIND_PORT_TLS)
+
         logger.info(
-            "Starting bind server on ports %s (serial=%s, model=%s)",
+            "Starting bind server on ports %s (serial=%s, model=%s, tls=%s)",
             BIND_PORTS,
             self.serial,
             self.model,
+            tls_ctx is not None,
         )
 
         try:
             for port in BIND_PORTS:
+                use_tls = port == BIND_PORT_TLS and tls_ctx is not None
                 try:
                     server = await asyncio.start_server(
                         self._handle_client,
                         self.bind_address,
                         port,
+                        ssl=tls_ctx if use_tls else None,
                     )
                     self._servers.append(server)
-                    logger.info("Bind server listening on %s:%s", self.bind_address, port)
+                    logger.info(
+                        "Bind server listening on %s:%s (%s)",
+                        self.bind_address,
+                        port,
+                        "TLS" if use_tls else "plain",
+                    )
                 except OSError as e:
                     if e.errno == 98:
                         logger.warning("Bind server port %s already in use, skipping", port)

+ 26 - 0
backend/app/services/virtual_printer/manager.py

@@ -410,6 +410,8 @@ class VirtualPrinterInstance:
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             name=self.name,
             bind_address=bind_addr,
+            cert_path=cert_path,
+            key_path=key_path,
         )
         self._tasks.append(
             asyncio.create_task(
@@ -626,6 +628,30 @@ class VirtualPrinterManager:
                         if printer:
                             proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
 
+        # Detect config changes on running instances and restart if needed
+        for vp in enabled_vps:
+            instance = self._instances.get(vp.id)
+            if not instance:
+                continue
+
+            changed = (
+                instance.mode != vp.mode
+                or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
+                or instance.access_code != (vp.access_code or "")
+                or instance.bind_ip != (vp.bind_ip or "")
+                or instance.remote_interface_ip != (vp.remote_interface_ip or "")
+                or instance.target_printer_id != vp.target_printer_id
+            )
+
+            if changed:
+                logger.info(
+                    "VP %s config changed (mode: %s→%s), restarting",
+                    instance.name,
+                    instance.mode,
+                    vp.mode,
+                )
+                await self.remove_instance(vp.id)
+
         # Start instances for all enabled VPs (skip already running)
         for vp in enabled_vps:
             if vp.id in self._instances:

+ 24 - 12
backend/app/services/virtual_printer/tcp_proxy.py

@@ -346,7 +346,7 @@ class TLSProxy:
 class TCPProxy:
     """Raw TCP proxy that forwards data without TLS termination.
 
-    Used for protocols where the printer doesn't use TLS (e.g., port 3002
+    Used for protocols where the printer doesn't use TLS (e.g., port 3000
     binding/authentication protocol).
     """
 
@@ -1123,18 +1123,30 @@ class SlicerProxyManager:
             bind_address=self.bind_address,
         )
 
-        # Bind/auth proxy (ports 3000 + 3002) - raw TCP, no TLS
-        # Different BambuStudio versions use different ports
+        # Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
         for bind_port in self.PRINTER_BIND_PORTS:
-            proxy = TCPProxy(
-                name="Bind",
-                listen_port=bind_port,
-                target_host=self.target_host,
-                target_port=bind_port,
-                on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-                on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-                bind_address=self.bind_address,
-            )
+            if bind_port == 3002:
+                proxy = TLSProxy(
+                    name="Bind-TLS",
+                    listen_port=bind_port,
+                    target_host=self.target_host,
+                    target_port=bind_port,
+                    server_cert_path=self.cert_path,
+                    server_key_path=self.key_path,
+                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                    bind_address=self.bind_address,
+                )
+            else:
+                proxy = TCPProxy(
+                    name="Bind",
+                    listen_port=bind_port,
+                    target_host=self.target_host,
+                    target_port=bind_port,
+                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                    bind_address=self.bind_address,
+                )
             self._bind_proxies.append(proxy)
 
         # Start as background tasks

+ 78 - 0
backend/app/utils/filament_ids.py

@@ -0,0 +1,78 @@
+"""Utility functions for converting between filament_id and setting_id formats.
+
+Bambu printers use two ID formats for filament presets:
+  - **filament_id** (aka tray_info_idx): e.g. "GFL05", "GFG02", "GFA00"
+    Reported by printer firmware (RFID tags, AMS status).
+  - **setting_id**: e.g. "GFSL05", "GFSG02", "GFSA00"
+    Used by BambuStudio / Bambu Cloud API to resolve presets.
+
+The only difference for official Bambu filaments is an "S" inserted after "GF".
+User presets (starting with "P") use the same ID in both contexts.
+"""
+
+
+def filament_id_to_setting_id(filament_id: str) -> str:
+    """Convert filament_id → setting_id (e.g. "GFL05" → "GFSL05").
+
+    - Already a setting_id ("GFS…") → returned unchanged.
+    - User presets ("P…") → returned unchanged.
+    - Empty / unknown → returned unchanged.
+    """
+    if not filament_id:
+        return filament_id
+
+    # User presets start with "P" - leave unchanged
+    if filament_id.startswith("P"):
+        return filament_id
+
+    # Official Bambu presets: GFx## -> GFSx##
+    if filament_id.startswith("GF") and len(filament_id) >= 4:
+        # Already a setting_id (has S after GF)
+        if filament_id[2] == "S":
+            return filament_id
+        return f"GFS{filament_id[2:]}"
+
+    return filament_id
+
+
+def setting_id_to_filament_id(setting_id: str) -> str:
+    """Convert setting_id → filament_id (e.g. "GFSL05" → "GFL05").
+
+    - Already a filament_id ("GF" without "S") → returned unchanged.
+    - User presets ("P…") → returned unchanged.
+    - Empty / unknown → returned unchanged.
+    """
+    if not setting_id:
+        return setting_id
+
+    # User presets start with "P" - leave unchanged
+    if setting_id.startswith("P"):
+        return setting_id
+
+    # Setting_id format: GFSx## -> GFx##  (remove the "S")
+    if setting_id.startswith("GFS") and len(setting_id) >= 5:
+        return f"GF{setting_id[3:]}"
+
+    return setting_id
+
+
+def normalize_slicer_filament(slicer_filament: str | None) -> tuple[str, str]:
+    """Normalize a slicer_filament value into (tray_info_idx, setting_id).
+
+    The slicer_filament field on a spool can be stored in either format:
+      - filament_id: "GFL05"  (from RFID tag scan)
+      - setting_id:  "GFSL05" or "GFSL05_07"  (from cloud preset picker)
+
+    Returns (tray_info_idx, setting_id) with version suffixes stripped.
+    """
+    raw = slicer_filament or ""
+    if not raw:
+        return ("", "")
+
+    # Strip version suffix (e.g. "GFSL05_07" → "GFSL05")
+    base = raw.split("_")[0] if "_" in raw else raw
+
+    tray_info_idx = setting_id_to_filament_id(base)
+    sid = filament_id_to_setting_id(base)
+
+    return (tray_info_idx, sid)

+ 6 - 2
backend/tests/conftest.py

@@ -50,8 +50,12 @@ def event_loop():
     """Create an instance of the default event loop for each test session."""
     loop = asyncio.get_event_loop_policy().new_event_loop()
     yield loop
-    # Drain pending callbacks so aiosqlite threads can finish before loop closes
-    loop.run_until_complete(asyncio.sleep(0.1))
+    # Dispose the module-level engine so aiosqlite worker threads finish
+    # before the event loop closes, preventing "Event loop is closed" errors.
+    from backend.app.core.database import engine
+
+    loop.run_until_complete(engine.dispose())
+    loop.run_until_complete(asyncio.sleep(0.05))
     loop.close()
 
 

+ 150 - 0
backend/tests/integration/test_archives_api.py

@@ -227,6 +227,156 @@ class TestArchivesAPI:
         assert "successful_prints" in result
 
 
+class TestArchivesSlimAPI:
+    """Integration tests for /api/v1/archives/slim endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_empty(self, async_client: AsyncClient):
+        """Verify empty list when no archives exist."""
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_returns_only_expected_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify response contains only slim fields, not full archive data."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Slim Test",
+            status="completed",
+            filament_type="PLA",
+            filament_color="#FF0000",
+            filament_used_grams=50.0,
+            print_time_seconds=3600,
+            cost=1.50,
+            quantity=2,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        item = data[0]
+
+        # Expected fields present
+        assert item["printer_id"] == printer.id
+        assert item["print_name"] == "Slim Test"
+        assert item["status"] == "completed"
+        assert item["filament_type"] == "PLA"
+        assert item["filament_color"] == "#FF0000"
+        assert item["filament_used_grams"] == 50.0
+        assert item["print_time_seconds"] == 3600
+        assert item["cost"] == 1.50
+        assert item["quantity"] == 2
+        assert "created_at" in item
+
+        # Full archive fields must NOT be present
+        assert "id" not in item
+        assert "filename" not in item
+        assert "file_path" not in item
+        assert "file_size" not in item
+        assert "extra_data" not in item
+        assert "notes" not in item
+        assert "tags" not in item
+        assert "photos" not in item
+        assert "thumbnail_path" not in item
+        assert "content_hash" not in item
+        assert "duplicates" not in item
+        assert "duplicate_count" not in item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_computes_actual_time(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is computed from started_at/completed_at."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
+        completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)  # 2 hours = 7200s
+        await archive_factory(
+            printer.id,
+            status="completed",
+            started_at=started,
+            completed_at=completed,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] == 7200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_actual_time_null_for_failed(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is null for non-completed prints."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="failed",
+            started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify date_from and date_to filters work."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Old Print",
+            created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
+        )
+        await archive_factory(
+            printer.id,
+            print_name="New Print",
+            created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
+        )
+
+        # Filter to only June 2024
+        response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["print_name"] == "New Print"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify limit and offset work."""
+        printer = await printer_factory()
+        for i in range(5):
+            await archive_factory(printer.id, print_name=f"Print {i}")
+
+        response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
+
+        assert response.status_code == 200
+        assert len(response.json()) == 2
+
+
 class TestArchiveDataIntegrity:
     """Tests for archive data integrity."""
 

+ 62 - 0
backend/tests/integration/test_auth_api.py

@@ -180,6 +180,68 @@ class TestAuthMeAPI:
         assert result["role"] == "admin"
         assert result["is_active"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):
+        """Verify /me returns synthetic admin user when using API key via Bearer token."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        # Create an API key directly in the database
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(name="test-kiosk", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
+        db_session.add(api_key)
+        await db_session.commit()
+
+        # Call /me with the API key as Bearer token
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": f"Bearer {full_key}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == 0
+        assert result["username"].startswith("api-key:")
+        assert result["role"] == "admin"
+        assert result["is_admin"] is True
+        assert result["is_active"] is True
+        assert len(result["permissions"]) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):
+        """Verify /me returns synthetic admin user when using X-API-Key header."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(name="test-kiosk-header", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
+        db_session.add(api_key)
+        await db_session.commit()
+
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"X-API-Key": full_key},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == 0
+        assert result["username"].startswith("api-key:")
+        assert result["is_admin"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_invalid_api_key(self, async_client: AsyncClient):
+        """Verify /me rejects invalid API key."""
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": "Bearer bb_invalid_key_value"},
+        )
+
+        assert response.status_code == 401
+
 
 class TestUsersAPI:
     """Integration tests for /api/v1/users/ endpoints."""

+ 22 - 1
backend/tests/integration/test_print_lifecycle.py

@@ -65,15 +65,26 @@ class TestPrintCompleteLogic:
     @pytest.mark.asyncio
     async def test_print_complete_no_import_errors(self, capture_logs):
         """Verify on_print_complete doesn't have import shadowing issues."""
+        # Snapshot tasks before the call so we can cancel orphans afterwards.
+        # on_print_complete fires background tasks (maintenance check, notifications,
+        # smart-plug) via asyncio.create_task.  If those tasks outlive the mock
+        # context they use the *real* async_session and can send real notifications.
+        tasks_before = set(asyncio.all_tasks())
+
         with (
             patch("backend.app.main.async_session") as mock_session_maker,
             patch("backend.app.main.notification_service") as mock_notif,
             patch("backend.app.main.smart_plug_manager") as mock_plug,
             patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.printer_manager") as mock_pm,
         ):
             mock_notif.on_print_complete = AsyncMock()
             mock_plug.on_print_complete = AsyncMock()
             mock_ws.send_print_complete = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+            mock_relay.on_print_complete = AsyncMock()
+            mock_pm.get_printer.return_value = None
 
             # Mock the database session
             mock_session = AsyncMock()
@@ -94,6 +105,16 @@ class TestPrintCompleteLogic:
                 },
             )
 
+            # Cancel background tasks spawned by on_print_complete before
+            # leaving the mock context — prevents them from running with
+            # the real async_session and sending real notifications.
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
         # Verify no import shadowing errors - this would have caught the ArchiveService bug
         errors = [r for r in capture_logs.get_errors() if "cannot access local variable" in str(r.message)]
         assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
@@ -215,7 +236,7 @@ class TestTimelapseTracking:
             {
                 "print": {
                     "gcode_state": "RUNNING",
-                    "hms": [{"attr": 0x07000002, "code": 0x1234}],  # Filament module error
+                    "hms": [{"attr": 0x07000002, "code": 0x8001}],  # Filament module error (code must be >= 0x4000)
                 }
             }
         )

+ 247 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1176,3 +1176,250 @@ class TestTargetLocationFeature:
         assert response.status_code == 200
         result = response.json()
         assert result["target_location"] is None
+
+
+class TestAbortedStatusNormalisation:
+    """Tests for issue #558: 'aborted' queue status causes 500 error."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Abort Test Printer {counter}",
+                "ip_address": f"192.168.1.{60 + counter}",
+                "serial_number": f"TESTABORT{counter:04d}",
+                "access_code": "12345678",
+                "model": "P1S",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"abort_test_{counter}.3mf",
+                "print_name": f"Abort Test Print {counter}",
+                "file_path": f"/tmp/abort_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"aborthash{counter:06d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_on_print_complete_normalises_aborted_to_cancelled(self, queue_item_factory, db_session):
+        """Verify the completion handler maps 'aborted' → 'cancelled' for queue items."""
+        import asyncio
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        item = await queue_item_factory(status="printing")
+
+        # Build a mock session whose execute returns our item
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [item]
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+        mock_session.execute = AsyncMock(return_value=mock_result)
+        mock_session.commit = AsyncMock()
+
+        tasks_before = set(asyncio.all_tasks())
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.notification_service") as mock_notif,
+            patch("backend.app.main.smart_plug_manager") as mock_plug,
+            patch("backend.app.main.printer_manager") as mock_pm,
+        ):
+            mock_ws.send_print_complete = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+            mock_relay.on_print_complete = AsyncMock()
+            mock_relay.on_queue_job_completed = AsyncMock()
+            mock_notif.on_print_complete = AsyncMock()
+            mock_plug.on_print_complete = AsyncMock()
+            mock_pm.get_printer.return_value = None
+
+            from backend.app.main import on_print_complete
+
+            await on_print_complete(
+                item.printer_id,
+                {
+                    "status": "aborted",
+                    "filename": "test.gcode",
+                    "subtask_name": "Test",
+                    "timelapse_was_active": False,
+                },
+            )
+
+            # Cancel background tasks before leaving mock context
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
+        # The item status should be normalised to 'cancelled', not 'aborted'
+        assert item.status == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_startup_fixup_converts_aborted_to_cancelled(self, queue_item_factory, db_session):
+        """Verify the startup fixup converts existing 'aborted' rows to 'cancelled'."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        # Create items with various statuses including 'aborted'
+        item_aborted = await queue_item_factory(status="pending")
+        item_pending = await queue_item_factory(status="pending")
+
+        # Manually set the invalid status
+        item_aborted.status = "aborted"
+        db_session.add(item_aborted)
+        await db_session.commit()
+
+        # Run the fixup query (same logic as lifespan)
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
+        aborted_items = result.scalars().all()
+        for i in aborted_items:
+            i.status = "cancelled"
+        await db_session.commit()
+
+        # Verify: no more 'aborted' items
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
+        assert len(result.scalars().all()) == 0
+
+        # The previously aborted item should now be 'cancelled'
+        await db_session.refresh(item_aborted)
+        assert item_aborted.status == "cancelled"
+
+        # The pending item should be unchanged
+        await db_session.refresh(item_pending)
+        assert item_pending.status == "pending"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_completed_status_passes_through_unchanged(self, queue_item_factory, db_session):
+        """Verify normal statuses like 'completed' are not affected by normalisation."""
+        import asyncio
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        item = await queue_item_factory(status="printing")
+
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [item]
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+        mock_session.execute = AsyncMock(return_value=mock_result)
+        mock_session.commit = AsyncMock()
+
+        tasks_before = set(asyncio.all_tasks())
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.notification_service") as mock_notif,
+            patch("backend.app.main.smart_plug_manager") as mock_plug,
+            patch("backend.app.main.printer_manager") as mock_pm,
+        ):
+            mock_ws.send_print_complete = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+            mock_relay.on_print_complete = AsyncMock()
+            mock_relay.on_queue_job_completed = AsyncMock()
+            mock_notif.on_print_complete = AsyncMock()
+            mock_plug.on_print_complete = AsyncMock()
+            mock_pm.get_printer.return_value = None
+
+            from backend.app.main import on_print_complete
+
+            await on_print_complete(
+                item.printer_id,
+                {
+                    "status": "completed",
+                    "filename": "test.gcode",
+                    "subtask_name": "Test",
+                    "timelapse_was_active": False,
+                },
+            )
+
+            # Cancel background tasks before leaving mock context
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
+        assert item.status == "completed"

+ 21 - 0
backend/tests/integration/test_settings_api.py

@@ -166,6 +166,27 @@ class TestSettingsAPI:
         assert result["ams_temp_good"] == 25.0
         assert result["ams_temp_fair"] == 32.0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_low_stock_threshold(self, async_client: AsyncClient):
+        """Verify low stock threshold setting can be updated."""
+        # Get default value
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 20.0
+
+        # Update to custom value
+        response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5})
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["low_stock_threshold"] == 15.5
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 15.5
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_notification_language(self, async_client: AsyncClient):

+ 743 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -0,0 +1,743 @@
+"""Integration tests for SpoolBuddy API endpoints."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+def device_factory(db_session: AsyncSession):
+    """Factory to create SpoolBuddyDevice records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        n = _counter[0]
+        defaults = {
+            "device_id": f"sb-{n:04d}",
+            "hostname": f"spoolbuddy-{n}",
+            "ip_address": f"10.0.0.{n}",
+            "firmware_version": "1.0.0",
+            "has_nfc": True,
+            "has_scale": True,
+            "tare_offset": 0,
+            "calibration_factor": 1.0,
+            "last_seen": datetime.now(timezone.utc),
+        }
+        defaults.update(kwargs)
+        device = SpoolBuddyDevice(**defaults)
+        db_session.add(device)
+        await db_session.commit()
+        await db_session.refresh(device)
+        return device
+
+    return _create
+
+
+@pytest.fixture
+def spool_factory(db_session: AsyncSession):
+    """Factory to create Spool records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Polymaker",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+# ============================================================================
+# Device endpoints
+# ============================================================================
+
+
+class TestDeviceEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_register_new_device(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-new",
+                    "hostname": "spoolbuddy-new",
+                    "ip_address": "10.0.0.99",
+                    "firmware_version": "1.2.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["device_id"] == "sb-new"
+        assert data["hostname"] == "spoolbuddy-new"
+        assert data["online"] is True
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(
+            device_id="sb-exist",
+            tare_offset=12345,
+            calibration_factor=0.0042,
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-exist",
+                    "hostname": "updated-host",
+                    "ip_address": "10.0.0.200",
+                    "firmware_version": "2.0.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["id"] == device.id
+        assert data["hostname"] == "updated-host"
+        assert data["ip_address"] == "10.0.0.200"
+        assert data["firmware_version"] == "2.0.0"
+        # Calibration preserved on re-register
+        assert data["tare_offset"] == 12345
+        assert data["calibration_factor"] == pytest.approx(0.0042)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices_empty(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-a", hostname="alpha")
+        await device_factory(device_id="sb-b", hostname="beta")
+
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        devices = resp.json()
+        assert len(devices) == 2
+        hostnames = {d["hostname"] for d in devices}
+        assert hostnames == {"alpha", "beta"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-hb")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-hb/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == device.tare_offset
+        assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-cmd", pending_command="tare")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert resp.status_code == 200
+        assert resp.json()["pending_command"] == "tare"
+
+        # Second heartbeat should have no pending command (cleared)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp2 = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+
+        assert resp2.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/nonexistent/heartbeat",
+                json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
+            )
+
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
+        # Create device with last_seen far in the past (offline)
+        await device_factory(
+            device_id="sb-offline",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-offline/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
+            )
+
+        assert resp.status_code == 200
+        # Should broadcast online since device was offline
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-offline"
+
+
+# ============================================================================
+# NFC endpoints
+# ============================================================================
+
+
+class TestNfcEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tag_uid="AABB1122", material="PLA")
+        mock_spool = MagicMock()
+        mock_spool.id = spool.id
+        mock_spool.material = spool.material
+        mock_spool.subtype = spool.subtype
+        mock_spool.color_name = spool.color_name
+        mock_spool.rgba = spool.rgba
+        mock_spool.brand = spool.brand
+        mock_spool.label_weight = spool.label_weight
+        mock_spool.core_weight = spool.core_weight
+        mock_spool.weight_used = spool.weight_used
+
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = mock_spool
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is True
+        assert data["spool_id"] == spool.id
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = None
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is False
+        assert data["spool_id"] is None
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_removed(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-removed",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_removed"
+        assert msg["device_id"] == "sb-1"
+        assert msg["tag_uid"] == "AABB1122"
+
+
+# ============================================================================
+# NFC write-tag endpoints
+# ============================================================================
+
+
+class TestWriteTagEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-wt")
+        spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "queued"
+
+        # Verify heartbeat returns write_tag command with payload
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        hb_data = hb.json()
+        assert hb_data["pending_command"] == "write_tag"
+        assert hb_data["pending_write_payload"] is not None
+        assert hb_data["pending_write_payload"]["spool_id"] == spool.id
+        assert "ndef_data_hex" in hb_data["pending_write_payload"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
+        """write_tag command persists across heartbeats until write-result clears it."""
+        device = await device_factory(device_id="sb-wt-persist")
+        spool = await spool_factory(material="PETG")
+
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        # First heartbeat — command present
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb1 = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+        assert hb1.json()["pending_command"] == "write_tag"
+
+        # Second heartbeat — should still be present (not cleared like tare)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb2 = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+        assert hb2.json()["pending_command"] == "write_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-wt-nospool")
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": 99999},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": "nonexistent", "spool_id": spool.id},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-wr", pending_command="write_tag")
+        spool = await spool_factory(material="PLA", tag_uid=None)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "04AABB11223344",
+                    "success": True,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_written"
+        assert msg["spool_id"] == spool.id
+        assert msg["tag_uid"] == "04AABB11223344"
+
+        # Verify spool got tag linked
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        spool_data = spool_resp.json()
+        assert spool_data["tag_uid"] == "04AABB11223344"
+        assert spool_data["tag_type"] == "ntag"
+        assert spool_data["data_origin"] == "opentag3d"
+        assert spool_data["encode_time"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_failure_broadcasts_error(
+        self, async_client: AsyncClient, device_factory, spool_factory
+    ):
+        device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
+        spool = await spool_factory(material="PLA", tag_uid=None)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "04AABB",
+                    "success": False,
+                    "message": "Write or verification failed",
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_write_failed"
+        assert msg["message"] == "Write or verification failed"
+
+        # Verify spool NOT linked
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        assert spool_resp.json()["tag_uid"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(
+            device_id="sb-wr-clear",
+            pending_command="write_tag",
+            pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
+        )
+        spool = await spool_factory()
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "AABB",
+                    "success": True,
+                },
+            )
+
+        # Heartbeat should have no pending command
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
+            )
+        assert hb.json()["pending_command"] is None
+        assert hb.json()["pending_write_payload"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-cancel")
+        spool = await spool_factory()
+
+        # Queue a write
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        # Cancel it
+        resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
+        assert resp.status_code == 200
+
+        # Heartbeat should have no pending command
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+        assert hb.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
+        """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
+        device = await device_factory(device_id="sb-wt-ndef")
+        spool = await spool_factory(
+            material="PLA",
+            brand="Polymaker",
+            color_name="White",
+            rgba="FFFFFFFF",
+            label_weight=1000,
+        )
+
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        payload = hb.json()["pending_write_payload"]
+        ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
+
+        # CC bytes
+        assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        # TLV type
+        assert ndef_bytes[4] == 0x03
+        # NDEF record: TNF=MIME, type=application/opentag3d
+        assert ndef_bytes[6] == 0xD2
+        assert ndef_bytes[9:30] == b"application/opentag3d"
+        # Terminator
+        assert ndef_bytes[-1] == 0xFE
+        # Total size fits NTAG213
+        assert len(ndef_bytes) <= 144
+
+
+# ============================================================================
+# Scale endpoints
+# ============================================================================
+
+
+class TestScaleEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scale_reading_broadcast(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/scale/reading",
+                json={
+                    "device_id": "sb-1",
+                    "weight_grams": 823.5,
+                    "stable": True,
+                    "raw_adc": 456789,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_weight"
+        assert msg["device_id"] == "sb-1"
+        assert msg["weight_grams"] == 823.5
+        assert msg["stable"] is True
+        assert msg["raw_adc"] == 456789
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 750g
+        # net_filament = max(0, 750 - 250) = 500
+        # weight_used = max(0, 1000 - 500) = 500
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 750},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 500
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 1250g (full spool)
+        # net_filament = max(0, 1250 - 250) = 1000
+        # weight_used = max(0, 1000 - 1000) = 0
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 1250},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
+        """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 750},
+        )
+        assert resp.status_code == 200
+
+        # Fetch the spool via inventory API to verify stored fields
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        assert spool_resp.status_code == 200
+        spool_data = spool_resp.json()
+        assert spool_data["last_scale_weight"] == 750
+        assert spool_data["last_weighed_at"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": 99999, "weight_grams": 500},
+        )
+        assert resp.status_code == 404
+
+
+# ============================================================================
+# Calibration endpoints
+# ============================================================================
+
+
+class TestCalibrationEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-tare")
+
+        resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "ok"
+
+        # Verify pending_command via heartbeat
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/sb-tare/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
+            )
+        assert hb.json()["pending_command"] == "tare"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-st", calibration_factor=0.005)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-st/calibration/set-tare",
+            json={"tare_offset": 54321},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 54321
+        assert data["calibration_factor"] == pytest.approx(0.005)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
+        # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
+        await device_factory(device_id="sb-cf", tare_offset=10000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-cf/calibration/set-factor",
+            json={"known_weight_grams": 200, "raw_adc": 50000},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["calibration_factor"] == pytest.approx(0.005)
+        assert data["tare_offset"] == 10000
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
+        # raw_adc == tare_offset → delta is 0 → 400 error
+        await device_factory(device_id="sb-zero", tare_offset=5000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-zero/calibration/set-factor",
+            json={"known_weight_grams": 100, "raw_adc": 5000},
+        )
+
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_calibration(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-gcal",
+            tare_offset=11111,
+            calibration_factor=0.0042,
+        )
+
+        resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 11111
+        assert data["calibration_factor"] == pytest.approx(0.0042)

+ 29 - 48
backend/tests/unit/services/test_bambu_ftp.py

@@ -28,7 +28,7 @@ from backend.app.services.bambu_ftp import (
 )
 
 # Brief delay to allow pyftpdlib to flush uploaded files to disk.
-# Needed because upload_file() skips voidresp() for A1 compatibility,
+# Needed because upload_file() skips voidresp() for all models,
 # so the server may still be processing the data channel close event.
 _UPLOAD_FLUSH_DELAY = 0.3
 
@@ -306,8 +306,8 @@ class TestUpload:
         result = client.upload_file(local, "/cache/upload.3mf")
         assert result is True
         client.disconnect()
-        # Verify via fresh connection (upload_file skips voidresp()
-        # so the original session can't be reused for download)
+        # Verify via fresh connection (upload_file skips voidresp() for all
+        # models, so the original session can't be reused for download)
         time.sleep(_UPLOAD_FLUSH_DELAY)
         client2 = ftp_client_factory()
         client2.connect()
@@ -403,11 +403,11 @@ class TestUpload:
     def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
         """Large file upload in chunks completes without error.
 
-        Uses 2.5MB to trigger multiple chunks with 1MB CHUNK_SIZE.
-        Content verification skipped because upload_file() doesn't call
-        voidresp() (for A1 compatibility), so the server may still be
-        flushing when we check. The upload result=True confirms the
-        client sent all chunks without error.
+        Uses 2.5MB to trigger multiple chunks with 64KB CHUNK_SIZE.
+        Content verification skipped because upload_file() skips
+        voidresp() for all models, so the server may still be flushing
+        when we check. The upload result=True confirms the client sent
+        all chunks without error.
         """
         content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
         local = tmp_path / "large.bin"
@@ -422,8 +422,8 @@ class TestUpload:
         client.connect()
         result = client.upload_file(local, "/cache/large.bin", on_progress)
         assert result is True
-        # Verify multiple chunks were sent
-        assert len(progress_calls) >= 3  # 2.5MB / 1MB = at least 3 chunks
+        # Verify many chunks were sent (2.5MB / 64KB = 40 chunks)
+        assert len(progress_calls) >= 38
         assert progress_calls[-1][0] == len(content)
         client.disconnect()
 
@@ -871,46 +871,27 @@ class TestFailureScenarios:
         assert result2 == b"data after retry"
         client.disconnect()
 
-    def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
-        """Upload returns True even when voidresp() gets a non-clean response.
+    def test_upload_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
+        """Upload returns True without calling voidresp() for any model.
 
-        Regression: Previously, a voidresp() error after successful data transfer
-        returned False, which caused with_ftp_retry to re-upload the entire file
-        in a loop.
+        voidresp() is skipped for all models: A1 printers hang on it,
+        H2D printers delay the 226 response by 30+ seconds, and X1C/P1S
+        gain nothing from waiting. The file is on the SD card once
+        sendall() returns.
         """
         content = b"voidresp test data"
         local = tmp_path / "voidresp_test.3mf"
         local.write_bytes(content)
-        client = ftp_client_factory(printer_model="X1C")
-        client.connect()
-        result = client.upload_file(local, "/cache/voidresp_test.3mf")
-        assert result is True
-        client.disconnect()
-        # Verify the file is actually on the server
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/voidresp_test.3mf")
-        assert downloaded == content
-        client2.disconnect()
-
-    def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
-        """A1 models skip voidresp() entirely and still return True.
-
-        Regression: A1 printers hang on voidresp() after transfercmd uploads.
-        """
-        content = b"A1 upload test"
-        local = tmp_path / "a1_test.3mf"
-        local.write_bytes(content)
-        client = ftp_client_factory(printer_model="A1")
-        client.connect()
-        result = client.upload_file(local, "/cache/a1_test.3mf")
-        assert result is True
-        client.disconnect()
-        # Verify the file is actually on the server
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/a1_test.3mf")
-        assert downloaded == content
-        client2.disconnect()
+        for model in ("X1C", "A1", "H2D", None):
+            client = ftp_client_factory(printer_model=model)
+            client.connect()
+            result = client.upload_file(local, "/cache/voidresp_test.3mf")
+            assert result is True, f"Upload failed for model={model}"
+            client.disconnect()
+            # Verify the file is actually on the server
+            time.sleep(_UPLOAD_FLUSH_DELAY)
+            client2 = ftp_client_factory()
+            client2.connect()
+            downloaded = client2.download_file("/cache/voidresp_test.3mf")
+            assert downloaded == content, f"Content mismatch for model={model}"
+            client2.disconnect()

+ 188 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -310,6 +310,192 @@ class TestVirtualPrinterManager:
         await manager.stop_all()
         assert len(manager._instances) == 0
 
+    # ========================================================================
+    # Tests for sync_from_db config change detection
+    # ========================================================================
+
+    def _make_db_vp(self, **overrides):
+        """Create a mock VirtualPrinter DB object."""
+        defaults = {
+            "id": 1,
+            "name": "TestVP",
+            "enabled": True,
+            "mode": "immediate",
+            "model": "C11",
+            "access_code": "12345678",
+            "serial_suffix": "391800001",
+            "bind_ip": "",
+            "remote_interface_ip": "",
+            "target_printer_id": None,
+            "position": 0,
+        }
+        defaults.update(overrides)
+        vp = MagicMock()
+        for k, v in defaults.items():
+            setattr(vp, k, v)
+        return vp
+
+    def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
+        """Wire up session_factory mock for sync_from_db."""
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = enabled_vps
+
+        mock_db = AsyncMock()
+        mock_db.execute = AsyncMock(return_value=mock_result)
+        mock_db.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_db.__aexit__ = AsyncMock(return_value=False)
+
+        manager._session_factory = MagicMock(return_value=mock_db)
+        manager._base_dir = tmp_path
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when mode changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        # DB says mode changed to "archive"
+        db_vp = self._make_db_vp(mode="archive")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            # Patch VirtualPrinterInstance to prevent actual start
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when access_code changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(access_code="newcode99")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
+        """Verify sync_from_db does NOT restart when config is identical."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        manager._instances[1] = inst
+
+        # DB matches running config exactly
+        db_vp = self._make_db_vp()
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            await manager.sync_from_db()
+
+            mock_remove.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when bind_ip changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            bind_ip="192.168.1.10",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(bind_ip="192.168.1.20")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when model changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(model="C12")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
 
 class TestFTPSession:
     """Tests for FTP session handling."""
@@ -1219,4 +1405,6 @@ class TestBindServer:
                 model="3DPrinter-X1-Carbon",
                 name="Bambuddy",
                 bind_address="192.168.1.50",
+                cert_path=Path("/tmp/cert.pem"),  # nosec B108
+                key_path=Path("/tmp/key.pem"),  # nosec B108
             )

+ 336 - 0
backend/tests/unit/test_bug_report.py

@@ -0,0 +1,336 @@
+"""Unit tests for bug report service and route."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestBugReportService:
+    """Tests for bug_report.submit_report()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_success(self):
+        """Successful relay call saves report and returns issue details."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "success": True,
+            "message": "Created",
+            "issue_url": "https://github.com/maziggy/bambuddy/issues/99",
+            "issue_number": 99,
+        }
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test bug",
+                reporter_email="user@test.com",
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is True
+        assert result["issue_number"] == 99
+        assert result["issue_url"] == "https://github.com/maziggy/bambuddy/issues/99"
+        mock_db.add.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_rate_limited(self):
+        """Returns failure when rate limit exceeded."""
+        import time
+
+        from backend.app.services.bug_report import submit_report
+
+        timestamps = [time.time()] * 5  # Already at limit
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "Rate limit" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_no_relay_url(self):
+        """Returns failure when relay URL is not configured."""
+        from backend.app.services.bug_report import submit_report
+
+        with (
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", ""),
+        ):
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "not configured" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_http_error(self):
+        """Non-200 relay response saves failed report."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 500
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "not available" in result["message"]
+        mock_db.add.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_connection_error(self):
+        """Connection failure saves failed report."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(side_effect=ConnectionError("Connection refused"))
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "Failed to submit" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_failure_response(self):
+        """Relay returns success=false in JSON body."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "success": False,
+            "message": "GitHub API error",
+        }
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "GitHub API error" in result["message"]
+
+
+class TestCollectDebugLogs:
+    """Tests for _collect_debug_logs()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_enables_debug_when_not_already_enabled(self):
+        """Debug logging is enabled, then restored after collection."""
+        from backend.app.api.routes.bug_report import _collect_debug_logs
+
+        apply_calls = []
+
+        mock_db = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(False, None)),
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch(
+                "backend.app.api.routes.bug_report._apply_log_level",
+                side_effect=lambda v: apply_calls.append(v),
+            ),
+            patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
+            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
+        ):
+            mock_pm._clients = {}
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await _collect_debug_logs()
+
+        assert result == "DEBUG log line"
+        assert apply_calls == [True, False]  # enabled then restored
+        assert mock_set.call_count == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_skips_enable_when_already_debug(self):
+        """Debug logging not toggled when already enabled."""
+        from backend.app.api.routes.bug_report import _collect_debug_logs
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
+            patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
+            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
+        ):
+            mock_pm._clients = {}
+            mock_db = AsyncMock()
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await _collect_debug_logs()
+
+        assert result == "logs"
+        mock_apply.assert_not_called()
+        mock_set.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_pushes_all_connected_printers(self):
+        """Sends status update request to all connected printers."""
+        from backend.app.api.routes.bug_report import _collect_debug_logs
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report._apply_log_level"),
+            patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value=""),
+            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
+        ):
+            mock_pm._clients = {"printer1": MagicMock(), "printer2": MagicMock()}
+            mock_db = AsyncMock()
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            await _collect_debug_logs()
+
+        assert mock_pm.request_status_update.call_count == 2
+
+
+class TestRateLimit:
+    """Tests for rate limiting in bug report service."""
+
+    def test_check_rate_limit_allows_first(self):
+        """First request within window is allowed."""
+        from backend.app.services.bug_report import _check_rate_limit
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", []):
+            assert _check_rate_limit() is True
+
+    def test_check_rate_limit_blocks_at_max(self):
+        """Requests at max limit are blocked."""
+        import time
+
+        from backend.app.services.bug_report import _check_rate_limit
+
+        now = time.time()
+        timestamps = [now] * 5
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            assert _check_rate_limit() is False
+
+    def test_check_rate_limit_clears_old(self):
+        """Old timestamps outside window are cleared."""
+        import time
+
+        from backend.app.services.bug_report import _check_rate_limit
+
+        old_time = time.time() - 7200  # 2 hours ago
+        timestamps = [old_time] * 5
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            assert _check_rate_limit() is True

+ 142 - 0
backend/tests/unit/test_opentag3d.py

@@ -0,0 +1,142 @@
+"""Unit tests for OpenTag3D NDEF encoder."""
+
+import struct
+from unittest.mock import MagicMock
+
+from backend.app.services.opentag3d import (
+    OPENTAG3D_MIME_TYPE,
+    PAYLOAD_SIZE,
+    _build_payload,
+    encode_opentag3d,
+)
+
+
+def _make_spool(**kwargs):
+    """Create a mock Spool with default values."""
+    defaults = {
+        "material": "PLA",
+        "subtype": "Matte",
+        "brand": "Polymaker",
+        "color_name": "Jade White",
+        "rgba": "00AE42FF",
+        "label_weight": 1000,
+        "nozzle_temp_min": 220,
+    }
+    defaults.update(kwargs)
+    spool = MagicMock()
+    for k, v in defaults.items():
+        setattr(spool, k, v)
+    return spool
+
+
+class TestBuildPayload:
+    def test_payload_is_102_bytes(self):
+        spool = _make_spool()
+        payload = _build_payload(spool)
+        assert len(payload) == PAYLOAD_SIZE
+
+    def test_tag_version(self):
+        payload = _build_payload(_make_spool())
+        version = struct.unpack_from(">H", payload, 0x00)[0]
+        assert version == 1000
+
+    def test_material_field(self):
+        payload = _build_payload(_make_spool(material="PETG"))
+        material = payload[0x02:0x07].decode("utf-8")
+        assert material == "PETG "
+
+    def test_material_truncated(self):
+        payload = _build_payload(_make_spool(material="SUPERLONG"))
+        material = payload[0x02:0x07].decode("utf-8")
+        assert material == "SUPER"
+
+    def test_modifiers_field(self):
+        payload = _build_payload(_make_spool(subtype="Silk"))
+        mods = payload[0x07:0x0C].decode("utf-8")
+        assert mods == "Silk "
+
+    def test_modifiers_none(self):
+        payload = _build_payload(_make_spool(subtype=None))
+        mods = payload[0x07:0x0C].decode("utf-8")
+        assert mods == "     "
+
+    def test_reserved_is_zero(self):
+        payload = _build_payload(_make_spool())
+        assert payload[0x0C:0x1B] == b"\x00" * 15
+
+    def test_brand_field(self):
+        payload = _build_payload(_make_spool(brand="Polymaker"))
+        brand = payload[0x1B:0x2B].decode("utf-8")
+        assert brand == "Polymaker       "
+
+    def test_color_name_field(self):
+        payload = _build_payload(_make_spool(color_name="Jade White"))
+        cn = payload[0x2B:0x4B].decode("utf-8")
+        assert cn.startswith("Jade White")
+        assert len(cn) == 32
+
+    def test_rgba_field(self):
+        payload = _build_payload(_make_spool(rgba="FF0000FF"))
+        assert payload[0x4B:0x4F] == bytes([0xFF, 0x00, 0x00, 0xFF])
+
+    def test_rgba_none(self):
+        payload = _build_payload(_make_spool(rgba=None))
+        assert payload[0x4B:0x4F] == b"\x00\x00\x00\x00"
+
+    def test_target_diameter(self):
+        payload = _build_payload(_make_spool())
+        diameter = struct.unpack_from(">H", payload, 0x5C)[0]
+        assert diameter == 1750
+
+    def test_target_weight(self):
+        payload = _build_payload(_make_spool(label_weight=750))
+        weight = struct.unpack_from(">H", payload, 0x5E)[0]
+        assert weight == 750
+
+    def test_print_temp(self):
+        payload = _build_payload(_make_spool(nozzle_temp_min=220))
+        assert payload[0x60] == 44  # 220 / 5
+
+    def test_print_temp_none(self):
+        payload = _build_payload(_make_spool(nozzle_temp_min=None))
+        assert payload[0x60] == 0
+
+
+class TestEncodeOpentag3d:
+    def test_starts_with_cc(self):
+        data = encode_opentag3d(_make_spool())
+        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+
+    def test_tlv_header(self):
+        data = encode_opentag3d(_make_spool())
+        # TLV type = 0x03
+        assert data[4] == 0x03
+        # TLV length = 3 (record header) + 21 (mime type) + 102 (payload) = 126
+        assert data[5] == 126
+
+    def test_ndef_record_header(self):
+        data = encode_opentag3d(_make_spool())
+        # Record starts after CC(4) + TLV(2) = offset 6
+        assert data[6] == 0xD2  # MB|ME|SR + TNF=MIME
+        assert data[7] == len(OPENTAG3D_MIME_TYPE)  # type length = 21
+        assert data[8] == PAYLOAD_SIZE  # payload length = 102
+
+    def test_mime_type(self):
+        data = encode_opentag3d(_make_spool())
+        mime = data[9:30]
+        assert mime == b"application/opentag3d"
+
+    def test_ends_with_terminator(self):
+        data = encode_opentag3d(_make_spool())
+        assert data[-1] == 0xFE
+
+    def test_total_size(self):
+        data = encode_opentag3d(_make_spool())
+        # CC(4) + TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 133
+        assert len(data) == 133
+
+    def test_fits_ntag213(self):
+        """NTAG213 has 36 writable pages (144 bytes). Our data must fit."""
+        data = encode_opentag3d(_make_spool())
+        ntag213_capacity = 36 * 4  # 144 bytes
+        assert len(data) <= ntag213_capacity

+ 8 - 0
backend/tests/unit/test_orca_profiles.py

@@ -194,6 +194,14 @@ class TestParseMaterialFromName:
         assert _parse_material_from_name("Fiberlogy PA12+CF15") is None
         assert _parse_material_from_name("Fiberlogy PA @BBL X1C") == "PA"
 
+    def test_support_for_pattern(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        # "PLA Support for PETG" — filament type is PETG, not PLA
+        assert _parse_material_from_name("PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle") == "PETG"
+        assert _parse_material_from_name("PLA Support for ABS @BBL X1C") == "ABS"
+        assert _parse_material_from_name("PVA Support for PLA @BBL X1C") == "PLA"
+
 
 class TestParseVendorFromName:
     """Tests for _parse_vendor_from_name()."""

+ 4 - 2
backend/tests/unit/test_support_helpers.py

@@ -373,6 +373,7 @@ class TestCollectSupportInfo:
         with (
             patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
             patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824),
+            patch("backend.app.api.routes.support._detect_docker_network_mode", return_value="bridge"),
             patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
             patch("backend.app.api.routes.support.printer_manager") as mock_pm,
             patch(
@@ -529,10 +530,11 @@ class TestCollectSupportInfo:
 
         assert info["network"]["interface_count"] == 2
         assert info["network"]["interfaces"][0]["name"] == "eth0"
-        assert info["network"]["interfaces"][0]["subnet"] == "192.168.1.0/24"
-        # Verify IP addresses are NOT included
+        assert info["network"]["interfaces"][0]["subnet"] == "x.x.1.0/24"
+        # Verify IP addresses are NOT included (first two octets masked)
         for iface in info["network"]["interfaces"]:
             assert "ip" not in iface
+            assert iface["subnet"].startswith("x.x.")
 
     @pytest.mark.asyncio
     @pytest.mark.unit

+ 392 - 0
docker-publish-daily-beta.sh

@@ -0,0 +1,392 @@
+#!/bin/bash
+# Daily beta build: build Docker image, push to registries, create/update GitHub prerelease
+#
+# Usage:
+#   ./docker-publish-daily-beta.sh [--parallel] [--ghcr-only] [--dockerhub-only] [--skip-release]
+#
+# Examples:
+#   ./docker-publish-daily-beta.sh                  # Full daily beta workflow
+#   ./docker-publish-daily-beta.sh --parallel       # Build both archs simultaneously
+#   ./docker-publish-daily-beta.sh --ghcr-only      # Only push to GHCR
+#   ./docker-publish-daily-beta.sh --dockerhub-only # Only push to Docker Hub
+#   ./docker-publish-daily-beta.sh --skip-release   # Build+push without GitHub release
+#
+# Reads APP_VERSION from backend/app/core/config.py (must be a beta version like 0.2.2b1).
+# Builds and pushes a multi-arch Docker image tagged with that version, overwriting any
+# previous image with the same tag. Optionally creates/updates a GitHub prerelease.
+#
+# Beta versions are never tagged as 'latest'. Users update by pulling the same tag
+# (e.g., docker pull ghcr.io/maziggy/bambuddy:0.2.2b1) or using Watchtower.
+#
+# Prerequisites:
+#   1. Log in to ghcr.io:
+#      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
+#
+#   2. Log in to Docker Hub:
+#      docker login -u YOUR_USERNAME
+#
+#   3. GitHub CLI (gh) authenticated for creating releases
+#
+# Supported architectures:
+#   - linux/amd64 (x86_64, most servers/desktops)
+#   - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)
+
+set -e
+
+# Configuration
+GHCR_REGISTRY="ghcr.io"
+DOCKERHUB_REGISTRY="docker.io"
+IMAGE_NAME="maziggy/bambuddy"
+GHCR_IMAGE="${GHCR_REGISTRY}/${IMAGE_NAME}"
+DOCKERHUB_IMAGE="${DOCKERHUB_REGISTRY}/${IMAGE_NAME}"
+PLATFORMS="linux/amd64,linux/arm64"
+BUILDER_NAME="bambuddy-builder"
+CONFIG_FILE="backend/app/core/config.py"
+CHANGELOG_FILE="CHANGELOG.md"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Parse arguments
+PARALLEL=false
+PUSH_GHCR=true
+PUSH_DOCKERHUB=true
+SKIP_RELEASE=false
+for arg in "$@"; do
+    case $arg in
+        --parallel)
+            PARALLEL=true
+            ;;
+        --ghcr-only)
+            PUSH_DOCKERHUB=false
+            ;;
+        --dockerhub-only)
+            PUSH_GHCR=false
+            ;;
+        --skip-release)
+            SKIP_RELEASE=true
+            ;;
+        --help|-h)
+            echo "Usage: $0 [--parallel] [--ghcr-only] [--dockerhub-only] [--skip-release]"
+            echo ""
+            echo "Build and publish a daily beta Docker image using the APP_VERSION from config.py."
+            echo ""
+            echo "Options:"
+            echo "  --parallel       Build both architectures simultaneously"
+            echo "  --ghcr-only      Only push to GitHub Container Registry"
+            echo "  --dockerhub-only Only push to Docker Hub"
+            echo "  --skip-release   Build+push without creating/updating GitHub release"
+            echo "  --help, -h       Show this help"
+            exit 0
+            ;;
+        *)
+            echo -e "${RED}Unknown argument: $arg${NC}"
+            echo "Run $0 --help for usage"
+            exit 1
+            ;;
+    esac
+done
+
+# ============================================================
+# Step 1: Read and validate APP_VERSION
+# ============================================================
+echo -e "${BLUE}[1/4] Validating APP_VERSION...${NC}"
+
+VERSION=$(grep -oP 'APP_VERSION = "\K[^"]+' "$CONFIG_FILE")
+
+if [ -z "$VERSION" ]; then
+    echo -e "${RED}Error: Could not read APP_VERSION from ${CONFIG_FILE}${NC}"
+    exit 1
+fi
+
+if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then
+    echo -e "${RED}Error: APP_VERSION '${VERSION}' is not a beta version (expected X.Y.Zb<N>)${NC}"
+    exit 1
+fi
+
+echo -e "${GREEN}  APP_VERSION: ${VERSION}${NC}"
+
+# ============================================================
+# Step 2: Build & push Docker images
+# ============================================================
+echo ""
+
+# Get CPU count
+CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
+
+echo -e "${GREEN}================================================${NC}"
+echo -e "${GREEN}  Daily beta build${NC}"
+echo -e "${GREEN}  Version: ${VERSION}${NC}"
+echo -e "${GREEN}  Platforms: ${PLATFORMS}${NC}"
+echo -e "${GREEN}  CPU cores: ${CPU_COUNT}${NC}"
+if [ "$PARALLEL" = true ]; then
+    echo -e "${GREEN}  Mode: PARALLEL (both archs simultaneously)${NC}"
+else
+    echo -e "${GREEN}  Mode: Sequential (amd64 → arm64)${NC}"
+fi
+echo -e "${GREEN}  Registries:${NC}"
+if [ "$PUSH_GHCR" = true ]; then
+    echo -e "${GREEN}    - ${GHCR_IMAGE}${NC}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo -e "${GREEN}    - ${DOCKERHUB_IMAGE}${NC}"
+fi
+echo -e "${GREEN}================================================${NC}"
+echo ""
+
+# Check registry logins
+if [ "$PUSH_GHCR" = true ]; then
+    if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
+        echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
+        echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
+        echo ""
+    fi
+fi
+
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    if ! grep -q "index.docker.io\|docker.io" ~/.docker/config.json 2>/dev/null; then
+        echo -e "${RED}Error: You are not logged in to Docker Hub${NC}"
+        echo "Run: docker login -u YOUR_USERNAME"
+        echo ""
+        exit 1
+    fi
+fi
+
+# Setup buildx builder if not exists
+echo -e "${BLUE}[2/4] Setting up Docker Buildx and building...${NC}"
+if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
+    echo "Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)"
+    docker buildx create \
+        --name "$BUILDER_NAME" \
+        --driver docker-container \
+        --driver-opt network=host \
+        --driver-opt "env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000" \
+        --buildkitd-flags "--allow-insecure-entitlement network.host --oci-worker-gc=false" \
+        --config /dev/stdin <<EOF
+[worker.oci]
+  max-parallelism = ${CPU_COUNT}
+EOF
+    docker buildx inspect --bootstrap "$BUILDER_NAME"
+fi
+docker buildx use "$BUILDER_NAME"
+
+# Verify builder supports multi-platform
+if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
+    echo -e "${YELLOW}Installing QEMU for cross-platform builds...${NC}"
+    docker run --privileged --rm tonistiigi/binfmt --install all
+fi
+
+# Beta versions never get 'latest' tag
+echo -e "${YELLOW}Beta version — skipping 'latest' tag${NC}"
+
+# Build tags for all target registries
+TAGS=""
+if [ "$PUSH_GHCR" = true ]; then
+    TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION}"
+fi
+
+# Common build args (no cache to ensure clean builds)
+BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
+
+if [ "$PARALLEL" = true ]; then
+    # Parallel build: Build each architecture separately then combine manifests
+    echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}"
+
+    # Build per-arch staging tags for each target registry
+    ARCH_TAGS_AMD64=""
+    ARCH_TAGS_ARM64=""
+    if [ "$PUSH_GHCR" = true ]; then
+        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:${VERSION}-amd64"
+        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:${VERSION}-arm64"
+    fi
+    if [ "$PUSH_DOCKERHUB" = true ]; then
+        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:${VERSION}-amd64"
+        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:${VERSION}-arm64"
+    fi
+
+    # Build amd64 in background
+    (
+        echo -e "${BLUE}[amd64] Starting build...${NC}"
+        docker buildx build \
+            --platform linux/amd64 \
+            ${ARCH_TAGS_AMD64} \
+            ${BUILD_ARGS} \
+            --push \
+            . 2>&1 | sed 's/^/[amd64] /'
+        echo -e "${GREEN}[amd64] Complete!${NC}"
+    ) &
+    PID_AMD64=$!
+
+    # Build arm64 in background
+    (
+        echo -e "${BLUE}[arm64] Starting build...${NC}"
+        docker buildx build \
+            --platform linux/arm64 \
+            ${ARCH_TAGS_ARM64} \
+            ${BUILD_ARGS} \
+            --push \
+            . 2>&1 | sed 's/^/[arm64] /'
+        echo -e "${GREEN}[arm64] Complete!${NC}"
+    ) &
+    PID_ARM64=$!
+
+    # Wait for both builds
+    echo "Waiting for parallel builds to complete..."
+    wait $PID_AMD64
+    wait $PID_ARM64
+
+    # Create multi-arch manifests per registry (no cross-registry blob copies)
+    echo -e "${BLUE}Creating multi-arch manifests...${NC}"
+
+    if [ "$PUSH_GHCR" = true ]; then
+        echo -e "${BLUE}  Creating GHCR manifest...${NC}"
+        docker buildx imagetools create \
+            -t "${GHCR_IMAGE}:${VERSION}" \
+            "${GHCR_IMAGE}:${VERSION}-amd64" \
+            "${GHCR_IMAGE}:${VERSION}-arm64"
+    fi
+    if [ "$PUSH_DOCKERHUB" = true ]; then
+        echo -e "${BLUE}  Creating Docker Hub manifest...${NC}"
+        docker buildx imagetools create \
+            -t "${DOCKERHUB_IMAGE}:${VERSION}" \
+            "${DOCKERHUB_IMAGE}:${VERSION}-amd64" \
+            "${DOCKERHUB_IMAGE}:${VERSION}-arm64"
+    fi
+else
+    # Sequential build (default): Build both platforms in one command
+    echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
+    DOCKER_BUILDKIT=1 docker buildx build \
+        --platform "$PLATFORMS" \
+        ${BUILD_ARGS} \
+        $TAGS \
+        --push \
+        .
+fi
+
+# ============================================================
+# Step 3: Create/update GitHub release
+# ============================================================
+if [ "$SKIP_RELEASE" = true ]; then
+    echo -e "${YELLOW}[3/4] Skipping GitHub release (--skip-release)${NC}"
+else
+    echo -e "${BLUE}[3/4] Creating/updating GitHub release...${NC}"
+
+    # Extract release notes from CHANGELOG: content between ## [<version>] and the next ## [ heading
+    CHANGELOG_NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[/!p}" "$CHANGELOG_FILE" | sed '/^$/d; 1{/^$/d}')
+
+    if [ -z "$CHANGELOG_NOTES" ]; then
+        echo -e "${YELLOW}  Warning: No changelog notes found for ${VERSION}${NC}"
+        CHANGELOG_NOTES="No changelog notes available for this release."
+    fi
+
+    # Build pull commands for the release body
+    PULL_COMMANDS=""
+    if [ "$PUSH_GHCR" = true ]; then
+        PULL_COMMANDS="docker pull ghcr.io/maziggy/bambuddy:${VERSION}"
+    fi
+    if [ "$PUSH_DOCKERHUB" = true ]; then
+        if [ -n "$PULL_COMMANDS" ]; then
+            PULL_COMMANDS="${PULL_COMMANDS}
+# or
+docker pull maziggy/bambuddy:${VERSION}"
+        else
+            PULL_COMMANDS="docker pull maziggy/bambuddy:${VERSION}"
+        fi
+    fi
+
+    # Create the release body
+    TODAY=$(date +%Y-%m-%d)
+    RELEASE_BODY=$(cat <<EOF
+> [!NOTE]
+> This is a **daily beta build** (${TODAY}). It contains the latest fixes and improvements but may have undiscovered issues.
+>
+> **Docker users:** Update by pulling the new image:
+> \`\`\`
+> ${PULL_COMMANDS}
+> \`\`\`
+>
+> **Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
+
+---
+
+${CHANGELOG_NOTES}
+EOF
+    )
+
+    # Delete existing release so the new one gets today's date
+    # (gh release edit only updates title/notes, not the creation timestamp)
+    if gh release view "v${VERSION}" >/dev/null 2>&1; then
+        echo "  Deleting old release v${VERSION} (will recreate with today's date)..."
+        gh release delete "v${VERSION}" --yes --cleanup-tag
+    fi
+
+    # Create/move tag to current HEAD and push
+    echo "  Tagging current HEAD as v${VERSION}..."
+    git tag -f "v${VERSION}"
+    git push origin "v${VERSION}" --force
+
+    echo "  Creating release v${VERSION}..."
+    gh release create "v${VERSION}" \
+        --title "Daily Beta Build v${VERSION} (${TODAY})" \
+        --prerelease \
+        --notes "$RELEASE_BODY"
+    echo -e "${GREEN}  Created GitHub release: v${VERSION}${NC}"
+fi
+
+# ============================================================
+# Step 4: Verify
+# ============================================================
+echo -e "${BLUE}[4/4] Verifying...${NC}"
+
+if [ "$PUSH_GHCR" = true ]; then
+    echo -e "${BLUE}GHCR manifest:${NC}"
+    docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo -e "${BLUE}Docker Hub manifest:${NC}"
+    docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}"
+fi
+
+if [ "$SKIP_RELEASE" != true ]; then
+    echo ""
+    echo -e "${BLUE}GitHub release:${NC}"
+    gh release view "v${VERSION}"
+fi
+
+# ============================================================
+# Summary
+# ============================================================
+echo ""
+echo -e "${GREEN}================================================${NC}"
+echo -e "${GREEN}  Daily beta build complete!${NC}"
+echo -e "${GREEN}  Version: ${VERSION}${NC}"
+echo -e "${GREEN}================================================${NC}"
+if [ "$PUSH_GHCR" = true ]; then
+    echo "  GHCR:       ${GHCR_IMAGE}:${VERSION}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo "  Docker Hub: ${DOCKERHUB_IMAGE}:${VERSION}"
+fi
+if [ "$SKIP_RELEASE" != true ]; then
+    echo "  Release:    https://github.com/${IMAGE_NAME}/releases/tag/v${VERSION}"
+fi
+echo ""
+echo -e "${BLUE}Supported platforms:${NC}"
+echo "  - linux/amd64 (Intel/AMD servers, desktops)"
+echo "  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
+echo ""
+echo -e "${GREEN}Users can now run:${NC}"
+if [ "$PUSH_GHCR" = true ]; then
+    echo "  docker pull ${GHCR_IMAGE}:${VERSION}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo "  docker pull ${DOCKERHUB_IMAGE}:${VERSION}"
+    echo "  docker pull ${IMAGE_NAME}:${VERSION}  # shorthand"
+fi

+ 10 - 0
frontend/package-lock.json

@@ -32,6 +32,7 @@
         "react-dom": "^19.2.0",
         "react-i18next": "^16.3.5",
         "react-router-dom": "^7.12.0",
+        "react-simple-keyboard": "^3.8.164",
         "recharts": "^3.5.1",
         "three": "^0.181.2"
       },
@@ -6856,6 +6857,15 @@
         "react-dom": ">=18"
       }
     },
+    "node_modules/react-simple-keyboard": {
+      "version": "3.8.164",
+      "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.164.tgz",
+      "integrity": "sha512-VwmLyclUizzkpRy/2DeLZRtzjR3K6MLWDVV98492DC5a0ZUHt9JK0R27ZXbcn51OA80U84XTZsUZlU8iYXkgxQ==",
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",

+ 1 - 0
frontend/package.json

@@ -38,6 +38,7 @@
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
     "react-router-dom": "^7.12.0",
+    "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
     "three": "^0.181.2"
   },

BIN
frontend/public/img/spoolbuddy_logo_dark.png


BIN
frontend/public/img/spoolbuddy_logo_dark_small.png


BIN
frontend/public/spoolbuddy_logo_dark.png


+ 4 - 3
frontend/src/App.tsx

@@ -26,9 +26,9 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
 import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
-import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
-
+import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
+import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
@@ -123,8 +123,9 @@ function App() {
                 <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
-                  <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
+                  <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+                  <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                 </Route>
 
                 {/* Main app with WebSocket for real-time updates */}

+ 177 - 0
frontend/src/__tests__/components/BugReportBubble.test.tsx

@@ -0,0 +1,177 @@
+/**
+ * Tests for the BugReportBubble component.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render, screen, waitFor } from '../utils';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { BugReportBubble } from '../../components/BugReportBubble';
+
+function getDescriptionTextarea() {
+  return document.querySelector('textarea') as HTMLTextAreaElement;
+}
+
+function getSubmitButton() {
+  const buttons = screen.getAllByRole('button');
+  return buttons.find(
+    (b) =>
+      b.className.includes('bg-red-500') &&
+      !b.className.includes('rounded-full') &&
+      b.textContent !== ''
+  );
+}
+
+describe('BugReportBubble', () => {
+  it('renders the floating bug button', () => {
+    render(<BugReportBubble />);
+
+    const button = screen.getByRole('button');
+    expect(button).toBeInTheDocument();
+  });
+
+  it('opens panel when bubble is clicked', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    expect(getDescriptionTextarea()).toBeInTheDocument();
+  });
+
+  it('closes panel when X button is clicked', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+
+    // Open
+    await user.click(screen.getByRole('button'));
+    expect(getDescriptionTextarea()).toBeInTheDocument();
+
+    // Close via the X button
+    const buttons = screen.getAllByRole('button');
+    const closeButton = buttons.find((b) => b.querySelector('.lucide-x'));
+    if (closeButton) await user.click(closeButton);
+
+    await waitFor(() => {
+      expect(document.querySelector('textarea')).not.toBeInTheDocument();
+    });
+  });
+
+  it('disables submit when description is empty', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    expect(getSubmitButton()).toBeDisabled();
+  });
+
+  it('enables submit when description is provided', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Something is broken');
+
+    expect(getSubmitButton()).not.toBeDisabled();
+  });
+
+  it('shows collecting state with countdown after submit', async () => {
+    const user = userEvent.setup();
+
+    // Delay the API response so we can see collecting state
+    server.use(
+      http.post('*/bug-report/submit', async () => {
+        await new Promise((resolve) => setTimeout(resolve, 60000));
+        return HttpResponse.json({ success: true, message: 'ok', issue_url: null, issue_number: null });
+      })
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Test bug report');
+
+    const submitBtn = getSubmitButton();
+    if (submitBtn) await user.click(submitBtn);
+
+    // Should show collecting state
+    await waitFor(() => {
+      const collectingText = screen.queryByText(/collecting|Collecting|収集|Sammeln|Collecte|Raccolta|Coletando|收集/i);
+      expect(collectingText).toBeInTheDocument();
+    });
+  });
+
+  it('shows success state after successful submission', async () => {
+    const user = userEvent.setup();
+
+    server.use(
+      http.post('*/bug-report/submit', () => {
+        return HttpResponse.json({
+          success: true,
+          message: 'Bug report submitted successfully!',
+          issue_url: 'https://github.com/maziggy/bambuddy/issues/42',
+          issue_number: 42,
+        });
+      })
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Test bug');
+
+    const submitBtn = getSubmitButton();
+    if (submitBtn) await user.click(submitBtn);
+
+    await waitFor(
+      () => {
+        expect(screen.getByText(/#42/)).toBeInTheDocument();
+      },
+      { timeout: 35000 }
+    );
+  });
+
+  it('shows error state after failed submission', async () => {
+    const user = userEvent.setup();
+
+    server.use(
+      http.post('*/bug-report/submit', () => {
+        return HttpResponse.json({
+          success: false,
+          message: 'Relay not available',
+          issue_url: null,
+          issue_number: null,
+        });
+      })
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Test bug');
+
+    const submitBtn = getSubmitButton();
+    if (submitBtn) await user.click(submitBtn);
+
+    await waitFor(
+      () => {
+        expect(screen.getByText(/Relay not available/)).toBeInTheDocument();
+      },
+      { timeout: 35000 }
+    );
+  });
+
+  it('has expandable data collection notice', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    const details = document.querySelector('details');
+    expect(details).toBeInTheDocument();
+  });
+});

+ 654 - 0
frontend/src/__tests__/components/FileUploadModal.test.tsx

@@ -0,0 +1,654 @@
+/**
+ * Tests for the FileUploadModal component.
+ * Tests file upload, drag-and-drop, ZIP/3MF/STL detection, and autoUpload mode.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FileUploadModal } from '../../components/FileUploadModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('FileUploadModal', () => {
+  const defaultProps = {
+    folderId: null as number | null,
+    onClose: vi.fn(),
+    onUploadComplete: vi.fn(),
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    server.use(
+      http.post('/api/v1/library/files', () => {
+        return HttpResponse.json({
+          id: 1,
+          filename: 'test.gcode.3mf',
+          file_type: '3mf',
+          file_size: 1048576,
+          thumbnail_path: null,
+          duplicate_of: null,
+          metadata: null,
+        });
+      }),
+      http.post('/api/v1/library/extract-zip', () => {
+        return HttpResponse.json({
+          extracted: 3,
+          errors: [],
+        });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal with title', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText('Upload Files')).toBeInTheDocument();
+    });
+
+    it('renders drag and drop zone', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
+    });
+
+    it('renders click to browse text', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
+    });
+
+    it('renders Cancel button', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+
+    it('renders Upload button disabled when no files', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).toBeDisabled();
+    });
+
+    it('shows all file types supported text', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/All file types supported/i)).toBeInTheDocument();
+    });
+  });
+
+  describe('file selection', () => {
+    it('shows added file in the list', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.gcode.3mf')).toBeInTheDocument();
+    });
+
+    it('shows file size in MB', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['x'.repeat(1048576)], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('1.00 MB')).toBeInTheDocument();
+    });
+
+    it('enables Upload button when files are added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      expect(uploadButton).not.toBeDisabled();
+    });
+
+    it('shows file count in Upload button', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const files = [
+        new File(['a'], 'file1.3mf', { type: 'application/octet-stream' }),
+        new File(['b'], 'file2.stl', { type: 'application/octet-stream' }),
+      ];
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, files);
+
+      expect(screen.getByRole('button', { name: /Upload \(2\)/i })).toBeInTheDocument();
+    });
+
+    it('accepts any file type (not restricted like UploadModal)', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'readme.txt', { type: 'text/plain' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('readme.txt')).toBeInTheDocument();
+    });
+  });
+
+  describe('file removal', () => {
+    it('removes a file when X button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.3mf')).toBeInTheDocument();
+
+      const fileRow = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileRow?.querySelector('button');
+      if (removeButton) {
+        await user.click(removeButton);
+      }
+
+      await waitFor(() => {
+        expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+      });
+    });
+
+    it('disables Upload button after removing all files', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const fileRow = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileRow?.querySelector('button');
+      if (removeButton) {
+        await user.click(removeButton);
+      }
+
+      await waitFor(() => {
+        const uploadButton = screen.getByRole('button', { name: /Upload/i });
+        expect(uploadButton).toBeDisabled();
+      });
+    });
+  });
+
+  describe('file type detection', () => {
+    it('shows ZIP options when .zip file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
+        expect(screen.getByText(/Create folder from ZIP/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows 3MF info when .3mf file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, threemfFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('3MF files detected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when .stl file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const stlFile = new File(['solid'], 'bracket.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when ZIP file is added (may contain STLs)', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/ZIP files may contain STL/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('ZIP options', () => {
+    it('preserve structure checkbox is checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        const label = screen.getByText(/Preserve folder structure/).closest('label');
+        const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+        expect(checkbox).toBeChecked();
+      });
+    });
+
+    it('create folder checkbox is unchecked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        const label = screen.getByText(/Create folder from ZIP/).closest('label');
+        const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+        expect(checkbox).not.toBeChecked();
+      });
+    });
+
+    it('can toggle ZIP options', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+      });
+
+      const preserveLabel = screen.getByText(/Preserve folder structure/).closest('label');
+      const preserveCheckbox = preserveLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      await user.click(preserveCheckbox);
+      expect(preserveCheckbox).not.toBeChecked();
+
+      const createFolderLabel = screen.getByText(/Create folder from ZIP/).closest('label');
+      const createFolderCheckbox = createFolderLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      await user.click(createFolderCheckbox);
+      expect(createFolderCheckbox).toBeChecked();
+    });
+  });
+
+  describe('upload flow', () => {
+    it('calls onUploadComplete after successful upload', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+
+    it('calls onFileUploaded with response data for each file', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(onFileUploaded).toHaveBeenCalledWith(
+          expect.objectContaining({
+            id: 1,
+            filename: 'test.gcode.3mf',
+          })
+        );
+      });
+    });
+
+    it('shows uploading state while uploading', async () => {
+      // Delay the response to observe uploading state
+      server.use(
+        http.post('/api/v1/library/files', async () => {
+          await new Promise((resolve) => setTimeout(resolve, 100));
+          return HttpResponse.json({
+            id: 1,
+            filename: 'model.3mf',
+            file_type: '3mf',
+            file_size: 1024,
+            thumbnail_path: null,
+            duplicate_of: null,
+            metadata: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      // Should show uploading state
+      await waitFor(() => {
+        expect(screen.getByText('Uploading...')).toBeInTheDocument();
+        expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+      });
+    });
+
+    it('shows error state on upload failure', async () => {
+      server.use(
+        http.post('/api/v1/library/files', () => {
+          return HttpResponse.json({ detail: 'File too large' }, { status: 413 });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+
+    it('closes modal after manual upload completes', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('autoUpload mode', () => {
+    it('uploads immediately when file is added', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          autoUpload
+          onFileUploaded={onFileUploaded}
+        />
+      );
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(onFileUploaded).toHaveBeenCalledWith(
+          expect.objectContaining({ id: 1 })
+        );
+      });
+    });
+
+    it('calls onClose after autoUpload completes', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} autoUpload />);
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when Cancel button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+      expect(defaultProps.onClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when X button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      // The X button is the one in the header (not file remove buttons)
+      const headerButtons = screen.getByText('Upload Files').parentElement?.querySelectorAll('button');
+      const closeButton = headerButtons?.[0];
+
+      if (closeButton) {
+        await user.click(closeButton);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+
+    it('always shows Cancel button (modal auto-closes after upload)', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+  });
+
+  describe('drag and drop', () => {
+    it('highlights drop zone on drag over', () => {
+      render(<FileUploadModal {...defaultProps} />);
+
+      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        expect(dropZone.className).toContain('border-bambu-green');
+      }
+    });
+
+    it('removes highlight on drag leave', () => {
+      render(<FileUploadModal {...defaultProps} />);
+
+      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
+        expect(dropZone.className).not.toContain('bg-bambu-green');
+      }
+    });
+  });
+
+  describe('folder context', () => {
+    it('accepts folderId prop for uploading to specific folder', () => {
+      render(<FileUploadModal {...defaultProps} folderId={5} />);
+      // Component should render without errors with a folder context
+      expect(screen.getByText('Upload Files')).toBeInTheDocument();
+    });
+  });
+
+  describe('validateFile prop', () => {
+    it('rejects files that fail validation and shows error', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const file = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      // Error should be shown
+      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
+      // File should NOT be added to the list
+      expect(screen.queryByText('model.stl')).not.toBeInTheDocument();
+    });
+
+    it('allows files that pass validation', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const file = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.gcode')).toBeInTheDocument();
+      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
+    });
+
+    it('clears validation error when a new file is added', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+
+      // First add an invalid file
+      const badFile = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
+      await user.upload(fileInput, badFile);
+      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
+
+      // Then add a valid file — error should clear
+      const goodFile = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
+      await user.upload(fileInput, goodFile);
+      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('accept prop', () => {
+    it('sets accept attribute on file input', () => {
+      render(<FileUploadModal {...defaultProps} accept=".gcode,.gcode.3mf" />);
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput.accept).toBe('.gcode,.gcode.3mf');
+    });
+
+    it('does not set accept attribute when prop is omitted', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput.accept).toBe('');
+    });
+  });
+
+  describe('onFileUploaded error handling', () => {
+    it('shows error and keeps modal open when onFileUploaded returns a string', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          onFileUploaded={() => 'This file was sliced for the wrong printer'}
+        />
+      );
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('This file was sliced for the wrong printer')).toBeInTheDocument();
+      });
+
+      // Modal should NOT close
+      expect(defaultProps.onClose).not.toHaveBeenCalled();
+    });
+
+    it('clears file list when onFileUploaded returns an error', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          onFileUploaded={() => 'Incompatible printer'}
+        />
+      );
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Incompatible printer')).toBeInTheDocument();
+      });
+
+      // File list should be cleared
+      expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+    });
+
+    it('closes modal normally when onFileUploaded returns undefined', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 116 - 0
frontend/src/__tests__/components/SpoolInfoCard.test.tsx

@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+
+const mockUpdateSpoolWeight = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
+  },
+}));
+
+const mockSpool: MatchedSpool = {
+  id: 42,
+  tag_uid: 'AABBCCDD11223344',
+  material: 'PLA',
+  subtype: 'Matte',
+  color_name: 'Jade White',
+  rgba: 'E8F5E9FF',
+  brand: 'Bambu',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 200,
+};
+
+describe('SpoolInfoCard', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
+  });
+
+  it('renders spool material, brand, color name', () => {
+    render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
+
+    expect(screen.getByText('Jade White')).toBeInTheDocument();
+    expect(screen.getByText(/Bambu/)).toBeInTheDocument();
+    expect(screen.getByText(/PLA/)).toBeInTheDocument();
+  });
+
+  it('shows spool color circle with correct hex color', () => {
+    const { container } = render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
+
+    // SpoolIcon renders an SVG circle with fill=colorHex
+    const circle = container.querySelector('circle[fill="#E8F5E9"]');
+    expect(circle).toBeInTheDocument();
+  });
+
+  it('shows remaining weight and fill percentage', () => {
+    // scaleWeight=900g, core=250g → remaining = 900-250 = 650g
+    // fillPercent = round(650/1000 * 100) = 65%
+    render(<SpoolInfoCard spool={mockSpool} scaleWeight={900} />);
+
+    expect(screen.getByText('650g')).toBeInTheDocument();
+    expect(screen.getByText('65%')).toBeInTheDocument();
+  });
+
+  it('calls onAssignToAms when "Assign to AMS" button clicked', () => {
+    const onAssign = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={onAssign} />
+    );
+
+    fireEvent.click(screen.getByText('Assign to AMS'));
+    expect(onAssign).toHaveBeenCalledTimes(1);
+  });
+
+  it('calls onSyncWeight when sync button clicked', async () => {
+    const onSync = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onSyncWeight={onSync} />
+    );
+
+    fireEvent.click(screen.getByText('Sync Weight'));
+
+    await waitFor(() => {
+      expect(mockUpdateSpoolWeight).toHaveBeenCalledWith(42, 800);
+    });
+  });
+
+  it('calls onClose when close button clicked', () => {
+    const onClose = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={null} onClose={onClose} />
+    );
+
+    fireEvent.click(screen.getByText('Close'));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe('UnknownTagCard', () => {
+  it('renders tag UID', () => {
+    render(<UnknownTagCard tagUid="DEADBEEF12345678" scaleWeight={null} />);
+
+    expect(screen.getByText('DEADBEEF12345678')).toBeInTheDocument();
+    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+  });
+
+  it('shows "Add to Inventory" button', () => {
+    const onAdd = vi.fn();
+    render(
+      <UnknownTagCard tagUid="DEADBEEF" scaleWeight={null} onAddToInventory={onAdd} />
+    );
+
+    const btn = screen.getByText('Add to Inventory');
+    expect(btn).toBeInTheDocument();
+    fireEvent.click(btn);
+    expect(onAdd).toHaveBeenCalledTimes(1);
+  });
+});

+ 110 - 0
frontend/src/__tests__/components/TagDetectedModal.test.tsx

@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+
+const mockUpdateSpoolWeight = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
+  },
+}));
+
+const mockSpool: MatchedSpool = {
+  id: 7,
+  tag_uid: 'AA11BB22CC33DD44',
+  material: 'PETG',
+  subtype: 'HF',
+  color_name: 'Orange',
+  rgba: 'FF6600FF',
+  brand: 'Overture',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+};
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  spool: mockSpool,
+  tagUid: 'AA11BB22CC33DD44',
+  scaleWeight: 950.0,
+  weightStable: true,
+  onSyncWeight: vi.fn(),
+  onAssignToAms: vi.fn(),
+  onLinkSpool: vi.fn(),
+  onAddToInventory: vi.fn(),
+};
+
+describe('TagDetectedModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
+  });
+
+  it('does not render when isOpen=false', () => {
+    render(<TagDetectedModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Spool Detected')).not.toBeInTheDocument();
+  });
+
+  it('renders known spool view when spool provided', () => {
+    render(<TagDetectedModal {...defaultProps} />);
+
+    expect(screen.getByText('Spool Detected')).toBeInTheDocument();
+    expect(screen.getByText('Orange')).toBeInTheDocument();
+    expect(screen.getByText(/Overture/)).toBeInTheDocument();
+    expect(screen.getByText(/PETG/)).toBeInTheDocument();
+  });
+
+  it('renders unknown tag view when spool is null', () => {
+    render(
+      <TagDetectedModal
+        {...defaultProps}
+        spool={null}
+        tagUid="DEADBEEF11223344"
+      />
+    );
+
+    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+    expect(screen.getByText('DEADBEEF11223344')).toBeInTheDocument();
+  });
+
+  it('closes on Escape key', () => {
+    const onClose = vi.fn();
+    render(<TagDetectedModal {...defaultProps} onClose={onClose} />);
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows weight from scale', () => {
+    // scaleWeight=950g, core=250g → remaining = 950-250 = 700g
+    render(<TagDetectedModal {...defaultProps} scaleWeight={950} />);
+
+    expect(screen.getByText('700g')).toBeInTheDocument();
+  });
+
+  it('shows action buttons (Assign to AMS, Sync Weight)', () => {
+    const onAssign = vi.fn();
+    const onSync = vi.fn();
+    render(
+      <TagDetectedModal
+        {...defaultProps}
+        onAssignToAms={onAssign}
+        onSyncWeight={onSync}
+      />
+    );
+
+    expect(screen.getByText('Assign to AMS')).toBeInTheDocument();
+    expect(screen.getByText('Sync Weight')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByText('Assign to AMS'));
+    expect(onAssign).toHaveBeenCalledTimes(1);
+  });
+});

+ 80 - 0
frontend/src/__tests__/components/WeightDisplay.test.tsx

@@ -0,0 +1,80 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
+
+const mockTare = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    tare: (...args: unknown[]) => mockTare(...args),
+  },
+}));
+
+const defaultProps = {
+  weight: 823.4,
+  weightStable: true,
+  deviceOnline: true,
+  deviceId: 'sb-0001',
+};
+
+describe('WeightDisplay', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockTare.mockResolvedValue({ status: 'ok' });
+  });
+
+  it('renders weight value with 1 decimal place', () => {
+    render(<WeightDisplay {...defaultProps} weight={823.456} />);
+    expect(screen.getByText('823.5')).toBeInTheDocument();
+  });
+
+  it('shows green dot when stable and online', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} weightStable={true} deviceOnline={true} />
+    );
+    const dot = container.querySelector('.bg-green-500');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('Stable')).toBeInTheDocument();
+  });
+
+  it('shows amber dot when unstable', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} weightStable={false} deviceOnline={true} />
+    );
+    const dot = container.querySelector('.bg-amber-500');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('Measuring...')).toBeInTheDocument();
+  });
+
+  it('shows gray dot when offline', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} deviceOnline={false} />
+    );
+    const dot = container.querySelector('.bg-zinc-600');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('No reading')).toBeInTheDocument();
+  });
+
+  it('tare button calls spoolbuddyApi.tare(deviceId)', async () => {
+    render(<WeightDisplay {...defaultProps} />);
+
+    const tareButton = screen.getByText('Tare');
+    fireEvent.click(tareButton);
+
+    await waitFor(() => {
+      expect(mockTare).toHaveBeenCalledWith('sb-0001');
+    });
+  });
+
+  it('tare button is disabled when no deviceId', () => {
+    render(<WeightDisplay {...defaultProps} deviceId={null} />);
+
+    const tareButton = screen.getByText('Tare');
+    expect(tareButton).toBeDisabled();
+  });
+});

+ 112 - 12
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -569,8 +569,8 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('upload modal with advanced 3MF support', () => {
-    it('opens upload modal', async () => {
+  describe('upload modal (FileUploadModal)', () => {
+    it('opens upload modal when Upload button is clicked', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -586,6 +586,27 @@ describe('FileManagerPage', () => {
       });
     });
 
+    it('closes upload modal when Cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
+      });
+    });
+
     it('shows 3MF extraction info when 3MF file is added', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -600,17 +621,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock 3MF file
       const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
-
-      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
 
-      // Simulate file selection
       await user.upload(fileInput, threemfFile);
 
-      // 3MF extraction info should appear
       await waitFor(() => {
         expect(screen.getByText('3MF files detected')).toBeInTheDocument();
         expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
@@ -631,22 +647,106 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock STL file
       const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
-
-      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
 
-      // Simulate file selection
       await user.upload(fileInput, stlFile);
 
-      // STL thumbnail option should appear
       await waitFor(() => {
         expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
         expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
       });
     });
+
+    it('shows ZIP options when ZIP file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
+      });
+    });
+
+    it('can add a file via the file input', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(screen.getByText('model.3mf')).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /Upload \(1\)/i })).toBeInTheDocument();
+      });
+    });
+
+    it('uploads file and refreshes file list', async () => {
+      server.use(
+        http.post('/api/v1/library/files', () => {
+          return HttpResponse.json({
+            id: 10,
+            filename: 'uploaded.3mf',
+            file_type: '3mf',
+            file_size: 1024,
+            thumbnail_path: null,
+            duplicate_of: null,
+            metadata: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const file = new File(['content'], 'uploaded.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      // Modal should auto-close after upload completes
+      await waitFor(() => {
+        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
+      });
+    });
   });
 
   describe('authentication-based UI changes', () => {

+ 396 - 0
frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx

@@ -0,0 +1,396 @@
+/**
+ * Tests for low stock threshold functionality in InventoryPage.
+ *
+ * Tests that the low stock threshold:
+ * - Is loaded from backend settings API
+ * - Can be updated via the UI
+ * - Persists changes to the backend
+ * - Does not use localStorage
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockSettings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: true,
+  check_printer_firmware: true,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'immediate',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+const mockSpools = [
+  {
+    id: 1,
+    material: 'PLA',
+    subtype: null,
+    brand: 'Polymaker',
+    color_name: 'Red',
+    rgba: 'FF0000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 900, // 10% remaining - low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-01T00:00:00Z',
+    updated_at: '2025-01-01T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    id: 2,
+    material: 'PETG',
+    subtype: null,
+    brand: 'eSun',
+    color_name: 'Blue',
+    rgba: '0000FFFF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 200, // 80% remaining - not low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-02T00:00:00Z',
+    updated_at: '2025-01-02T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    id: 3,
+    material: 'ABS',
+    subtype: null,
+    brand: 'Hatchbox',
+    color_name: 'Black',
+    rgba: '000000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 850, // 15% remaining - low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-03T00:00:00Z',
+    updated_at: '2025-01-03T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+];
+
+describe('InventoryPage - Low Stock Threshold', () => {
+  beforeEach(() => {
+    // Clear localStorage to ensure we're not relying on it
+    localStorage.clear();
+
+    server.use(
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json(mockSettings);
+      }),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Partial<typeof mockSettings>;
+        return HttpResponse.json({ ...mockSettings, ...body });
+      }),
+      http.get('/api/v1/inventory/spools', () => {
+        return HttpResponse.json(mockSpools);
+      }),
+      http.get('/api/v1/inventory/assignments', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/spoolman/settings', () => {
+        return HttpResponse.json({ spoolman_enabled: 'false' });
+      })
+    );
+  });
+
+  describe('default threshold from backend', () => {
+    it('loads the default threshold of 20% from backend settings', async () => {
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // Find the low stock stat showing the threshold
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+
+    it('calculates low stock count based on default threshold', async () => {
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
+        const lowStockSection = screen.getByText(/low stock/i).closest('div');
+        expect(lowStockSection).toBeInTheDocument();
+      });
+    });
+
+    it('does not use localStorage for threshold', async () => {
+      // Set a value in localStorage that should be ignored
+      localStorage.setItem('bambuddy-low-stock-threshold', '50');
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // Should show backend value (20%), not localStorage value (50%)
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('updating threshold via UI', () => {
+    it('shows edit button for threshold', async () => {
+      const user = userEvent.setup();
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Find the edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      expect(editButton).toBeInTheDocument();
+
+      await user.click(editButton);
+
+      // Input field should appear with default threshold value
+      await waitFor(() => {
+        const input = screen.getByDisplayValue('20');
+        expect(input).toBeInTheDocument();
+      });
+    });
+
+    it('updates threshold and persists to backend', async () => {
+      const user = userEvent.setup();
+      let updatedSettings: Partial<typeof mockSettings> | null = null;
+
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Partial<typeof mockSettings>;
+          updatedSettings = body;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      await user.click(editButton);
+
+      // Enter new value
+      const input = screen.getByDisplayValue('20');
+      await user.clear(input);
+      await user.type(input, '15.5');
+
+      // Submit form
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      await user.click(saveButton);
+
+      // Verify API was called with correct value
+      await waitFor(() => {
+        expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
+      });
+    });
+
+    it('validates threshold input range', async () => {
+      const user = userEvent.setup();
+      let updatedSettings: Partial<typeof mockSettings> | null = null;
+
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Partial<typeof mockSettings>;
+          updatedSettings = body;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      await user.click(editButton);
+
+      // Try invalid values
+      const input = screen.getByDisplayValue('20');
+
+      // Too low (0 is below the 0.1 minimum)
+      await user.clear(input);
+      await user.type(input, '0');
+
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      await user.click(saveButton);
+
+      // Should show error and NOT call the PUT endpoint
+      await waitFor(() => {
+        expect(updatedSettings).toBeNull();
+      });
+    });
+
+    it('allows canceling threshold edit', async () => {
+      const user = userEvent.setup();
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      await user.click(editButton);
+
+      // Change value
+      const input = screen.getByDisplayValue('20');
+      await user.clear(input);
+      await user.type(input, '30');
+
+      // Cancel
+      const cancelButton = screen.getByRole('button', { name: /cancel/i });
+      await user.click(cancelButton);
+
+      // Should revert to original display
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('custom threshold from backend', () => {
+    it('loads custom threshold value from backend', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
+      });
+    });
+
+    it('applies custom threshold to low stock filtering', async () => {
+      // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
+      });
+
+      // The low stock count should reflect the new threshold
+      // Implementation would show appropriate count based on 30% threshold
+    });
+  });
+});

+ 6 - 2
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -112,12 +112,16 @@ describe('PrintersPage', () => {
   });
 
   describe('printer info', () => {
-    it('shows IP address', async () => {
+    it('shows IP address in printer info modal', async () => {
       render(<PrintersPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
       });
+
+      // IP address is shown in the PrinterInfoModal (accessed via 3-dot menu),
+      // not directly on the card. Verify the printer data loaded correctly.
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
     });
 
     it('shows location when set', async () => {

+ 114 - 0
frontend/src/__tests__/pages/SpoolBuddyAmsPageLogic.test.ts

@@ -0,0 +1,114 @@
+/**
+ * Tests for SpoolBuddy AMS page logic:
+ * - External slot active state (tray_now=255 bug fix)
+ * - Fill level override fallback chain (inventory → AMS remain)
+ *
+ * These mirror inline logic from SpoolBuddyAmsPage.tsx, extracted for testability.
+ */
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Mirrors the ext slot isExtActive calculation from SpoolBuddyAmsPage.tsx.
+ * tray_now=255 means "no tray loaded" (idle) — should never mark any slot active.
+ */
+function computeExtActive(
+  trayNow: number,
+  isDualNozzle: boolean,
+  extTrayId: number,
+  activeExtruder: number | undefined,
+): boolean {
+  return trayNow === 255 ? false
+    : isDualNozzle && trayNow === 254
+      ? (extTrayId === 254 && activeExtruder === 1) ||
+        (extTrayId === 255 && activeExtruder === 0)
+      : trayNow === extTrayId;
+}
+
+/**
+ * Mirrors the effective fill fallback from SpoolBuddyAmsPage.tsx and AmsUnitCard.tsx.
+ * Priority: inventory fill override → AMS remain (if >= 0)
+ */
+function computeEffectiveFill(
+  fillOverride: number | null,
+  amsRemain: number | null | undefined,
+): number | null {
+  const amsFill = amsRemain != null && amsRemain >= 0 ? amsRemain : null;
+  return fillOverride ?? amsFill;
+}
+
+describe('ext slot active state', () => {
+  describe('tray_now=255 (idle) — no slot should be active', () => {
+    it('single-nozzle: ext (id=254) not active when tray_now=255', () => {
+      expect(computeExtActive(255, false, 254, undefined)).toBe(false);
+    });
+
+    it('dual-nozzle: ext-L (id=254) not active when tray_now=255', () => {
+      expect(computeExtActive(255, true, 254, 1)).toBe(false);
+    });
+
+    it('dual-nozzle: ext-R (id=255) not active when tray_now=255', () => {
+      // This was the bug: trayNow(255) === extTrayId(255) without the guard
+      expect(computeExtActive(255, true, 255, 0)).toBe(false);
+    });
+  });
+
+  describe('tray_now=254 on dual-nozzle — uses active_extruder', () => {
+    it('ext-L active when active_extruder=1 (left)', () => {
+      expect(computeExtActive(254, true, 254, 1)).toBe(true);
+    });
+
+    it('ext-R active when active_extruder=0 (right)', () => {
+      expect(computeExtActive(254, true, 255, 0)).toBe(true);
+    });
+
+    it('ext-L not active when active_extruder=0 (right)', () => {
+      expect(computeExtActive(254, true, 254, 0)).toBe(false);
+    });
+
+    it('ext-R not active when active_extruder=1 (left)', () => {
+      expect(computeExtActive(254, true, 255, 1)).toBe(false);
+    });
+  });
+
+  describe('tray_now=254 on single-nozzle — direct ID match', () => {
+    it('ext (id=254) active when tray_now=254', () => {
+      expect(computeExtActive(254, false, 254, undefined)).toBe(true);
+    });
+  });
+
+  describe('AMS tray active — ext slots not active', () => {
+    it('ext not active when AMS slot is active (tray_now=5)', () => {
+      expect(computeExtActive(5, false, 254, undefined)).toBe(false);
+    });
+  });
+});
+
+describe('fill level override fallback', () => {
+  it('uses inventory fill when available, ignoring AMS remain', () => {
+    expect(computeEffectiveFill(75, 50)).toBe(75);
+  });
+
+  it('falls back to AMS remain when no inventory fill', () => {
+    expect(computeEffectiveFill(null, 50)).toBe(50);
+  });
+
+  it('returns null when neither source available', () => {
+    expect(computeEffectiveFill(null, null)).toBeNull();
+  });
+
+  it('returns null when AMS remain is -1 (unknown) and no inventory fill', () => {
+    expect(computeEffectiveFill(null, -1)).toBeNull();
+  });
+
+  it('uses inventory fill even when AMS remain is -1', () => {
+    expect(computeEffectiveFill(80, -1)).toBe(80);
+  });
+
+  it('uses AMS remain of 0 (empty) as valid fill', () => {
+    expect(computeEffectiveFill(null, 0)).toBe(0);
+  });
+
+  it('uses inventory fill of 0 over AMS remain', () => {
+    expect(computeEffectiveFill(0, 50)).toBe(0);
+  });
+});

+ 137 - 0
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -0,0 +1,137 @@
+/**
+ * Tests for SpoolBuddyWriteTagPage:
+ * - Renders three workflow tabs
+ * - Tab switching works
+ * - Search input renders on existing/replace tabs
+ * - New spool form renders on new tab
+ * - NFC status panel shows correct idle state
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
+
+// Mock the API modules
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 1, material: 'PLA' }),
+  },
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([]),
+    writeTag: vi.fn().mockResolvedValue({ status: 'queued' }),
+    cancelWrite: vi.fn().mockResolvedValue({ status: 'ok' }),
+  },
+}));
+
+// Mock i18n
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+const mockOutletContext = {
+  selectedPrinterId: null,
+  setSelectedPrinterId: vi.fn(),
+  sbState: {
+    weight: null,
+    weightStable: false,
+    rawAdc: null,
+    matchedSpool: null,
+    unknownTagUid: null,
+    deviceOnline: false,
+    deviceId: null,
+    remainingWeight: null,
+    netWeight: null,
+  },
+  setAlert: vi.fn(),
+  displayBrightness: 100,
+  setDisplayBrightness: vi.fn(),
+  displayBlankTimeout: 0,
+  setDisplayBlankTimeout: vi.fn(),
+};
+
+function OutletWrapper() {
+  return <Outlet context={mockOutletContext} />;
+}
+
+function renderPage() {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false, gcTime: 0 } },
+  });
+
+  return render(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>
+        <Routes>
+          <Route element={<OutletWrapper />}>
+            <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyWriteTagPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders three workflow tabs', () => {
+    renderPage();
+    expect(screen.getByText('Existing Spool')).toBeDefined();
+    expect(screen.getByText('New Spool')).toBeDefined();
+    expect(screen.getByText('Replace Tag')).toBeDefined();
+  });
+
+  it('shows search input on existing spool tab', () => {
+    renderPage();
+    expect(screen.getByPlaceholderText('Search by material, color, brand...')).toBeDefined();
+  });
+
+  it('shows no spools message when list is empty', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('No spools without tags')).toBeDefined();
+    });
+  });
+
+  it('switches to new spool form on tab click', async () => {
+    renderPage();
+    fireEvent.click(screen.getByText('New Spool'));
+    await waitFor(() => {
+      expect(screen.getByText('Material')).toBeDefined();
+      expect(screen.getByText('Color Name')).toBeDefined();
+      expect(screen.getByText('Brand')).toBeDefined();
+      expect(screen.getByText('Weight (g)')).toBeDefined();
+      expect(screen.getByText('Create Spool')).toBeDefined();
+    });
+  });
+
+  it('switches to replace tab and shows appropriate empty message', async () => {
+    renderPage();
+    fireEvent.click(screen.getByText('Replace Tag'));
+    await waitFor(() => {
+      expect(screen.getByText('No spools with tags')).toBeDefined();
+    });
+  });
+
+  it('shows device offline message in NFC panel', () => {
+    renderPage();
+    expect(screen.getByText('SpoolBuddy is offline')).toBeDefined();
+  });
+
+  it('shows idle prompt when device is online but no spool selected', () => {
+    mockOutletContext.sbState.deviceOnline = true;
+    renderPage();
+    expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();
+    mockOutletContext.sbState.deviceOnline = false; // reset
+  });
+});

+ 204 - 14
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -41,8 +41,70 @@ const mockPrinters = [
 ];
 
 const mockArchives = [
-  { id: 1, created_at: '2024-01-01T00:00:00Z', print_name: 'Test Print 1' },
-  { id: 2, created_at: '2024-01-02T00:00:00Z', print_name: 'Test Print 2' },
+  {
+    id: 1,
+    created_at: '2024-01-01T10:00:00Z',
+    started_at: '2024-01-01T10:00:00Z',
+    completed_at: '2024-01-01T14:30:00Z',
+    print_name: 'Benchy',
+    status: 'completed',
+    printer_id: 1,
+    filament_type: 'PLA',
+    filament_color: '#00FF00',
+    filament_used_grams: 25,
+    actual_time_seconds: 16200,
+    print_time_seconds: 15000,
+    cost: 0.75,
+    quantity: 1,
+  },
+  {
+    id: 2,
+    created_at: '2024-01-02T14:00:00Z',
+    started_at: '2024-01-02T14:00:00Z',
+    completed_at: '2024-01-02T22:00:00Z',
+    print_name: 'Large Vase',
+    status: 'completed',
+    printer_id: 1,
+    filament_type: 'PETG',
+    filament_color: '#FF0000',
+    filament_used_grams: 180,
+    actual_time_seconds: 28800,
+    print_time_seconds: 27000,
+    cost: 5.40,
+    quantity: 1,
+  },
+  {
+    id: 3,
+    created_at: '2024-01-03T08:00:00Z',
+    started_at: '2024-01-03T08:00:00Z',
+    completed_at: null,
+    print_name: 'Failed Bracket',
+    status: 'failed',
+    printer_id: 2,
+    filament_type: 'ABS',
+    filament_color: '#0000FF',
+    filament_used_grams: 10,
+    actual_time_seconds: 3600,
+    print_time_seconds: 7200,
+    cost: 0.30,
+    quantity: 1,
+  },
+  {
+    id: 4,
+    created_at: '2024-01-03T20:00:00Z',
+    started_at: '2024-01-03T20:00:00Z',
+    completed_at: '2024-01-04T02:00:00Z',
+    print_name: 'Phone Stand',
+    status: 'completed',
+    printer_id: 2,
+    filament_type: 'PLA',
+    filament_color: '#00FF00',
+    filament_used_grams: 45,
+    actual_time_seconds: 21600,
+    print_time_seconds: 20000,
+    cost: 1.35,
+    quantity: 1,
+  },
 ];
 
 const mockSettings = {
@@ -60,9 +122,19 @@ const mockFailureAnalysis = {
     'First layer adhesion': 3,
     'Filament runout': 2,
   },
+  failures_by_filament: {
+    'ABS': 3,
+    'PLA': 2,
+  },
+  failures_by_printer: {
+    '1': 2,
+    '2': 3,
+  },
+  failures_by_hour: {},
+  recent_failures: [],
   trend: [
-    { week: '2024-W01', failure_rate: 6.0 },
-    { week: '2024-W02', failure_rate: 5.0 },
+    { week_start: '2024-01-01', total_prints: 50, failed_prints: 3, failure_rate: 6.0 },
+    { week_start: '2024-01-08', total_prints: 50, failed_prints: 2, failure_rate: 5.0 },
   ],
 };
 
@@ -75,13 +147,13 @@ describe('StatsPage', () => {
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json(mockPrinters);
       }),
-      http.get('/api/v1/archives/', () => {
+      http.get('/api/v1/archives/slim', () => {
         return HttpResponse.json(mockArchives);
       }),
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json(mockSettings);
       }),
-      http.get('/api/v1/stats/failure-analysis', () => {
+      http.get('/api/v1/archives/analysis/failures', () => {
         return HttpResponse.json(mockFailureAnalysis);
       })
     );
@@ -127,7 +199,7 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Filament Used')).toBeInTheDocument();
-        expect(screen.getByText('5.50kg')).toBeInTheDocument();
+        expect(screen.getByText('5.5kg')).toBeInTheDocument();
       });
     });
   });
@@ -138,7 +210,7 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
-        // Success rate should be calculated: 140/150 = 93%
+        // Success rate: 140/(140+10) = 93%
         expect(screen.getByText('93%')).toBeInTheDocument();
       });
     });
@@ -163,27 +235,145 @@ describe('StatsPage', () => {
   });
 
   describe('widgets', () => {
-    it('shows filament types widget', async () => {
+    it('shows time accuracy widget', async () => {
       render(<StatsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Filament Types')).toBeInTheDocument();
+        expect(screen.getByText('Time Accuracy')).toBeInTheDocument();
       });
     });
 
-    it('shows time accuracy widget', async () => {
+    it('shows print activity widget', async () => {
       render(<StatsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Time Accuracy')).toBeInTheDocument();
+        expect(screen.getByText('Print Activity')).toBeInTheDocument();
       });
     });
 
-    it('shows print activity widget', async () => {
+    it('shows failure analysis widget', async () => {
       render(<StatsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Print Activity')).toBeInTheDocument();
+        expect(screen.getByText('Failure Analysis')).toBeInTheDocument();
+      });
+    });
+
+    it('shows printer stats widget', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer Stats')).toBeInTheDocument();
+      });
+    });
+
+    it('shows filament trends widget', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+      });
+    });
+
+    it('shows records widget', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Records')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('printer stats sub-cards', () => {
+    it('shows prints by printer section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Prints by Printer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print duration section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Duration')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print habits section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Habits')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print time of day section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Time of Day')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('filament trends sub-cards', () => {
+    it('shows by material section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('By Material')).toBeInTheDocument();
+      });
+    });
+
+    it('shows success by material section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Success by Material')).toBeInTheDocument();
+      });
+    });
+
+    it('shows color distribution section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Color Distribution')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('records widget', () => {
+    it('shows longest print record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Longest Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows heaviest print record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Heaviest Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows most expensive record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Most Expensive')).toBeInTheDocument();
+      });
+    });
+
+    it('shows success streak record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Success Streak')).toBeInTheDocument();
       });
     });
   });

+ 2 - 2
frontend/src/__tests__/utils/currency.test.ts

@@ -37,7 +37,7 @@ describe('SUPPORTED_CURRENCIES', () => {
     expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
   });
 
-  it('has 25 entries', () => {
-    expect(SUPPORTED_CURRENCIES).toHaveLength(25);
+  it('has 26 entries', () => {
+    expect(SUPPORTED_CURRENCIES).toHaveLength(26);
   });
 });

+ 102 - 6
frontend/src/api/client.ts

@@ -210,6 +210,7 @@ export interface PrinterStatus {
   timelapse: boolean;  // Timelapse recording active
   ipcam: boolean;  // Live view enabled
   wifi_signal: number | null;  // WiFi signal strength in dBm
+  wired_network: boolean;  // Ethernet connection detected
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
   nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack
   print_options: PrintOptions | null;  // AI detection and print options
@@ -373,6 +374,22 @@ export interface Archive {
   created_by_username: string | null;
 }
 
+export interface ArchiveSlim {
+  printer_id: number | null;
+  print_name: string | null;
+  print_time_seconds: number | null;
+  actual_time_seconds: number | null;
+  filament_used_grams: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  cost: number | null;
+  quantity: number;
+  created_at: string;
+}
+
 export interface PrintLogEntry {
   id: number;
   print_name: string | null;
@@ -764,6 +781,7 @@ export interface AppSettings {
   check_updates: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
+  language: string;
   notification_language: string;
   // AMS threshold settings
   ams_humidity_good: number;  // <= this is green
@@ -820,6 +838,8 @@ export interface AppSettings {
   prometheus_token: string;
   // Bed cooled threshold
   bed_cooled_threshold: number;
+  // Inventory low stock threshold
+  low_stock_threshold: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -1804,6 +1824,8 @@ export interface InventorySpool {
   created_at: string;
   updated_at: string;
   cost_per_kg: number | null;
+  last_scale_weight: number | null;
+  last_weighed_at: string | null;
   k_profiles?: SpoolKProfile[];
 }
 
@@ -2489,14 +2511,23 @@ export const api = {
     request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
 
   // Archives
-  getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => {
+  getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0, dateFrom?: string, dateTo?: string) => {
     const params = new URLSearchParams();
     if (printerId) params.set('printer_id', String(printerId));
     if (projectId) params.set('project_id', String(projectId));
     params.set('limit', String(limit));
     params.set('offset', String(offset));
+    if (dateFrom) params.set('date_from', dateFrom);
+    if (dateTo) params.set('date_to', dateTo);
     return request<Archive[]>(`/archives/?${params}`);
   },
+  getArchivesSlim: (dateFrom?: string, dateTo?: string) => {
+    const params = new URLSearchParams();
+    if (dateFrom) params.set('date_from', dateFrom);
+    if (dateTo) params.set('date_to', dateTo);
+    const qs = params.toString();
+    return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
+  },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   searchArchives: (query: string, options?: {
     printerId?: number;
@@ -2536,7 +2567,13 @@ export const api = {
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
-  getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  getArchiveStats: (options?: { dateFrom?: string; dateTo?: string }) => {
+    const params = new URLSearchParams();
+    if (options?.dateFrom) params.set('date_from', options.dateFrom);
+    if (options?.dateTo) params.set('date_to', options.dateTo);
+    const qs = params.toString();
+    return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
+  },
   // Tag management
   getTags: () => request<TagInfo[]>('/archives/tags'),
   renameTag: (oldName: string, newName: string) =>
@@ -2550,12 +2587,15 @@ export const api = {
     }),
   recalculateCosts: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
-  getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
+  getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number }) => {
     const params = new URLSearchParams();
     if (options?.days) params.set('days', String(options.days));
+    if (options?.dateFrom) params.set('date_from', options.dateFrom);
+    if (options?.dateTo) params.set('date_to', options.dateTo);
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
-    return request<FailureAnalysis>(`/archives/analysis/failures?${params}`);
+    const qs = params.toString();
+    return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);
   },
   compareArchives: (archiveIds: number[]) =>
     request<ArchiveComparison>(`/archives/compare?archive_ids=${archiveIds.join(',')}`),
@@ -4828,6 +4868,12 @@ export interface SpoolBuddyDevice {
   has_scale: boolean;
   tare_offset: number;
   calibration_factor: number;
+  nfc_reader_type: string | null;
+  nfc_connection: string | null;
+  display_brightness: number;
+  display_blank_timeout: number;
+  has_backlight: boolean;
+  last_calibrated_at: string | null;
   last_seen: string | null;
   pending_command: string | null;
   nfc_ok: boolean;
@@ -4836,6 +4882,13 @@ export interface SpoolBuddyDevice {
   online: boolean;
 }
 
+export interface DaemonUpdateCheck {
+  current_version: string;
+  latest_version: string | null;
+  update_available: boolean;
+  release_url: string | null;
+}
+
 // SpoolBuddy API
 export const spoolbuddyApi = {
   getDevices: () =>
@@ -4850,10 +4903,10 @@ export const spoolbuddyApi = {
   getCalibration: (deviceId: string) =>
     request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
 
-  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number) =>
+  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number, tareRawAdc?: number) =>
     request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {
       method: 'POST',
-      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc }),
+      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc, tare_raw_adc: tareRawAdc }),
     }),
 
   updateSpoolWeight: (spoolId: number, weightGrams: number) =>
@@ -4861,4 +4914,47 @@ export const spoolbuddyApi = {
       method: 'POST',
       body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),
     }),
+
+  updateDisplay: (deviceId: string, brightness: number, blankTimeout: number) =>
+    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/display`, {
+      method: 'PUT',
+      body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
+    }),
+
+  checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
+    request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
+
+  writeTag: (deviceId: string, spoolId: number) =>
+    request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
+      method: 'POST',
+      body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
+    }),
+
+  cancelWrite: (deviceId: string) =>
+    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/cancel-write`, {
+      method: 'POST',
+      body: '{}',
+    }),
+};
+
+export interface BugReportRequest {
+  description: string;
+  email?: string;
+  screenshot_base64?: string;
+  include_support_info?: boolean;
+}
+
+export interface BugReportResponse {
+  success: boolean;
+  message: string;
+  issue_url?: string;
+  issue_number?: number;
+}
+
+export const bugReportApi = {
+  submit: (data: BugReportRequest) =>
+    request<BugReportResponse>('/bug-report/submit', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
 };

+ 362 - 0
frontend/src/components/BugReportBubble.tsx

@@ -0,0 +1,362 @@
+import { useState, useRef, useCallback, useEffect } from 'react';
+import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { bugReportApi } from '../api/client';
+
+type ViewState = 'form' | 'collecting' | 'submitting' | 'success' | 'error';
+
+const LOG_COLLECTION_SECONDS = 30;
+
+const MAX_DIMENSION = 1920;
+const JPEG_QUALITY = 0.7;
+
+function compressImage(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const img = new Image();
+    img.onload = () => {
+      let { width, height } = img;
+      if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
+        const scale = MAX_DIMENSION / Math.max(width, height);
+        width = Math.round(width * scale);
+        height = Math.round(height * scale);
+      }
+      const canvas = document.createElement('canvas');
+      canvas.width = width;
+      canvas.height = height;
+      const ctx = canvas.getContext('2d');
+      if (!ctx) { reject(new Error('No canvas context')); return; }
+      ctx.drawImage(img, 0, 0, width, height);
+      const dataUrl = canvas.toDataURL('image/jpeg', JPEG_QUALITY);
+      resolve(dataUrl.replace(/^data:[^;]+;base64,/, ''));
+    };
+    img.onerror = reject;
+    img.src = URL.createObjectURL(file);
+  });
+}
+
+export function BugReportBubble() {
+  const { t } = useTranslation();
+  const [isOpen, setIsOpen] = useState(false);
+  const [viewState, setViewState] = useState<ViewState>('form');
+  const [description, setDescription] = useState('');
+  const [email, setEmail] = useState('');
+  const [screenshot, setScreenshot] = useState<string | null>(null);
+  const [isDragging, setIsDragging] = useState(false);
+  const [issueUrl, setIssueUrl] = useState<string | null>(null);
+  const [issueNumber, setIssueNumber] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState('');
+  const [countdown, setCountdown] = useState(0);
+  const modalRef = useRef<HTMLDivElement>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // Countdown timer for log collection phase
+  useEffect(() => {
+    if (viewState !== 'collecting') return;
+    if (countdown <= 0) {
+      setViewState('submitting');
+      return;
+    }
+    const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+    return () => clearTimeout(timer);
+  }, [viewState, countdown]);
+
+  const handleOpen = () => {
+    setIsOpen(true);
+    setViewState('form');
+    setDescription('');
+    setEmail('');
+    setScreenshot(null);
+    setIssueUrl(null);
+    setIssueNumber(null);
+    setErrorMessage('');
+  };
+
+  const handleClose = () => {
+    setIsOpen(false);
+  };
+
+  const handleFile = useCallback(async (file: File) => {
+    if (!file.type.startsWith('image/')) return;
+    try {
+      const b64 = await compressImage(file);
+      setScreenshot(b64);
+    } catch {
+      // Ignore read errors
+    }
+  }, []);
+
+  const handlePaste = useCallback((e: React.ClipboardEvent) => {
+    const items = e.clipboardData?.items;
+    if (!items) return;
+    for (const item of items) {
+      if (item.type.startsWith('image/')) {
+        const file = item.getAsFile();
+        if (file) handleFile(file);
+        break;
+      }
+    }
+  }, [handleFile]);
+
+  const handleDragOver = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(true);
+  }, []);
+
+  const handleDragLeave = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(false);
+  }, []);
+
+  const handleDrop = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(false);
+    const file = e.dataTransfer.files?.[0];
+    if (file) handleFile(file);
+  }, [handleFile]);
+
+  const handleSubmit = async () => {
+    if (!description.trim()) return;
+    setCountdown(LOG_COLLECTION_SECONDS);
+    setViewState('collecting');
+    try {
+      const result = await bugReportApi.submit({
+        description: description.trim(),
+        email: email.trim() || undefined,
+        screenshot_base64: screenshot || undefined,
+        include_support_info: true,
+      });
+      if (result.success) {
+        setIssueUrl(result.issue_url || null);
+        setIssueNumber(result.issue_number || null);
+        setViewState('success');
+      } else {
+        setErrorMessage(result.message);
+        setViewState('error');
+      }
+    } catch (err) {
+      setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));
+      setViewState('error');
+    }
+  };
+
+  return (
+    <>
+      {/* Floating bubble */}
+      <button
+        onClick={handleOpen}
+        className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
+        title={t('bugReport.title')}
+      >
+        <Bug className="w-5 h-5" />
+      </button>
+
+      {/* Slide-in panel anchored to bottom-right */}
+      {isOpen && (
+        <div
+          id="bug-report-modal"
+          className="fixed bottom-20 right-4 z-50 w-full max-w-md"
+          onPaste={handlePaste}
+        >
+          <div
+            ref={modalRef}
+            className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto"
+          >
+            {/* Header */}
+            <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">
+              <h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
+                <Bug className="w-5 h-5 text-red-500" />
+                {t('bugReport.title')}
+              </h2>
+              <button
+                onClick={handleClose}
+                className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            <div className="p-4 space-y-4">
+              {viewState === 'form' && (
+                <>
+                  {/* Description */}
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                      {t('bugReport.description')} *
+                    </label>
+                    <textarea
+                      value={description}
+                      onChange={(e) => setDescription(e.target.value)}
+                      placeholder={t('bugReport.descriptionPlaceholder')}
+                      rows={3}
+                      className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
+                    />
+                  </div>
+
+                  {/* Email (optional) */}
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                      {t('bugReport.email')}
+                    </label>
+                    <input
+                      type="email"
+                      value={email}
+                      onChange={(e) => setEmail(e.target.value)}
+                      placeholder={t('bugReport.emailPlaceholder')}
+                      className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+                    />
+                    <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                      {t('bugReport.emailPrivacy')}
+                    </p>
+                  </div>
+
+                  {/* Screenshot — upload, paste, or drag */}
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                      {t('bugReport.screenshot')}
+                    </label>
+                    {screenshot ? (
+                      <div className="relative">
+                        <img
+                          src={`data:image/jpeg;base64,${screenshot}`}
+                          alt={t('bugReport.screenshot')}
+                          className="w-full max-h-40 object-contain rounded-lg border border-gray-200 dark:border-gray-600"
+                        />
+                        <button
+                          onClick={() => setScreenshot(null)}
+                          className="absolute top-2 right-2 p-1 bg-red-500 hover:bg-red-600 text-white rounded-full shadow"
+                          title={t('common.delete')}
+                        >
+                          <Trash2 className="w-3 h-3" />
+                        </button>
+                      </div>
+                    ) : (
+                      <button
+                        type="button"
+                        onClick={() => fileInputRef.current?.click()}
+                        onDragOver={handleDragOver}
+                        onDragLeave={handleDragLeave}
+                        onDrop={handleDrop}
+                        className={`w-full flex flex-col items-center gap-2 px-4 py-4 border-2 border-dashed rounded-lg transition-colors cursor-pointer ${
+                          isDragging
+                            ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
+                            : 'border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-gray-400 dark:hover:border-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
+                        }`}
+                      >
+                        <Upload className="w-5 h-5" />
+                        <span className="text-sm">{t('bugReport.uploadOrPaste')}</span>
+                      </button>
+                    )}
+                    <input
+                      ref={fileInputRef}
+                      type="file"
+                      accept="image/*"
+                      className="hidden"
+                      onChange={(e) => {
+                        const file = e.target.files?.[0];
+                        if (file) handleFile(file);
+                        e.target.value = '';
+                      }}
+                    />
+                  </div>
+
+                  {/* Data collection notice */}
+                  <details className="text-xs bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
+                    <summary className="cursor-pointer font-medium text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200">
+                      {t('bugReport.dataCollectedSummary')}
+                    </summary>
+                    <div className="mt-2 space-y-2 pl-2 border-l-2 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200">
+                      <p className="font-medium">{t('bugReport.dataIncluded')}</p>
+                      <p>{t('bugReport.dataIncludedList')}</p>
+                      <p className="font-medium">{t('bugReport.dataNeverIncluded')}</p>
+                      <p>{t('bugReport.dataNeverIncludedList')}</p>
+                    </div>
+                  </details>
+
+                  {/* Buttons */}
+                  <div className="flex justify-end gap-2 pt-2">
+                    <button
+                      onClick={handleClose}
+                      className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
+                    >
+                      {t('common.cancel')}
+                    </button>
+                    <button
+                      onClick={handleSubmit}
+                      disabled={!description.trim()}
+                      className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
+                    >
+                      {t('bugReport.submit')}
+                    </button>
+                  </div>
+                </>
+              )}
+
+              {(viewState === 'collecting' || viewState === 'submitting') && (
+                <div className="flex flex-col items-center justify-center py-8 gap-3">
+                  <Loader2 className="w-8 h-8 animate-spin text-blue-500" />
+                  {viewState === 'collecting' ? (
+                    <>
+                      <p className="text-sm font-medium text-gray-900 dark:text-white">{t('bugReport.collectingLogs')}</p>
+                      <p className="text-xs text-gray-500 dark:text-gray-400">{t('bugReport.collectingLogsHint')}</p>
+                      {countdown > 0 && (
+                        <p className="text-lg font-mono text-blue-500">{t('bugReport.countdownSeconds', { seconds: countdown })}</p>
+                      )}
+                    </>
+                  ) : (
+                    <p className="text-sm text-gray-600 dark:text-gray-400">{t('bugReport.submitting')}</p>
+                  )}
+                </div>
+              )}
+
+              {viewState === 'success' && (
+                <div className="flex flex-col items-center justify-center py-8 gap-3">
+                  <CheckCircle className="w-12 h-12 text-green-500" />
+                  <p className="text-lg font-semibold text-gray-900 dark:text-white">{t('bugReport.thankYou')}</p>
+                  <p className="text-sm text-gray-600 dark:text-gray-400">{t('bugReport.submitted')}</p>
+                  {issueUrl && (
+                    <a
+                      href={issueUrl}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="text-sm text-blue-500 hover:text-blue-600 underline"
+                    >
+                      {t('bugReport.viewIssue')} #{issueNumber}
+                    </a>
+                  )}
+                  <button
+                    onClick={handleClose}
+                    className="mt-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
+                  >
+                    {t('common.close')}
+                  </button>
+                </div>
+              )}
+
+              {viewState === 'error' && (
+                <div className="flex flex-col items-center justify-center py-8 gap-3">
+                  <AlertCircle className="w-12 h-12 text-red-500" />
+                  <p className="text-lg font-semibold text-gray-900 dark:text-white">{t('bugReport.submitFailed')}</p>
+                  <p className="text-sm text-gray-600 dark:text-gray-400 text-center">{errorMessage}</p>
+                  <div className="flex gap-2 mt-4">
+                    <button
+                      onClick={() => setViewState('form')}
+                      className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
+                    >
+                      {t('bugReport.submit')}
+                    </button>
+                    <button
+                      onClick={handleClose}
+                      className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
+                    >
+                      {t('common.close')}
+                    </button>
+                  </div>
+                </div>
+              )}
+            </div>
+          </div>
+        </div>
+      )}
+    </>
+  );
+}

+ 350 - 39
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, useEffect, useCallback } from 'react';
+import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
@@ -77,6 +77,7 @@ interface ConfigureAmsSlotModalProps {
   nozzleDiameter?: string;
   printerModel?: string;
   onSuccess?: () => void;
+  fullScreen?: boolean;
 }
 
 // Known filament material types
@@ -86,9 +87,23 @@ const MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', 'ABS', 'ASA', 'TPU', 'PC', 'PA',
 function parsePresetName(name: string): { material: string; brand: string; variant: string } {
   // Remove printer/nozzle suffix first
   const withoutSuffix = name.replace(/@.+$/, '').trim();
+  const upperName = withoutSuffix.toUpperCase();
+
+  // Handle "X Support for Y" pattern: the filament type is Y, not X.
+  // e.g. "PLA Support for PETG PETG Basic" → material is PETG
+  const supportMatch = upperName.match(/\bSUPPORT\s+FOR\s+/);
+  if (supportMatch) {
+    const afterSupport = upperName.slice(supportMatch.index! + supportMatch[0].length);
+    for (const mat of MATERIAL_TYPES) {
+      const regex = new RegExp(`\\b${mat}\\b`);
+      if (regex.test(afterSupport)) {
+        const brand = withoutSuffix.slice(0, supportMatch.index).trim();
+        return { material: mat, brand, variant: 'Support' };
+      }
+    }
+  }
 
   // Try to find a known material type in the name
-  const upperName = withoutSuffix.toUpperCase();
   for (const mat of MATERIAL_TYPES) {
     // Use word boundary to match whole words only
     const regex = new RegExp(`\\b${mat}\\b`, 'i');
@@ -231,6 +246,7 @@ export function ConfigureAmsSlotModal({
   nozzleDiameter = '0.4',
   printerModel,
   onSuccess,
+  fullScreen,
 }: ConfigureAmsSlotModalProps) {
   const { t } = useTranslation();
   const [selectedPresetId, setSelectedPresetId] = useState<string>('');
@@ -240,6 +256,7 @@ export function ConfigureAmsSlotModal({
   const [searchQuery, setSearchQuery] = useState('');
   const [showSuccess, setShowSuccess] = useState(false);
   const [showExtendedColors, setShowExtendedColors] = useState(false);
+  const scrolledToRef = useRef<string>('');
 
   // Fetch cloud settings (gracefully handle 401 when logged out)
   const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
@@ -321,11 +338,15 @@ export function ConfigureAmsSlotModal({
       let trayInfoIdx: string;
       let settingId: string;
 
+      // Parsed material from preset name — handles "Support for" patterns correctly.
+      // Prefer this over stored filament_type which may have been parsed with old logic.
+      const parsedMat = parsed.material.toUpperCase();
+
       if (isLocal) {
         // Local presets have no Bambu Cloud setting_id, but need a valid
         // tray_info_idx for the printer to recognize the filament type.
         // Map the material type to the closest generic Bambu filament ID.
-        const material = (localPreset?.filament_type || parsed.material || '').toUpperCase();
+        const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase();
         const GENERIC_IDS: Record<string, string> = {
           'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
           'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
@@ -373,8 +394,10 @@ export function ConfigureAmsSlotModal({
       let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
 
       if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
-        // Fall back to material-based defaults
-        const material = (isLocal ? (localPreset?.filament_type || parsed.material) : parsed.material).toUpperCase();
+        // Fall back to material-based defaults (prefer parsed material for "Support for" handling)
+        const material = (isLocal
+          ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '')
+          : parsed.material).toUpperCase();
         if (material.includes('PLA')) {
           tempMin = 190;
           tempMax = 230;
@@ -405,9 +428,10 @@ export function ConfigureAmsSlotModal({
       // Parse K value from selected profile
       const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
 
-      // Determine tray_type: use local preset's filament_type or parsed material
+      // Determine tray_type: prefer parsed material from preset name (handles "Support for"
+      // patterns correctly) over stored filament_type which may have been parsed with old logic.
       const trayType = isLocal
-        ? (localPreset?.filament_type || parsed.material || 'PLA')
+        ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA')
         : (parsed.material || 'PLA');
 
       // Configure the slot via MQTT
@@ -563,8 +587,12 @@ export function ConfigureAmsSlotModal({
     } else if (cloudSettings?.filament) {
       const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
       presetName = cp?.name || null;
+    } else {
+      // No cloud settings available
+    }
+    if (!presetName) {
+      return null;
     }
-    if (!presetName) return null;
 
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
     let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();
@@ -712,6 +740,13 @@ export function ConfigureAmsSlotModal({
         if (currentPreset) {
           setSelectedPresetId(currentPreset.setting_id);
         }
+      } else if (slotInfo.trayInfoIdx && builtinFilaments?.length) {
+        // Last resort: match trayInfoIdx against builtin presets
+        const trayIdx = slotInfo.trayInfoIdx;
+        const match = builtinFilaments.find(bf => bf.filament_id === trayIdx);
+        if (match) {
+          setSelectedPresetId(`builtin_${match.filament_id}`);
+        }
       }
 
       // Pre-populate color from current slot (black is valid — empty slots don't pass trayColor)
@@ -729,8 +764,9 @@ export function ConfigureAmsSlotModal({
       setColorInput('');
       setSearchQuery('');
       setShowSuccess(false);
+      scrolledToRef.current = '';
     }
-  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament]);
+  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]);
 
   // Auto-select best matching K profile when preset changes
   useEffect(() => {
@@ -764,29 +800,69 @@ export function ConfigureAmsSlotModal({
     }
   }, [isOpen, handleKeyDown]);
 
-  if (!isOpen) return null;
-
   const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading;
+
+  // Scroll selected preset into view when data finishes loading or the selection changes.
+  // Uses a ref guard so scrollIntoView only fires once per selection, preventing the
+  // infinite scroll loop that occurred on Windows with inline callback refs.
+  useEffect(() => {
+    if (!isLoading && selectedPresetId && selectedPresetId !== scrolledToRef.current) {
+      const raf = requestAnimationFrame(() => {
+          const modal = document.querySelector('[class*="fixed inset-0 z-50"]');
+          const el = modal?.querySelector(`[data-preset-id="${CSS.escape(selectedPresetId)}"]`);
+        if (el) {
+          scrolledToRef.current = selectedPresetId;
+          el.scrollIntoView({ block: 'nearest' });
+        }
+      });
+      return () => cancelAnimationFrame(raf);
+    }
+  }, [selectedPresetId, isLoading]);
+
+  if (!isOpen) return null;
   const canSave = selectedPresetId && !configureMutation.isPending;
 
   // Get display color (custom or slot default)
   const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center">
+    <div className={`fixed inset-0 z-50 flex ${fullScreen ? '' : 'items-center justify-center'}`}>
       {/* Backdrop */}
-      <div
-        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
-        onClick={onClose}
-      />
+      {!fullScreen && (
+        <div
+          className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+          onClick={onClose}
+        />
+      )}
 
       {/* Modal */}
-      <div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+      <div className={fullScreen
+        ? 'relative w-full h-full bg-bambu-dark-secondary flex flex-col'
+        : 'relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl'
+      }>
         {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
           <div className="flex items-center gap-2">
             <Settings2 className="w-5 h-5 text-bambu-blue" />
             <h2 className="text-lg font-semibold text-white">{t('configureAmsSlot.title')}</h2>
+            {/* Inline slot info in fullScreen mode */}
+            {fullScreen && (
+              <div className="flex items-center gap-2 ml-4 text-sm text-bambu-gray">
+                <span className="text-white/30">|</span>
+                {slotInfo.trayColor && (
+                  <span
+                    className="w-4 h-4 rounded-full border border-white/20"
+                    style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
+                  />
+                )}
+                <span className="text-white/70">
+                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
+                </span>
+                {slotInfo.traySubBrands && (
+                  <span>({slotInfo.traySubBrands})</span>
+                )}
+              </div>
+            )}
           </div>
           <button
             onClick={onClose}
@@ -797,7 +873,7 @@ export function ConfigureAmsSlotModal({
         </div>
 
         {/* Content */}
-        <div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
+        <div className={`p-4 overflow-y-auto ${fullScreen ? 'flex-1 min-h-0' : 'space-y-4 max-h-[60vh]'}`}>
           {/* Success overlay */}
           {showSuccess && (
             <div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
@@ -810,28 +886,265 @@ export function ConfigureAmsSlotModal({
           )}
 
           {/* Slot info */}
-          <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
-            <div className="flex items-center gap-2">
-              {slotInfo.trayColor && (
-                <span
-                  className="w-4 h-4 rounded-full border border-white/20"
-                  style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
-                />
-              )}
-              <span className="text-white font-medium">
-                {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
-              </span>
-              {slotInfo.traySubBrands && (
-                <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
-              )}
+          {!fullScreen && (
+            <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+              <p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
+              <div className="flex items-center gap-2">
+                {slotInfo.trayColor && (
+                  <span
+                    className="w-4 h-4 rounded-full border border-white/20"
+                    style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
+                  />
+                )}
+                <span className="text-white font-medium">
+                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
+                </span>
+                {slotInfo.traySubBrands && (
+                  <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
+                )}
+              </div>
             </div>
-          </div>
+          )}
 
           {isLoading ? (
             <div className="flex justify-center py-8">
               <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
             </div>
+          ) : fullScreen ? (
+            /* Two-column layout for kiosk display */
+            <div className="flex gap-4 h-full">
+              {/* Left column: Filament preset list (takes full height) */}
+              <div className="w-1/2 flex flex-col min-h-0">
+                <label className="block text-sm text-bambu-gray mb-2">
+                  {t('configureAmsSlot.filamentProfile')} <span className="text-red-400">*</span>
+                </label>
+                <input
+                  type="text"
+                  placeholder={t('configureAmsSlot.searchPresets')}
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2 shrink-0"
+                />
+                <div className="flex-1 min-h-0 overflow-y-auto space-y-1">
+                  {filteredPresets.length === 0 ? (
+                    <p className="text-center py-4 text-bambu-gray">
+                      {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
+                        ? t('configureAmsSlot.noPresetsAvailable')
+                        : t('configureAmsSlot.noMatchingPresets')}
+                    </p>
+                  ) : (
+                    filteredPresets.map((preset) => (
+                      <button
+                        key={preset.id}
+                        data-preset-id={preset.id}
+                        onClick={() => setSelectedPresetId(preset.id)}
+                        className={`w-full p-2 rounded-lg border text-left transition-colors ${
+                          selectedPresetId === preset.id
+                            ? 'bg-bambu-green/20 border-bambu-green'
+                            : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
+                        }`}
+                      >
+                        <div className="flex items-center justify-between">
+                          <span className="text-white text-sm truncate">{preset.name}</span>
+                          <div className="flex items-center gap-1 flex-shrink-0">
+                            {preset.source === 'local' && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
+                                {t('profiles.localProfiles.badge')}
+                              </span>
+                            )}
+                            {preset.source === 'builtin' && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
+                                {t('configureAmsSlot.builtin')}
+                              </span>
+                            )}
+                            {preset.isUser && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
+                                {t('configureAmsSlot.custom')}
+                              </span>
+                            )}
+                          </div>
+                        </div>
+                      </button>
+                    ))
+                  )}
+                </div>
+              </div>
+
+              {/* Right column: K Profile + Color */}
+              <div className="w-1/2 flex flex-col gap-4 min-h-0 overflow-y-auto">
+                {/* K Profile Select */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-2">
+                    {t('configureAmsSlot.kProfileLabel')}
+                    {selectedMaterial && (
+                      <span className="ml-2 text-xs text-bambu-blue">
+                        {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
+                      </span>
+                    )}
+                  </label>
+                  {matchingKProfiles.length > 0 ? (
+                    <div className="relative">
+                      <select
+                        value={selectedKProfile?.name || ''}
+                        onChange={(e) => {
+                          const profile = matchingKProfiles.find(p => p.name === e.target.value);
+                          setSelectedKProfile(profile || null);
+                        }}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
+                      >
+                        <option value="">{t('configureAmsSlot.noKProfile')}</option>
+                        {matchingKProfiles.map((profile) => (
+                          <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                            {profile.name} (K={profile.k_value})
+                          </option>
+                        ))}
+                      </select>
+                      <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                    </div>
+                  ) : selectedPresetId ? (
+                    <p className="text-sm text-bambu-gray italic py-2">
+                      {t('configureAmsSlot.noMatchingKProfiles')}
+                    </p>
+                  ) : (
+                    <span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
+                      {t('configureAmsSlot.selectFilamentFirst')}
+                    </span>
+                  )}
+                  {selectedKProfile && (
+                    <p className="text-xs text-bambu-green mt-1">
+                      {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
+                    </p>
+                  )}
+                </div>
+
+                {/* Custom color */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-2">
+                    {t('configureAmsSlot.customColorLabel')}
+                  </label>
+                  {catalogColors.length > 0 && (
+                    <div className="mb-3">
+                      <p className="text-xs text-bambu-gray mb-1.5">
+                        {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
+                      </p>
+                      <div className="flex flex-wrap gap-1.5">
+                        {catalogColors.map((entry) => (
+                          <button
+                            key={entry.id}
+                            onClick={() => {
+                              const hex = entry.hex_color.replace('#', '').toUpperCase();
+                              setColorHex(hex);
+                              setColorInput(entry.color_name);
+                            }}
+                            className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
+                              colorHex === entry.hex_color.replace('#', '').toUpperCase()
+                                ? 'border-bambu-green scale-105'
+                                : 'border-white/20 hover:border-white/40'
+                            }`}
+                            title={entry.color_name}
+                          >
+                            <span
+                              className="w-4 h-4 rounded-full border border-white/30 flex-shrink-0"
+                              style={{ backgroundColor: entry.hex_color }}
+                            />
+                            <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
+                          </button>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+                  <div className="flex flex-wrap gap-1.5 mb-2">
+                    {QUICK_COLORS_BASIC.map((color) => (
+                      <button
+                        key={color.hex}
+                        onClick={() => {
+                          setColorHex(color.hex);
+                          setColorInput(color.name);
+                        }}
+                        className={`w-7 h-7 rounded-md border-2 transition-all ${
+                          colorHex === color.hex
+                            ? 'border-bambu-green scale-110'
+                            : 'border-white/20 hover:border-white/40'
+                        }`}
+                        style={{ backgroundColor: `#${color.hex}` }}
+                        title={color.name}
+                      />
+                    ))}
+                    <button
+                      onClick={() => setShowExtendedColors(!showExtendedColors)}
+                      className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
+                      title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
+                    >
+                      {showExtendedColors ? '−' : '+'}
+                    </button>
+                  </div>
+                  {showExtendedColors && (
+                    <div className="flex flex-wrap gap-1.5 mb-2">
+                      {QUICK_COLORS_EXTENDED.map((color) => (
+                        <button
+                          key={color.hex}
+                          onClick={() => {
+                            setColorHex(color.hex);
+                            setColorInput(color.name);
+                          }}
+                          className={`w-7 h-7 rounded-md border-2 transition-all ${
+                            colorHex === color.hex
+                              ? 'border-bambu-green scale-110'
+                              : 'border-white/20 hover:border-white/40'
+                          }`}
+                          style={{ backgroundColor: `#${color.hex}` }}
+                          title={color.name}
+                        />
+                      ))}
+                    </div>
+                  )}
+                  <div className="flex gap-2 items-center">
+                    <div
+                      className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
+                      style={{ backgroundColor: `#${displayColor}` }}
+                    />
+                    <input
+                      type="text"
+                      placeholder={t('configureAmsSlot.colorPlaceholder')}
+                      value={colorInput}
+                      onChange={(e) => {
+                        const input = e.target.value;
+                        setColorInput(input);
+                        const nameHex = colorNameToHex(input);
+                        if (nameHex) {
+                          setColorHex(nameHex);
+                        } else {
+                          const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
+                          if (cleaned.length === 6) {
+                            setColorHex(cleaned);
+                          } else if (cleaned.length === 3) {
+                            setColorHex(cleaned.split('').map(c => c + c).join(''));
+                          }
+                        }
+                      }}
+                      className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
+                    />
+                    {colorHex && (
+                      <button
+                        onClick={() => {
+                          setColorHex('');
+                          setColorInput('');
+                        }}
+                        className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
+                        title={t('configureAmsSlot.clearCustomColor')}
+                      >
+                        {t('configureAmsSlot.clear')}
+                      </button>
+                    )}
+                  </div>
+                  {colorHex && (
+                    <p className="text-xs text-bambu-gray mt-1.5">
+                      {t('configureAmsSlot.hexLabel', { hex: colorHex })}
+                    </p>
+                  )}
+                </div>
+              </div>
+            </div>
           ) : (
             <>
               {/* Filament Profile Select */}
@@ -858,9 +1171,7 @@ export function ConfigureAmsSlotModal({
                       filteredPresets.map((preset) => (
                         <button
                           key={preset.id}
-                          ref={selectedPresetId === preset.id ? (el) => {
-                            el?.scrollIntoView({ block: 'nearest' });
-                          } : undefined}
+                          data-preset-id={preset.id}
                           onClick={() => setSelectedPresetId(preset.id)}
                           className={`w-full p-2 rounded-lg border text-left transition-colors ${
                             selectedPresetId === preset.id
@@ -1079,7 +1390,7 @@ export function ConfigureAmsSlotModal({
         </div>
 
         {/* Footer */}
-        <div className="flex justify-between p-4 border-t border-bambu-dark-tertiary">
+        <div className="flex justify-between p-4 border-t border-bambu-dark-tertiary shrink-0">
           {/* Reset button on the left */}
           <Button
             variant="secondary"

+ 8 - 0
frontend/src/components/Dashboard.tsx

@@ -157,6 +157,14 @@ export function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideCo
         // Ensure sizes exist (for backwards compatibility)
         if (!parsed.sizes) {
           parsed.sizes = getDefaultSizes();
+        } else {
+          // Merge in default sizes for any new widgets not in saved layout
+          const defaults = getDefaultSizes();
+          for (const id in defaults) {
+            if (!(id in parsed.sizes)) {
+              parsed.sizes[id] = defaults[id];
+            }
+          }
         }
         return parsed;
       } catch {

+ 1 - 11
frontend/src/components/FilamentHoverCard.tsx

@@ -1,6 +1,7 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
+import { isLightColor } from '../utils/colors';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -136,17 +137,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     return '#22c55e'; // green
   };
 
-  // Determine if color is light (for text contrast on swatch)
-  const isLightColor = (hex: string | null): boolean => {
-    if (!hex) return false;
-    const cleanHex = hex.replace('#', '');
-    const r = parseInt(cleanHex.slice(0, 2), 16);
-    const g = parseInt(cleanHex.slice(2, 4), 16);
-    const b = parseInt(cleanHex.slice(4, 6), 16);
-    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
-    return luminance > 0.6;
-  };
-
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
   const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 

+ 290 - 131
frontend/src/components/FilamentTrends.tsx

@@ -1,4 +1,5 @@
 import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
 import {
   AreaChart,
   Area,
@@ -7,55 +8,37 @@ import {
   CartesianGrid,
   Tooltip,
   ResponsiveContainer,
-  BarChart,
-  Bar,
   PieChart,
   Pie,
   Cell,
-  Legend,
 } from 'recharts';
-import type { Archive } from '../api/client';
+import type { ArchiveSlim } from '../api/client';
+import { MetricToggle, type Metric } from './MetricToggle';
 import { parseUTCDate } from '../utils/date';
+import { formatWeight } from '../utils/weight';
 
 interface FilamentTrendsProps {
-  archives: Archive[];
+  archives: ArchiveSlim[];
   currency?: string;
+  dateFrom?: string;
+  dateTo?: string;
 }
 
-type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
-
 const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
 
-function getDateRange(range: TimeRange): Date {
-  const now = new Date();
-  switch (range) {
-    case '7d':
-      return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
-    case '30d':
-      return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
-    case '90d':
-      return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
-    case '365d':
-      return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
-    case 'all':
-      return new Date(0);
-  }
-}
-
-export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
-  const [timeRange, setTimeRange] = useState<TimeRange>('30d');
+const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+const HOUR_SUFFIXES = ['12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm'];
 
-  // Filter archives by time range
-  const filteredArchives = useMemo(() => {
-    const startDate = getDateRange(timeRange);
-    return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
-  }, [archives, timeRange]);
+export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) {
+  const { t } = useTranslation();
+  const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
+  const [colorMetric, setColorMetric] = useState<Metric>('weight');
 
   // Calculate daily usage data
   const dailyData = useMemo(() => {
     const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
 
-    filteredArchives.forEach(archive => {
+    archives.forEach(archive => {
       const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       // Use local date string for grouping
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
@@ -73,25 +56,69 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         ...d,
         dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
       }));
-  }, [filteredArchives]);
+  }, [archives]);
 
-  // Calculate weekly aggregated data for longer time ranges
+  // Compute effective span in days from props or archive spread
+  const spanDays = useMemo(() => {
+    if (dateFrom && dateTo) {
+      return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;
+    }
+    if (dateFrom) {
+      return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;
+    }
+    if (archives.length < 2) return 0;
+    const times = archives.map(a => new Date(a.completed_at || a.created_at).getTime());
+    return (Math.max(...times) - Math.min(...times)) / 86400000;
+  }, [archives, dateFrom, dateTo]);
+
+  // Calculate hourly data for short timeframes (≤ 7 days)
+  const hourlyData = useMemo(() => {
+    if (spanDays > 7) return [];
+
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+    const multiDay = spanDays > 1;
+
+    archives.forEach(archive => {
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
+      const h = date.getHours();
+      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`;
+
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      existing.filament += archive.filament_used_grams || 0;
+      existing.cost += archive.cost || 0;
+      existing.prints += archive.quantity || 1;
+      dataMap.set(key, existing);
+    });
+
+    return Array.from(dataMap.values())
+      .sort((a, b) => a.date.localeCompare(b.date))
+      .map(d => {
+        const [datePart, hourPart] = d.date.split('T');
+        const dt = new Date(datePart);
+        const h = parseInt(hourPart, 10);
+        const label = multiDay
+          ? `${DAY_NAMES[dt.getDay()]} ${HOUR_SUFFIXES[h]}`
+          : HOUR_SUFFIXES[h];
+        return { ...d, dateLabel: label };
+      });
+  }, [archives, spanDays]);
+
+  // Calculate weekly aggregated data when there are many daily points
   const weeklyData = useMemo(() => {
-    if (timeRange === '7d' || timeRange === '30d') return dailyData;
+    if (dailyData.length <= 60) return dailyData;
 
     const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
 
-    filteredArchives.forEach(archive => {
-      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
-      // Get week start (Sunday)
+    dailyData.forEach(day => {
+      const date = new Date(day.date);
       const weekStart = new Date(date);
       weekStart.setDate(date.getDate() - date.getDay());
       const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
-      existing.filament += archive.filament_used_grams || 0;
-      existing.cost += archive.cost || 0;
-      existing.prints += archive.quantity || 1;
+      existing.filament += day.filament;
+      existing.cost += day.cost;
+      existing.prints += day.prints;
       dataMap.set(key, existing);
     });
 
@@ -102,13 +129,13 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
         ...d,
       }));
-  }, [filteredArchives, dailyData, timeRange]);
+  }, [dailyData]);
 
   // Usage by filament type
   const filamentTypeData = useMemo(() => {
     const dataMap = new Map<string, number>();
 
-    filteredArchives.forEach(archive => {
+    archives.forEach(archive => {
       const type = archive.filament_type || 'Unknown';
       // Handle multiple types (e.g., "PLA, PETG")
       const types = type.split(', ');
@@ -121,80 +148,119 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
     return Array.from(dataMap.entries())
       .map(([name, value]) => ({ name, value: Math.round(value) }))
       .sort((a, b) => b.value - a.value);
-  }, [filteredArchives]);
+  }, [archives]);
 
-  // Monthly comparison data
-  const monthlyComparison = useMemo(() => {
-    const now = new Date();
-    const months: { month: string; filament: number; cost: number; prints: number }[] = [];
+  // Usage by filament type (print count)
+  const filamentTypePrintData = useMemo(() => {
+    const dataMap = new Map<string, number>();
+    archives.forEach(archive => {
+      const type = archive.filament_type || 'Unknown';
+      const types = type.split(', ');
+      types.forEach(t => {
+        dataMap.set(t, (dataMap.get(t) || 0) + 1);
+      });
+    });
+    return Array.from(dataMap.entries())
+      .map(([name, value]) => ({ name, value }))
+      .sort((a, b) => b.value - a.value);
+  }, [archives]);
 
-    for (let i = 5; i >= 0; i--) {
-      const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
-      const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
-      const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
+  // Usage by filament type (print time in hours)
+  const filamentTypeTimeData = useMemo(() => {
+    const dataMap = new Map<string, number>();
+    archives.forEach(archive => {
+      const type = archive.filament_type || 'Unknown';
+      const types = type.split(', ');
+      const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length;
+      types.forEach(t => {
+        dataMap.set(t, (dataMap.get(t) || 0) + seconds);
+      });
+    });
+    return Array.from(dataMap.entries())
+      .map(([name, seconds]) => ({ name, value: Math.round((seconds / 3600) * 10) / 10 }))
+      .sort((a, b) => b.value - a.value);
+  }, [archives]);
 
-      const monthArchives = archives.filter(a => {
-        const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0);
-        return d >= monthDate && d <= monthEnd;
+  // Success rate by filament type
+  const filamentSuccessData = useMemo(() => {
+    const map = new Map<string, { completed: number; failed: number }>();
+    archives.forEach(a => {
+      if (a.status !== 'completed' && a.status !== 'failed') return;
+      const types = (a.filament_type || 'Unknown').split(', ');
+      types.forEach(type => {
+        const entry = map.get(type) || { completed: 0, failed: 0 };
+        if (a.status === 'completed') entry.completed++;
+        else entry.failed++;
+        map.set(type, entry);
       });
+    });
+    return Array.from(map.entries())
+      .filter(([, v]) => v.completed + v.failed >= 2)
+      .map(([name, v]) => {
+        const total = v.completed + v.failed;
+        const rate = Math.round((v.completed / total) * 100);
+        return { name, rate, total };
+      })
+      .sort((a, b) => b.rate - a.rate);
+  }, [archives]);
+
+  // Color distribution
+  const colorData = useMemo(() => {
+    const colorMap = new Map<string, { count: number; weight: number }>();
+
+    archives.forEach(a => {
+      if (!a.filament_color) return;
+      const colors = a.filament_color.split(',').map(c => c.trim());
+      const weightPerColor = (a.filament_used_grams || 0) / colors.length;
 
-      months.push({
-        month: monthStr,
-        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
-        cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
-        prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
+      colors.forEach(hex => {
+        const entry = colorMap.get(hex) || { count: 0, weight: 0 };
+        entry.count++;
+        entry.weight += weightPerColor;
+        colorMap.set(hex, entry);
       });
-    }
+    });
 
-    return months;
-  }, [archives]);
+    return Array.from(colorMap.entries())
+      .map(([hex, data]) => ({
+        hex,
+        value: colorMetric === 'prints' ? data.count : Math.round(data.weight),
+      }))
+      .sort((a, b) => b.value - a.value);
+  }, [archives, colorMetric]);
 
-  const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
-  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
-  const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
-  const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
+  const activeFilamentTypeData =
+    filamentTypeMetric === 'weight' ? filamentTypeData :
+    filamentTypeMetric === 'prints' ? filamentTypePrintData :
+    filamentTypeTimeData;
 
-  return (
-    <div className="space-y-6">
-      {/* Time Range Selector */}
-      <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-2">
-        <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
-        <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
-          {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
-            <button
-              key={range}
-              onClick={() => setTimeRange(range)}
-              className={`px-3 py-1 text-sm rounded-md transition-colors ${
-                timeRange === range
-                  ? 'bg-bambu-green text-white'
-                  : 'text-bambu-gray hover:text-white'
-              }`}
-            >
-              {range === 'all' ? 'All' : range.replace('d', 'D')}
-            </button>
-          ))}
-        </div>
-      </div>
+  const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
+  const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
+  const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
+  const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
+  const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
 
+  return (
+    <div className="space-y-4">
       {/* Summary Cards */}
-      <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
+      <div className="grid grid-cols-3 gap-2 max-[640px]:grid-cols-1">
         <div className="bg-bambu-dark rounded-lg p-4">
-          <div className="flex items-center justify-between gap-3">
-            <p className="text-sm text-bambu-gray leading-none">Period Filament</p>
-            <p className="text-2xl font-bold text-white leading-none">{(totalFilament / 1000).toFixed(2)}kg</p>
+          <div className="flex items-center justify-between gap-2">
+            <p className="text-sm text-bambu-gray leading-none">{t('stats.periodFilament')}</p>
+            <p className="text-2xl font-bold text-white leading-none">{formatWeight(totalFilament)}</p>
           </div>
-          <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
+          <p className="text-xs text-bambu-gray">{printerCount} {t('nav.printers').toLowerCase()}</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <div className="flex items-center justify-between gap-3">
-            <p className="text-sm text-bambu-gray leading-none">Period Cost</p>
+          <div className="flex items-center justify-between gap-2">
+            <p className="text-sm text-bambu-gray leading-none">{t('stats.periodCost')}</p>
             <p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
           </div>
-          <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
+          <p className="text-xs text-bambu-gray">{totalPrints} {t('common.prints')}</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <div className="flex items-center justify-between gap-3">
-            <p className="text-sm text-bambu-gray leading-none">Avg per Print</p>
+          <div className="flex items-center justify-between gap-2">
+            <p className="text-sm text-bambu-gray leading-none">{t('stats.avgPerPrint')}</p>
             <p className="text-2xl font-bold text-white leading-none">
               {totalPrints > 0
                 ? (totalFilament / totalPrints).toFixed(0)
@@ -210,7 +276,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       {/* Usage Over Time Chart */}
       {chartData.length > 0 ? (
         <div className="bg-bambu-dark rounded-lg p-4">
-          <h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.usageOverTime')}</h4>
           <ResponsiveContainer width="100%" height={250}>
             <AreaChart data={chartData}>
               <defs>
@@ -253,21 +319,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         </div>
       ) : (
         <div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
-          No data for selected time range
+          {t('stats.noPrintDataInRange')}
         </div>
       )}
 
       {/* Bottom Charts */}
-      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
         {/* Filament Type Distribution */}
         <div className="bg-bambu-dark rounded-lg p-4">
-          <h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
-          {filamentTypeData.length > 0 ? (
+          <div className="flex items-center justify-between mb-4">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.byMaterial')}</h4>
+            <MetricToggle value={filamentTypeMetric} onChange={setFilamentTypeMetric} />
+          </div>
+          {activeFilamentTypeData.length > 0 ? (
             <div className="flex items-center gap-4">
               <ResponsiveContainer width={160} height={160}>
                 <PieChart>
                   <Pie
-                    data={filamentTypeData}
+                    data={activeFilamentTypeData}
                     cx="50%"
                     cy="50%"
                     innerRadius={40}
@@ -275,7 +344,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                     paddingAngle={2}
                     dataKey="value"
                   >
-                    {filamentTypeData.map((_, index) => (
+                    {activeFilamentTypeData.map((_, index) => (
                       <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
                     ))}
                   </Pie>
@@ -285,13 +354,18 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                       border: '1px solid #3d3d3d',
                       borderRadius: '8px',
                     }}
-                    formatter={(value) => [`${value ?? 0}g`, 'Usage']}
+                    formatter={(value) => [
+                      filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) :
+                      filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` :
+                      `${value ?? 0}`,
+                      filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints',
+                    ]}
                   />
                 </PieChart>
               </ResponsiveContainer>
               <div className="flex-1 space-y-2 overflow-hidden">
-                {filamentTypeData.map((entry, index) => {
-                  const total = filamentTypeData.reduce((sum, e) => sum + e.value, 0);
+                {activeFilamentTypeData.map((entry, index) => {
+                  const total = activeFilamentTypeData.reduce((sum, e) => sum + e.value, 0);
                   const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;
                   return (
                     <div key={entry.name} className="flex items-center gap-2 text-sm">
@@ -300,7 +374,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                         style={{ backgroundColor: COLORS[index % COLORS.length] }}
                       />
                       <span className="text-white truncate flex-1">{entry.name}</span>
-                      <span className="text-bambu-gray flex-shrink-0">{percent}%</span>
+                      <span className="text-bambu-gray flex-shrink-0">
+                        {filamentTypeMetric === 'weight' ? formatWeight(entry.value) :
+                         filamentTypeMetric === 'time' ? `${entry.value}h` :
+                         entry.value} · {percent}%
+                      </span>
                     </div>
                   );
                 })}
@@ -308,34 +386,115 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
             </div>
           ) : (
             <div className="h-[160px] flex items-center justify-center text-bambu-gray">
-              No filament data
+              {t('stats.noFilamentData')}
             </div>
           )}
         </div>
 
-        {/* Monthly Comparison */}
+        {/* Success by Material */}
         <div className="bg-bambu-dark rounded-lg p-4">
-          <h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
-          <ResponsiveContainer width="100%" height={200}>
-            <BarChart data={monthlyComparison}>
-              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
-              <XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
-              <YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
-              <Tooltip
-                contentStyle={{
-                  backgroundColor: '#2d2d2d',
-                  border: '1px solid #3d3d3d',
-                  borderRadius: '8px',
-                }}
-                formatter={(value, name) => [
-                  name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0,
-                  name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
-                ]}
-              />
-              <Legend />
-              <Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
-            </BarChart>
-          </ResponsiveContainer>
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.filamentSuccess')}</h4>
+          {filamentSuccessData.length > 0 ? (
+            <div className="space-y-1.5">
+              {filamentSuccessData.map(d => (
+                <div key={d.name} className="flex items-center gap-2 text-sm">
+                  <span className="text-white truncate w-20 flex-shrink-0">{d.name}</span>
+                  <div className="flex-1 h-1.5 bg-bambu-dark-secondary rounded-full">
+                    <div
+                      className={`h-full rounded-full transition-all ${
+                        d.rate >= 90 ? 'bg-status-ok' : d.rate >= 70 ? 'bg-status-warning' : 'bg-status-error'
+                      }`}
+                      style={{ width: `${d.rate}%` }}
+                    />
+                  </div>
+                  <span className={`font-medium flex-shrink-0 tabular-nums ${
+                    d.rate >= 90 ? 'text-status-ok' : d.rate >= 70 ? 'text-status-warning' : 'text-status-error'
+                  }`}>
+                    {d.rate}%
+                  </span>
+                  <span className="text-bambu-gray flex-shrink-0 text-xs">({d.total})</span>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <div className="h-[160px] flex items-center justify-center text-bambu-gray">
+              {t('stats.noArchiveData')}
+            </div>
+          )}
+        </div>
+
+        {/* Color Distribution */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <div className="flex items-center justify-between mb-4">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.colorDistribution')}</h4>
+            <MetricToggle value={colorMetric} onChange={setColorMetric} exclude={['time']} />
+          </div>
+          {colorData.length > 0 ? (() => {
+            const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);
+            return (
+              <div>
+                <div className="relative mx-auto" style={{ width: 160, height: 160 }}>
+                  <ResponsiveContainer width="100%" height="100%">
+                    <PieChart>
+                      <Pie
+                        data={colorData}
+                        cx="50%"
+                        cy="50%"
+                        innerRadius={45}
+                        outerRadius={70}
+                        paddingAngle={2}
+                        dataKey="value"
+                      >
+                        {colorData.map((entry, index) => (
+                          <Cell key={`color-${index}`} fill={entry.hex} stroke="#1a1a1a" strokeWidth={1} />
+                        ))}
+                      </Pie>
+                      <Tooltip
+                        contentStyle={{
+                          backgroundColor: '#2d2d2d',
+                          border: '1px solid #3d3d3d',
+                          borderRadius: '8px',
+                        }}
+                        formatter={(value) => [
+                          colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,
+                          colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),
+                        ]}
+                      />
+                    </PieChart>
+                  </ResponsiveContainer>
+                  <div className="absolute inset-0 flex flex-col items-center justify-center">
+                    <span className="text-lg font-bold text-white">
+                      {colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal}
+                    </span>
+                    <span className="text-[10px] text-bambu-gray">
+                      {colorData.length} {colorData.length === 1 ? 'color' : 'colors'}
+                    </span>
+                  </div>
+                </div>
+                <div className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
+                  {colorData.slice(0, 8).map((entry) => {
+                    const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;
+                    return (
+                      <div key={entry.hex} className="flex items-center gap-1.5 text-xs min-w-0">
+                        <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
+                          style={{ backgroundColor: entry.hex }} />
+                        <span className="text-bambu-gray truncate">
+                          {percent}%
+                        </span>
+                      </div>
+                    );
+                  })}
+                </div>
+                {colorData.length > 8 && (
+                  <p className="text-[10px] text-bambu-gray mt-1 text-center">+{colorData.length - 8} more</p>
+                )}
+              </div>
+            );
+          })() : (
+            <div className="h-[160px] flex items-center justify-center text-bambu-gray">
+              {t('stats.noColorData')}
+            </div>
+          )}
         </div>
       </div>
     </div>

+ 0 - 1
frontend/src/components/FileManagerModal.tsx

@@ -247,7 +247,6 @@ function formatStorageSize(bytes: number): string {
   return `${mb.toFixed(0)} MB`;
 }
 
-
 function getFileIcon(filename: string, isDirectory: boolean) {
   if (isDirectory) return Folder;
 

+ 352 - 0
frontend/src/components/FileUploadModal.tsx

@@ -0,0 +1,352 @@
+import { useState, useRef, type DragEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Upload,
+  X,
+  File,
+  Loader2,
+  CheckCircle,
+  XCircle,
+  Archive as ArchiveIcon,
+  Printer,
+  Image,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { LibraryFileUploadResponse } from '../api/client';
+import { Button } from './Button';
+
+interface UploadFile {
+  file: File;
+  status: 'pending' | 'uploading' | 'success' | 'error';
+  error?: string;
+  isZip?: boolean;
+  is3mf?: boolean;
+  extractedCount?: number;
+}
+
+interface FileUploadModalProps {
+  folderId: number | null;
+  onClose: () => void;
+  onUploadComplete: () => void;
+  /** Called after each file is successfully uploaded with its response data. Return a string to show an error and prevent modal from closing. */
+  onFileUploaded?: (file: LibraryFileUploadResponse) => string | void;
+  /** When true, automatically uploads the file as soon as it's added and closes the modal */
+  autoUpload?: boolean;
+  /** Validate files before adding. Return a string to reject with an error message. */
+  validateFile?: (file: File) => string | undefined;
+  /** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
+  accept?: string;
+}
+
+export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
+  const { t } = useTranslation();
+  const [files, setFiles] = useState<UploadFile[]>([]);
+  const [isDragging, setIsDragging] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [preserveZipStructure, setPreserveZipStructure] = useState(true);
+  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
+  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
+  const [uploadError, setUploadError] = useState<string | null>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(true);
+  };
+
+  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(false);
+  };
+
+  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(false);
+    addFiles(Array.from(e.dataTransfer.files));
+  };
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files) {
+      addFiles(Array.from(e.target.files));
+    }
+  };
+
+  const updateFileStatus = (file: File, update: Partial<UploadFile>) => {
+    setFiles((prev) => prev.map((f) => (f.file === file ? { ...f, ...update } : f)));
+  };
+
+  const uploadFiles = async (filesToUpload: UploadFile[]) => {
+    setIsUploading(true);
+
+    for (const uf of filesToUpload) {
+      if (uf.status !== 'pending') continue;
+
+      updateFileStatus(uf.file, { status: 'uploading' });
+
+      try {
+        if (uf.isZip) {
+          const result = await api.extractZipFile(uf.file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
+          updateFileStatus(uf.file, {
+            status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
+            extractedCount: result.extracted,
+            error: result.errors.length > 0 ? t('fileManager.zipFilesFailed', '{{count}} files failed', { count: result.errors.length }) : undefined,
+          });
+        } else {
+          const result = await api.uploadLibraryFile(uf.file, folderId, generateStlThumbnails);
+          updateFileStatus(uf.file, { status: 'success' });
+          const error = onFileUploaded?.(result);
+          if (error) {
+            setUploadError(error);
+            setFiles([]);
+            setIsUploading(false);
+            return;
+          }
+        }
+      } catch (err) {
+        updateFileStatus(uf.file, {
+          status: 'error',
+          error: err instanceof Error ? err.message : t('fileManager.uploadFailed', 'Upload failed'),
+        });
+      }
+    }
+
+    setIsUploading(false);
+    onUploadComplete();
+    onClose();
+  };
+
+  const addFiles = (newFiles: File[]) => {
+    setUploadError(null);
+    if (validateFile) {
+      for (const file of newFiles) {
+        const error = validateFile(file);
+        if (error) {
+          setUploadError(error);
+          return;
+        }
+      }
+    }
+    const toUpload: UploadFile[] = newFiles.map((file) => ({
+      file,
+      status: 'pending' as const,
+      isZip: file.name.toLowerCase().endsWith('.zip'),
+      is3mf: file.name.toLowerCase().endsWith('.3mf'),
+    }));
+    setFiles((prev) => [...prev, ...toUpload]);
+
+    if (autoUpload && newFiles.length > 0) {
+      uploadFiles(toUpload);
+    }
+  };
+
+  const removeFile = (index: number) => {
+    setFiles((prev) => prev.filter((_, i) => i !== index));
+  };
+
+  const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
+  const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
+  const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
+  const pendingCount = files.filter((f) => f.status === 'pending').length;
+  const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
+          <h2 className="text-lg font-semibold text-white">{t('fileManager.uploadFiles')}</h2>
+          <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4">
+          {/* Drop Zone */}
+          <div
+            onDragOver={handleDragOver}
+            onDragLeave={handleDragLeave}
+            onDrop={handleDrop}
+            onClick={() => fileInputRef.current?.click()}
+            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
+              isDragging
+                ? 'border-bambu-green bg-bambu-green/10'
+                : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
+            }`}
+          >
+            <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+            <p className="text-white font-medium">
+              {isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}
+            </p>
+            <p className="text-sm text-bambu-gray mt-1">{t('fileManager.orClickToBrowse')}</p>
+            <p className="text-xs text-bambu-gray/70 mt-2">{t('fileManager.allFileTypesSupported')}</p>
+          </div>
+
+          <input
+            ref={fileInputRef}
+            type="file"
+            multiple
+            accept={accept}
+            className="hidden"
+            onChange={handleFileSelect}
+          />
+
+          {/* ZIP Options */}
+          {hasZipFiles && (
+            <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-blue-300 font-medium">{t('fileManager.zipFilesDetected')}</p>
+                  <p className="text-xs text-blue-300/70 mt-1">
+                    {t('fileManager.zipExtractOptions')}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={preserveZipStructure}
+                      onChange={(e) => setPreserveZipStructure(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">{t('fileManager.preserveZipStructure')}</span>
+                  </label>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={createFolderFromZip}
+                      onChange={(e) => setCreateFolderFromZip(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">{t('fileManager.createFolderFromZip')}</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* 3MF File Info */}
+          {has3mfFiles && (
+            <div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
+                  <p className="text-xs text-purple-300/70 mt-1">
+                    {t('fileManager.threemfExtractionInfo')}
+                  </p>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* STL Thumbnail Options */}
+          {(hasStlFiles || hasZipFiles) && (
+            <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-bambu-green font-medium">{t('fileManager.stlThumbnailGeneration')}</p>
+                  <p className="text-xs text-bambu-green/70 mt-1">
+                    {hasZipFiles && !hasStlFiles
+                      ? t('fileManager.zipMayContainStl')
+                      : t('fileManager.thumbnailsCanBeGenerated')}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={generateStlThumbnails}
+                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">{t('fileManager.generateThumbnailsForStl')}</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* File List */}
+          {files.length > 0 && (
+            <div className="max-h-48 overflow-y-auto space-y-2">
+              {files.map((uploadFile, index) => (
+                <div
+                  key={index}
+                  className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
+                >
+                  {uploadFile.isZip ? (
+                    <ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
+                  ) : (
+                    <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  )}
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
+                    <p className="text-xs text-bambu-gray">
+                      {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
+                      {uploadFile.isZip && uploadFile.status === 'pending' && (
+                        <span className="text-blue-400 ml-2">• {t('fileManager.willBeExtracted')}</span>
+                      )}
+                      {uploadFile.extractedCount !== undefined && (
+                        <span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
+                      )}
+                    </p>
+                  </div>
+                  {uploadFile.status === 'pending' && (
+                    <button
+                      onClick={() => removeFile(index)}
+                      className="p-1 hover:bg-bambu-dark-tertiary rounded"
+                    >
+                      <X className="w-4 h-4 text-bambu-gray" />
+                    </button>
+                  )}
+                  {uploadFile.status === 'uploading' && (
+                    <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
+                  )}
+                  {uploadFile.status === 'success' && (
+                    <CheckCircle className="w-4 h-4 text-green-500" />
+                  )}
+                  {uploadFile.status === 'error' && (
+                    <span title={uploadFile.error}>
+                      <XCircle className="w-4 h-4 text-red-500" />
+                    </span>
+                  )}
+                </div>
+              ))}
+            </div>
+          )}
+
+          {/* Compatibility Error */}
+          {uploadError && (
+            <div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
+                <p className="text-sm text-red-300">{uploadError}</p>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <Button variant="secondary" onClick={onClose}>
+            {t('common.cancel')}
+          </Button>
+          {!allDone && (
+            <Button
+              onClick={() => uploadFiles(files)}
+              disabled={pendingCount === 0 || isUploading}
+            >
+              {isUploading ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {t('fileManager.uploading')}
+                </>
+              ) : (
+                <>
+                  <Upload className="w-4 h-4 mr-2" />
+                  {t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}
+                </>
+              )}
+            </Button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 6 - 6
frontend/src/components/GitHubBackupSettings.tsx

@@ -37,6 +37,12 @@ import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime } from '../utils/date';
 
+function formatDateTime(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  const date = new Date(dateStr);
+  return date.toLocaleString();
+}
+
 interface StatusBadgeProps {
   status: string | null;
 }
@@ -66,12 +72,6 @@ function StatusBadge({ status }: StatusBadgeProps) {
   );
 }
 
-function formatDateTime(dateStr: string | null): string {
-  if (!dateStr) return '-';
-  const date = new Date(dateStr);
-  return date.toLocaleString();
-}
-
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();

+ 2 - 0
frontend/src/components/Layout.tsx

@@ -14,6 +14,7 @@ import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
+import { BugReportBubble } from './BugReportBubble';
 
 interface NavItem {
   id: string;
@@ -1074,6 +1075,7 @@ export function Layout() {
           </Card>
         </div>
       )}
+      <BugReportBubble />
     </div>
   );
 }

+ 39 - 0
frontend/src/components/MetricToggle.tsx

@@ -0,0 +1,39 @@
+import { useTranslation } from 'react-i18next';
+
+export type Metric = 'weight' | 'prints' | 'time';
+
+const METRICS: Metric[] = ['weight', 'prints', 'time'];
+
+interface MetricToggleProps {
+  value: Metric;
+  onChange: (metric: Metric) => void;
+  exclude?: Metric[];
+}
+
+export function MetricToggle({ value, onChange, exclude }: MetricToggleProps) {
+  const { t } = useTranslation();
+
+  const labels: Record<Metric, string> = {
+    weight: t('stats.filamentByWeight'),
+    prints: t('stats.filamentByPrints'),
+    time: t('stats.filamentByTime'),
+  };
+
+  const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS;
+
+  return (
+    <div className="flex gap-0.5 bg-bambu-dark rounded-lg p-0.5">
+      {metrics.map(m => (
+        <button
+          key={m}
+          onClick={() => onChange(m)}
+          className={`px-2 py-0.5 text-xs rounded-md transition-colors ${
+            value === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
+          }`}
+        >
+          {labels[m]}
+        </button>
+      ))}
+    </div>
+  );
+}

+ 29 - 5
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -52,6 +52,21 @@ export function FilamentMapping({
     return map;
   }, [assignments]);
 
+  const trayRemainingWeightMap = useMemo(() => {
+    const map = new Map<number, number | null>();
+    for (const assignment of assignments || []) {
+      const isExternal = assignment.ams_id === 255;
+      const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
+      const spool = assignment.spool;
+      if (!spool) {
+        map.set(globalTrayId, null);
+        continue;
+      }
+      map.set(globalTrayId, Math.max(0, Math.round((spool.label_weight ?? 0) - (spool.weight_used ?? 0))));
+    }
+    return map;
+  }, [assignments]);
+
   const totalCost = useMemo(() => {
     let total = 0;
     for (const item of filamentComparison) {
@@ -198,11 +213,20 @@ export function FilamentMapping({
                 </option>
                 {loadedFilaments
                   .filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)
-                  .map((f) => (
-                  <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                    {f.label}: {f.type} ({f.colorName})
-                  </option>
-                ))}
+                  .map((f) => {
+                    const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
+                    const remainingLabel = remainingWeight != null
+                      ? t('printModal.slotRemainingShort', {
+                          grams: remainingWeight,
+                          defaultValue: ` - ${remainingWeight}g left`,
+                        })
+                      : '';
+                    return (
+                      <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                        {f.label}: {f.type} ({f.colorName}){remainingLabel}
+                      </option>
+                    );
+                })}
               </select>
               {/* Status icon */}
               {item.status === 'match' ? (

+ 33 - 24
frontend/src/components/PrintModal/index.tsx

@@ -43,6 +43,7 @@ export function PrintModal({
   libraryFileId,
   archiveName,
   queueItem,
+  initialSelectedPrinterIds,
   onClose,
   onSuccess,
 }: PrintModalProps) {
@@ -60,6 +61,9 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.printer_id) {
       return [queueItem.printer_id];
     }
+    if (initialSelectedPrinterIds?.length) {
+      return initialSelectedPrinterIds;
+    }
     return [];
   });
 
@@ -653,7 +657,7 @@ export function PrintModal({
       onClick={isSubmitting ? undefined : onClose}
     >
       <Card
-        className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
         onClick={(e) => e.stopPropagation()}
       >
         <CardContent className={mode === 'reprint' ? '' : 'p-0'}>
@@ -677,7 +681,10 @@ export function PrintModal({
             <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
               {mode === 'reprint' ? (
                 <>
-                  Send <span className="text-white">{archiveName}</span> to printer(s)
+                  Send <span className="text-white">{archiveName}</span> to{' '}
+                  {initialSelectedPrinterIds?.length === 1 && printers
+                    ? <span className="text-white">{printers.find(p => p.id === initialSelectedPrinterIds[0])?.name ?? 'printer(s)'}</span>
+                    : 'printer(s)'}
                 </>
               ) : (
                 <>
@@ -695,26 +702,28 @@ export function PrintModal({
               onSelect={setSelectedPlate}
             />
 
-            {/* Printer selection with per-printer mapping */}
-            <PrinterSelector
-              printers={printers || []}
-              selectedPrinterIds={selectedPrinters}
-              onMultiSelect={setSelectedPrinters}
-              isLoading={loadingPrinters}
-              allowMultiple={true}
-              showInactive={mode === 'edit-queue-item'}
-              printerMappingResults={multiPrinterMapping.printerResults}
-              filamentReqs={effectiveFilamentReqs}
-              onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
-              onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
-              assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
-              onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
-              targetModel={targetModel}
-              onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
-              targetLocation={targetLocation}
-              onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
-              slicedForModel={slicedForModel}
-            />
+            {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
+            {!initialSelectedPrinterIds?.length && (
+              <PrinterSelector
+                printers={printers || []}
+                selectedPrinterIds={selectedPrinters}
+                onMultiSelect={setSelectedPrinters}
+                isLoading={loadingPrinters}
+                allowMultiple={true}
+                showInactive={mode === 'edit-queue-item'}
+                printerMappingResults={multiPrinterMapping.printerResults}
+                filamentReqs={effectiveFilamentReqs}
+                onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
+                onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
+                assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
+                onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
+                targetModel={targetModel}
+                onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+                targetLocation={targetLocation}
+                onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
+                slicedForModel={slicedForModel}
+              />
+            )}
 
             {/* Filament override - shown in model mode when filament requirements are available */}
             {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
@@ -759,7 +768,7 @@ export function PrintModal({
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
-                defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
+                defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
               />
@@ -767,7 +776,7 @@ export function PrintModal({
 
             {/* Print options */}
             {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
-              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
+              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
             )}
 
             {/* Schedule options - only for queue modes */}

+ 2 - 0
frontend/src/components/PrintModal/types.ts

@@ -26,6 +26,8 @@ export interface PrintModalProps {
   archiveName: string;
   /** Existing queue item (only for edit-queue-item mode) */
   queueItem?: PrintQueueItem;
+  /** Pre-select specific printers when opening the modal */
+  initialSelectedPrinterIds?: number[];
   /** Handler for closing the modal */
   onClose: () => void;
   /** Handler for successful operation */

+ 256 - 0
frontend/src/components/PrinterInfoModal.tsx

@@ -0,0 +1,256 @@
+import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X, Copy, Check, Signal, Cable } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { formatDateOnly } from '../utils/date';
+import { getPrinterImage, getWifiStrength } from '../utils/printer';
+import type { Printer, PrinterStatus } from '../api/client';
+
+interface PrinterInfoModalProps {
+  printer: Printer;
+  status?: PrinterStatus;
+  totalPrintHours?: number;
+  onClose: () => void;
+}
+
+function CopyButton({ value }: { value: string }) {
+  const { t } = useTranslation();
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(value);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      // Clipboard may not be available in non-secure contexts
+    }
+  };
+
+  return (
+    <button
+      onClick={handleCopy}
+      className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+      title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
+    >
+      {copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
+    </button>
+  );
+}
+
+export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [onClose]);
+
+  const rows: { label: string; value: React.ReactNode }[] = [];
+
+  // Model
+  rows.push({
+    label: t('printers.model'),
+    value: printer.model ?? '—',
+  });
+
+  // Connection Status
+  rows.push({
+    label: t('common.status'),
+    value: (
+      <span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
+        status?.connected
+          ? 'bg-bambu-green/20 text-bambu-green'
+          : 'bg-red-500/20 text-red-400'
+      }`}>
+        <span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />
+        {status?.connected ? t('printers.status.available') : t('printers.status.offline')}
+      </span>
+    ),
+  });
+
+  // State
+  if (status?.state) {
+    const stateMap: Record<string, string> = {
+      IDLE: 'printers.status.idle',
+      RUNNING: 'printers.status.printing',
+      PAUSE: 'printers.status.paused',
+      FINISH: 'printers.status.finished',
+      FAILED: 'printers.status.error',
+    };
+    rows.push({
+      label: t('printers.state'),
+      value: t(stateMap[status.state] ?? 'printers.status.unknown'),
+    });
+  }
+
+  // IP Address
+  rows.push({
+    label: t('printers.ipAddress'),
+    value: (
+      <span className="flex items-center">
+        <span className="font-mono">{printer.ip_address}</span>
+        <CopyButton value={printer.ip_address} />
+      </span>
+    ),
+  });
+
+  // Serial Number
+  rows.push({
+    label: t('printers.serialNumber'),
+    value: (
+      <span className="flex items-center">
+        <span className="font-mono truncate">{printer.serial_number}</span>
+        <CopyButton value={printer.serial_number} />
+      </span>
+    ),
+  });
+
+  // Network connection
+  if (status?.wired_network) {
+    rows.push({
+      label: t('printers.networkLabel', 'Network'),
+      value: (
+        <span className="flex items-center gap-2">
+          <Cable className="w-4 h-4 text-bambu-green" />
+          <span className="text-bambu-green">{t('printers.connection.ethernet', 'Ethernet')}</span>
+        </span>
+      ),
+    });
+  } else if (status?.wifi_signal != null) {
+    const wifi = getWifiStrength(status.wifi_signal);
+    rows.push({
+      label: t('printers.wifiSignalLabel'),
+      value: (
+        <span className="flex items-center gap-2">
+          <Signal className={`w-4 h-4 ${wifi.color}`} />
+          <span className={wifi.color}>{t(wifi.labelKey)}</span>
+          <span className="text-bambu-gray text-xs">({status.wifi_signal} dBm)</span>
+        </span>
+      ),
+    });
+  }
+
+  // Firmware
+  rows.push({
+    label: t('printers.firmware'),
+    value: status?.firmware_version ?? '—',
+  });
+
+  // Developer Mode
+  if (status?.developer_mode != null) {
+    rows.push({
+      label: t('printers.developerMode'),
+      value: (
+        <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+          status.developer_mode
+            ? 'bg-bambu-green/20 text-bambu-green'
+            : 'bg-bambu-dark-tertiary text-bambu-gray'
+        }`}>
+          {status.developer_mode ? t('printers.enabled') : t('printers.disabled')}
+        </span>
+      ),
+    });
+  }
+
+  // Nozzle Count
+  rows.push({
+    label: t('printers.nozzleCount'),
+    value: printer.nozzle_count,
+  });
+
+  // SD Card
+  if (status?.sdcard != null) {
+    rows.push({
+      label: t('printers.sdCard'),
+      value: (
+        <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+          status.sdcard
+            ? 'bg-bambu-green/20 text-bambu-green'
+            : 'bg-bambu-dark-tertiary text-bambu-gray'
+        }`}>
+          {status.sdcard ? t('printers.inserted') : t('printers.notInserted')}
+        </span>
+      ),
+    });
+  }
+
+  // Auto-Archive
+  rows.push({
+    label: t('printers.autoArchive'),
+    value: (
+      <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+        printer.auto_archive
+          ? 'bg-bambu-green/20 text-bambu-green'
+          : 'bg-bambu-dark-tertiary text-bambu-gray'
+      }`}>
+        {printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}
+      </span>
+    ),
+  });
+
+  // Total Print Hours
+  if (totalPrintHours != null && totalPrintHours > 0) {
+    rows.push({
+      label: t('printers.totalPrintHours'),
+      value: `${Math.round(totalPrintHours)}h`,
+    });
+  }
+
+  // Location
+  if (printer.location) {
+    rows.push({
+      label: t('printers.sort.location'),
+      value: printer.location,
+    });
+  }
+
+  // Added date
+  rows.push({
+    label: t('printers.addedOn'),
+    value: formatDateOnly(printer.created_at),
+  });
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      role="dialog"
+      aria-modal="true"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent>
+          <div className="flex items-center justify-between mb-4">
+            <h2 className="text-lg font-semibold text-white">
+              {printer.name}
+            </h2>
+            <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded flex-shrink-0">
+              <X className="w-5 h-5 text-bambu-gray" />
+            </button>
+          </div>
+
+          {/* Printer Image */}
+          <div className="flex justify-center mb-4">
+            <img
+              src={getPrinterImage(printer.model)}
+              alt={printer.model ?? printer.name}
+              className="h-24 object-contain"
+            />
+          </div>
+
+          <div className="space-y-0">
+            {rows.map((row, i) => (
+              <div key={i} className="flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0">
+                <span className="text-sm text-bambu-gray whitespace-nowrap">{row.label}</span>
+                <span className="text-sm text-white text-right">{row.value}</span>
+              </div>
+            ))}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 29 - 3
frontend/src/components/SpoolFormModal.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Save, Beaker, Palette, Zap } from 'lucide-react';
+import { X, Loader2, Save, Beaker, Palette, Zap, Tag } from 'lucide-react';
 import { api } from '../api/client';
 import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import { Button } from './Button';
@@ -360,6 +360,19 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     },
   });
 
+  const deleteTagMutation = useMutation({
+    mutationFn: () =>
+      api.updateSpool(spool!.id, { tag_uid: null } as Parameters<typeof api.updateSpool>[1]),
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
   // Save K-profiles for selected calibrations
   const saveKProfiles = async (spoolId: number) => {
     if (selectedProfiles.size === 0) {
@@ -464,7 +477,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     }
   };
 
-  const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending;
+  const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending;
 
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -621,7 +634,19 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         </div>
 
         {/* Footer */}
-        <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
+        <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
+          {isEditing && (
+            <Button
+              variant="secondary"
+              onClick={() => deleteTagMutation.mutate()}
+              disabled={isPending || !spool?.tag_uid}
+              className="mr-auto"
+            >
+              <Tag className="w-4 h-4" />
+              {t('inventory.deleteTag', 'Delete Tag')}
+            </Button>
+          )}
+          <div className="flex gap-2 ml-auto">
           <Button variant="secondary" onClick={onClose}>
             {t('common.cancel')}
           </Button>
@@ -641,6 +666,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
               </>
             )}
           </Button>
+          </div>
         </div>
       </div>
     </div>

+ 4 - 5
frontend/src/components/SpoolUsageHistory.tsx

@@ -5,17 +5,16 @@ import { api } from '../api/client';
 import type { SpoolUsageRecord } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
-
-interface SpoolUsageHistoryProps {
-  spoolId: number;
-}
-
 function formatDate(dateStr: string): string {
   const date = new Date(dateStr);
   return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
     ' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
 }
 
+interface SpoolUsageHistoryProps {
+  spoolId: number;
+}
+
 const STATUS_COLORS: Record<string, string> = {
   completed: 'text-bambu-green',
   failed: 'text-red-400',

+ 5 - 10
frontend/src/components/TimelapseEditorModal.tsx

@@ -18,6 +18,7 @@ import {
 import { Button } from './Button';
 import { api } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
+import { formatMediaTime } from '../utils/date';
 
 interface TimelapseEditorModalProps {
   archiveId: number;
@@ -28,12 +29,6 @@ interface TimelapseEditorModalProps {
 
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 
-function formatTime(seconds: number): string {
-  const mins = Math.floor(seconds / 60);
-  const secs = Math.floor(seconds % 60);
-  return `${mins}:${secs.toString().padStart(2, '0')}`;
-}
-
 export function TimelapseEditorModal({
   archiveId,
   timelapseSrc,
@@ -321,7 +316,7 @@ export function TimelapseEditorModal({
               <Scissors className="w-4 h-4" />
               <span>Trim</span>
               <span className="ml-auto">
-                {formatTime(trimStart)} - {formatTime(trimEnd)} ({formatTime(trimmedDuration)})
+                {formatMediaTime(trimStart)} - {formatMediaTime(trimEnd)} ({formatMediaTime(trimmedDuration)})
               </span>
             </div>
 
@@ -435,7 +430,7 @@ export function TimelapseEditorModal({
             <div className="flex items-center gap-2 text-sm text-bambu-gray">
               <Gauge className="w-4 h-4" />
               <span>Speed</span>
-              <span className="ml-auto">{speed}x (output: {formatTime(outputDuration)})</span>
+              <span className="ml-auto">{speed}x (output: {formatMediaTime(outputDuration)})</span>
             </div>
             <div className="flex gap-1">
               {SPEED_OPTIONS.map((s) => (
@@ -523,10 +518,10 @@ export function TimelapseEditorModal({
           {/* Summary */}
           <div className="p-3 bg-bambu-dark rounded-lg text-sm space-y-1">
             <p className="text-bambu-gray">
-              <span className="text-white">Original:</span> {formatTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
+              <span className="text-white">Original:</span> {formatMediaTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
             </p>
             <p className="text-bambu-gray">
-              <span className="text-white">Output:</span> {formatTime(outputDuration)} @ {speed}x speed
+              <span className="text-white">Output:</span> {formatMediaTime(outputDuration)} @ {speed}x speed
               {audioFile && ` + music overlay`}
             </p>
           </div>

+ 3 - 8
frontend/src/components/TimelapseViewer.tsx

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
 import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
 import { Button } from './Button';
 import { TimelapseEditorModal } from './TimelapseEditorModal';
+import { formatMediaTime } from '../utils/date';
 
 interface TimelapseViewerProps {
   src: string;
@@ -97,12 +98,6 @@ export function TimelapseViewer({
     video.currentTime = Math.min(duration, video.currentTime + 5);
   };
 
-  const formatTime = (time: number) => {
-    const minutes = Math.floor(time / 60);
-    const seconds = Math.floor(time % 60);
-    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
-  };
-
   const handleDownload = () => {
     const link = document.createElement('a');
     link.href = src;
@@ -154,7 +149,7 @@ export function TimelapseViewer({
             {/* Progress bar */}
             <div className="flex items-center gap-3">
               <span className="text-xs text-bambu-gray w-12 text-right">
-                {formatTime(currentTime)}
+                {formatMediaTime(currentTime)}
               </span>
               <input
                 type="range"
@@ -168,7 +163,7 @@ export function TimelapseViewer({
                   [&::-webkit-slider-thumb]:cursor-pointer"
               />
               <span className="text-xs text-bambu-gray w-12">
-                {formatTime(duration)}
+                {formatMediaTime(duration)}
               </span>
             </div>
 

+ 82 - 0
frontend/src/components/VirtualKeyboard.css

@@ -0,0 +1,82 @@
+/*
+ * Dark theme for react-simple-keyboard — matches bambu-dark / bambu-green palette.
+ * Tailwind v4 preflight resets button display/flex, so we must explicitly
+ * restore the layout that react-simple-keyboard expects.
+ */
+
+.simple-keyboard.vkb-theme {
+  background: #1a1a1a;
+  border-top: 1px solid #333;
+  padding: 8px 4px;
+  font-family: inherit;
+}
+
+/* Row layout — Tailwind preflight strips flex from generic elements */
+.simple-keyboard.vkb-theme .hg-row {
+  display: flex !important;
+  flex-direction: row !important;
+  flex-wrap: nowrap !important;
+  gap: 4px;
+  margin-bottom: 4px;
+}
+
+.simple-keyboard.vkb-theme .hg-row:last-child {
+  margin-bottom: 0;
+}
+
+/* Key buttons — must restore inline-flex sizing */
+.simple-keyboard.vkb-theme .hg-button {
+  display: inline-flex !important;
+  align-items: center;
+  justify-content: center;
+  flex-grow: 1;
+  flex-shrink: 1;
+  flex-basis: auto;
+  background: #2d2d2d;
+  color: #e0e0e0;
+  border: none;
+  border-radius: 6px;
+  height: 44px;
+  font-size: 16px;
+  font-weight: 500;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
+  transition: background 0.1s;
+  cursor: pointer;
+  padding: 0 2px;
+  min-width: 0;
+}
+
+.simple-keyboard.vkb-theme .hg-button:active {
+  background: #00ae42;
+  color: #fff;
+}
+
+/* Functional keys */
+.simple-keyboard.vkb-theme .hg-button-bksp,
+.simple-keyboard.vkb-theme .hg-button-shift,
+.simple-keyboard.vkb-theme .hg-button-lock {
+  background: #3a3a3a;
+  color: #aaa;
+  flex-grow: 1.5;
+}
+
+.simple-keyboard.vkb-theme .hg-button-close {
+  background: #3a3a3a;
+  color: #aaa;
+  flex-grow: 2;
+  font-weight: 600;
+}
+
+.simple-keyboard.vkb-theme .hg-button-close:active {
+  background: #555;
+}
+
+.simple-keyboard.vkb-theme .hg-button-space {
+  flex-grow: 7;
+}
+
+/* Active shift/caps indicator */
+.simple-keyboard.vkb-theme .hg-activeButton {
+  background: #00ae42;
+  color: #fff;
+}

+ 187 - 0
frontend/src/components/VirtualKeyboard.tsx

@@ -0,0 +1,187 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import Keyboard from 'react-simple-keyboard';
+import 'react-simple-keyboard/build/css/index.css';
+import './VirtualKeyboard.css';
+
+const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
+
+/**
+ * Set value on a controlled React input using the native setter,
+ * then dispatch an input event so React picks up the change.
+ */
+function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
+  const setter =
+    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ??
+    Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
+  setter?.call(input, value);
+  input.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+export function VirtualKeyboard() {
+  const [visible, setVisible] = useState(false);
+  const [closing, setClosing] = useState(false);
+  const closingRef = useRef(false);
+  const [layoutName, setLayoutName] = useState('default');
+  const activeInput = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
+  const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const handleFocusIn = useCallback((e: FocusEvent) => {
+    if (closingRef.current) return;
+    const target = e.target as HTMLElement;
+
+    // Skip inputs that opt out (e.g. SpoolBuddySettingsPage numpad field)
+    if (target.closest('[data-vkb="false"]')) return;
+
+    if (target instanceof HTMLInputElement) {
+      if (!FOCUSABLE_TYPES.has(target.type)) return;
+    } else if (!(target instanceof HTMLTextAreaElement)) {
+      return;
+    }
+
+    activeInput.current = target as HTMLInputElement | HTMLTextAreaElement;
+    setVisible(true);
+    setLayoutName('default');
+
+    // Sync keyboard display with current value
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (keyboardRef.current as any)?.setInput?.(activeInput.current.value);
+
+    // Scroll input into view above the keyboard
+    setTimeout(() => {
+      target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    }, 100);
+  }, []);
+
+  const handleFocusOut = useCallback(() => {
+    // Delay to allow click on keyboard buttons to register
+    setTimeout(() => {
+      const active = document.activeElement;
+      // Keep visible if focus moved to keyboard or back to same input
+      if (
+        active &&
+        (containerRef.current?.contains(active) || active === activeInput.current)
+      ) {
+        return;
+      }
+      setVisible(false);
+      activeInput.current = null;
+    }, 150);
+  }, []);
+
+  useEffect(() => {
+    document.addEventListener('focusin', handleFocusIn);
+    document.addEventListener('focusout', handleFocusOut);
+    return () => {
+      document.removeEventListener('focusin', handleFocusIn);
+      document.removeEventListener('focusout', handleFocusOut);
+    };
+  }, [handleFocusIn, handleFocusOut]);
+
+  // Two-phase close: hide the keyboard immediately but keep the backdrop
+  // alive for 400ms to absorb the ghost click that touch devices synthesize.
+  const dismiss = useCallback(() => {
+    closingRef.current = true;
+    setClosing(true);
+    activeInput.current?.blur();
+    activeInput.current = null;
+    setTimeout(() => {
+      setVisible(false);
+      setClosing(false);
+      closingRef.current = false;
+    }, 400);
+  }, []);
+
+  const onKeyPress = useCallback((button: string) => {
+    const input = activeInput.current;
+    if (!input) return;
+
+    if (button === '{shift}') {
+      setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
+      return;
+    }
+    if (button === '{lock}') {
+      setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
+      return;
+    }
+    if (button === '{close}') {
+      dismiss();
+      return;
+    }
+    if (button === '{bksp}') {
+      setNativeValue(input, input.value.slice(0, -1));
+    } else if (button === '{space}') {
+      setNativeValue(input, input.value + ' ');
+    } else {
+      setNativeValue(input, input.value + button);
+      // Auto-unshift after typing one character (like mobile keyboards)
+      if (layoutName === 'shift') {
+        setLayoutName('default');
+      }
+    }
+
+    // Keep focus on the input
+    input.focus();
+    // Sync keyboard internal state
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (keyboardRef.current as any)?.setInput?.(input.value);
+  }, [layoutName, dismiss]);
+
+  if (!visible) return null;
+
+  return (
+    <>
+      {/* Backdrop: absorbs taps so they don't reach elements under the keyboard.
+          Stays alive during closing phase to catch ghost clicks. */}
+      <div
+        className="fixed inset-0 z-[9998] bg-transparent"
+        onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
+        onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
+        onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
+      />
+      {!closing && (
+      <div
+        ref={containerRef}
+        className="fixed bottom-0 left-0 right-0 z-[9999]"
+        onMouseDown={(e) => e.preventDefault()}
+        onTouchStart={(e) => {
+          // Prevent focus loss but allow button interaction
+          if (!(e.target as HTMLElement).closest('.hg-button')) {
+            e.preventDefault();
+          }
+        }}
+      >
+        <Keyboard
+        keyboardRef={(r: ReturnType<typeof Keyboard>) => { keyboardRef.current = r; }}
+        layoutName={layoutName}
+        onKeyPress={onKeyPress}
+        theme="simple-keyboard vkb-theme"
+        layout={{
+          default: [
+            '1 2 3 4 5 6 7 8 9 0 {bksp}',
+            'q w e r t y u i o p',
+            '{lock} a s d f g h j k l',
+            '{shift} z x c v b n m . @',
+            '{space} {close}',
+          ],
+          shift: [
+            '! @ # $ % ^ & * ( ) {bksp}',
+            'Q W E R T Y U I O P',
+            '{lock} A S D F G H J K L',
+            '{shift} Z X C V B N M , _',
+            '{space} {close}',
+          ],
+        }}
+        display={{
+          '{bksp}': '\u232B',
+          '{close}': '\u2715 Close',
+          '{shift}': '\u21E7',
+          '{lock}': '\u21EA',
+          '{space}': ' ',
+        }}
+      />
+      </div>
+      )}
+    </>
+  );
+}

+ 175 - 24
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -15,26 +15,147 @@ function getAmsName(id: number): string {
   return `AMS ${id}`;
 }
 
-function formatHumidity(value: number | null): string {
-  if (value === null || value === undefined) return '-';
-  if (value > 5) return `${value}%`;
-  return `Level ${value}`;
+// --- SVG Icons (matching PrintersPage Bambu Lab style) ---
+
+function WaterDropEmpty({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#C3C2C1"/>
+    </svg>
+  );
+}
+
+function WaterDropHalf({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#C3C2C1"/>
+      <path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
+    </svg>
+  );
 }
 
+function WaterDropFull({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
+      <path d="M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#C3C2C1"/>
+    </svg>
+  );
+}
+
+function ThermometerEmpty({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+      <circle cx="6" cy="15" r="2.5" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
+function ThermometerHalf({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <rect x="4.5" y="8" width="3" height="4.5" fill="#d4a017" rx="0.5"/>
+      <circle cx="6" cy="15" r="2" fill="#d4a017"/>
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
+function ThermometerFull({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <rect x="4.5" y="3" width="3" height="9.5" fill="#c62828" rx="0.5"/>
+      <circle cx="6" cy="15" r="2" fill="#c62828"/>
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
+// --- Threshold-colored indicators ---
+
+function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: { humidity: number; goodThreshold?: number; fairThreshold?: number }) {
+  let textColor: string;
+  let DropComponent: React.FC<{ className?: string }>;
+
+  if (humidity <= goodThreshold) {
+    textColor = '#22a352';
+    DropComponent = WaterDropEmpty;
+  } else if (humidity <= fairThreshold) {
+    textColor = '#d4a017';
+    DropComponent = WaterDropHalf;
+  } else {
+    textColor = '#c62828';
+    DropComponent = WaterDropFull;
+  }
+
+  return (
+    <div className="flex items-center gap-0.5">
+      <DropComponent className="w-3 h-3.5" />
+      <span className="font-medium tabular-nums text-xs" style={{ color: textColor }}>{humidity}%</span>
+    </div>
+  );
+}
+
+function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: { temp: number; goodThreshold?: number; fairThreshold?: number }) {
+  let textColor: string;
+  let ThermoComponent: React.FC<{ className?: string }>;
+
+  if (temp <= goodThreshold) {
+    textColor = '#22a352';
+    ThermoComponent = ThermometerEmpty;
+  } else if (temp <= fairThreshold) {
+    textColor = '#d4a017';
+    ThermoComponent = ThermometerHalf;
+  } else {
+    textColor = '#c62828';
+    ThermoComponent = ThermometerFull;
+  }
+
+  return (
+    <div className="flex items-center gap-0.5">
+      <ThermoComponent className="w-3 h-3.5" />
+      <span className="font-medium tabular-nums text-xs" style={{ color: textColor }}>{temp}°C</span>
+    </div>
+  );
+}
+
+// --- Nozzle badge ---
+
+function NozzleBadge({ side }: { side: 'L' | 'R' }) {
+  return (
+    <span
+      className="inline-flex items-center justify-center w-4 h-4 text-[9px] font-bold rounded"
+      style={{ backgroundColor: '#1a4d2e', color: '#00ae42' }}
+    >
+      {side}
+    </span>
+  );
+}
+
+// --- Components ---
+
 interface SpoolSlotProps {
   tray: AMSTray;
   slotIndex: number;
   isActive: boolean;
+  fillOverride?: number | null;
+  onClick?: () => void;
 }
 
-function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
+function SpoolSlot({ tray, slotIndex, isActive, fillOverride, onClick }: SpoolSlotProps) {
   const isEmpty = isTrayEmpty(tray);
   const color = trayColorToCSS(tray.tray_color);
+  const amsFill = tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 ? tray.remain : null;
+  const effectiveFill = fillOverride ?? amsFill;
 
   return (
-    <div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''}`}>
+    <div
+      className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}
+      onClick={onClick}
+    >
       {/* Spool visualization */}
-      <div className="relative w-14 h-14 mb-1">
+      <div className="relative w-16 h-16 mb-1">
         {isEmpty ? (
           <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
             <div className="w-3 h-3 rounded-full bg-gray-600" />
@@ -49,40 +170,52 @@ function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
           </svg>
         )}
         {isActive && (
-          <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-bambu-green rounded-full" />
+          <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-bambu-green rounded-full" />
         )}
       </div>
 
       {/* Material type */}
-      <span className="text-xs text-white/70 truncate max-w-full">
+      <span className="text-sm text-white/70 truncate max-w-full">
         {isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}
       </span>
 
       {/* Fill level bar */}
-      {!isEmpty && tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 && (
+      {!isEmpty && effectiveFill !== null && effectiveFill >= 0 && (
         <div className="w-full h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mt-1">
           <div
             className="h-full rounded-full transition-all"
             style={{
-              width: `${tray.remain}%`,
-              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
+              width: `${effectiveFill}%`,
+              backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
             }}
           />
         </div>
       )}
 
       {/* Slot number */}
-      <span className="absolute top-1 right-1 text-[10px] text-white/30">{slotIndex + 1}</span>
+      <span className="absolute top-1 right-1 text-xs text-white/30">{slotIndex + 1}</span>
     </div>
   );
 }
 
+export interface AmsThresholds {
+  humidityGood: number;
+  humidityFair: number;
+  tempGood: number;
+  tempFair: number;
+}
+
 interface AmsUnitCardProps {
   unit: AMSUnit;
   activeSlot: number | null;
+  onConfigureSlot?: (amsId: number, trayId: number, tray: AMSTray | null) => void;
+  isDualNozzle?: boolean;
+  nozzleSide?: 'L' | 'R' | null;
+  thresholds?: AmsThresholds;
+  fillOverrides?: Record<string, number>;
 }
 
-export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
+export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides }: AmsUnitCardProps) {
   const trays = unit.tray || [];
   const isHt = unit.is_ams_ht;
   const slotCount = isHt ? 1 : 4;
@@ -90,16 +223,29 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
   return (
     <div className="bg-bambu-dark-secondary rounded-lg p-3">
       {/* Header */}
-      <div className="flex items-center justify-between mb-3">
-        <span className="text-white font-medium">{getAmsName(unit.id)}</span>
-        {unit.humidity !== null && unit.humidity !== undefined && (
-          <div className="flex items-center gap-1 text-xs text-white/50">
-            <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
-            </svg>
-            <span>{formatHumidity(unit.humidity)}</span>
-          </div>
-        )}
+      <div className="flex items-center justify-between mb-2">
+        <div className="flex items-center gap-1.5">
+          <span className="text-white font-medium text-base">{getAmsName(unit.id)}</span>
+          {isDualNozzle && nozzleSide && (
+            <NozzleBadge side={nozzleSide} />
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {unit.temp != null && (
+            <TemperatureIndicator
+              temp={unit.temp}
+              goodThreshold={thresholds?.tempGood}
+              fairThreshold={thresholds?.tempFair}
+            />
+          )}
+          {unit.humidity != null && (
+            <HumidityIndicator
+              humidity={unit.humidity}
+              goodThreshold={thresholds?.humidityGood}
+              fairThreshold={thresholds?.humidityFair}
+            />
+          )}
+        </div>
       </div>
 
       {/* Slots grid */}
@@ -126,6 +272,8 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
               tray={tray}
               slotIndex={i}
               isActive={activeSlot === i}
+              fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}
+              onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
             />
           );
         })}
@@ -133,3 +281,6 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
     </div>
   );
 }
+
+// Exported for use in SpoolBuddyAmsPage compact cards
+export { HumidityIndicator, TemperatureIndicator, NozzleBadge };

+ 329 - 0
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -0,0 +1,329 @@
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
+import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
+import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
+
+function getAmsName(id: number): string {
+  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
+  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
+  return `AMS ${id}`;
+}
+
+function isTrayEmpty(tray: AMSTray): boolean {
+  return !tray.tray_type || tray.tray_type === '';
+}
+
+function trayColorToCSS(color: string | null): string {
+  if (!color) return '#808080';
+  return `#${color.slice(0, 6)}`;
+}
+
+interface AssignToAmsModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  spool: InventorySpool;
+  printerId: number | null;
+}
+
+export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const [statusMessage, setStatusMessage] = useState<string | null>(null);
+  const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
+
+  useEffect(() => {
+    if (isOpen) {
+      setStatusMessage(null);
+      setStatusType(null);
+    }
+  }, [isOpen]);
+
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    if (e.key === 'Escape') onClose();
+  }, [onClose]);
+
+  useEffect(() => {
+    if (isOpen) document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [isOpen, handleKeyDown]);
+
+  const { data: status } = useQuery<PrinterStatus>({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId!),
+    enabled: isOpen && printerId !== null,
+    refetchInterval: 5000,
+  });
+
+  const { data: printer } = useQuery({
+    queryKey: ['printer', printerId],
+    queryFn: () => api.getPrinter(printerId!),
+    enabled: isOpen && printerId !== null,
+  });
+
+  const isConnected = status?.connected ?? false;
+  const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
+  const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
+  const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
+  const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
+  const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
+
+  const cachedAmsExtruderMap = useRef<Record<string, number>>({});
+  useEffect(() => {
+    if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
+      cachedAmsExtruderMap.current = status.ams_extruder_map;
+    }
+  }, [status?.ams_extruder_map]);
+  const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
+    ? status.ams_extruder_map
+    : cachedAmsExtruderMap.current;
+
+  const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
+    if (!isDualNozzle) return null;
+    const mappedExtruderId = amsExtruderMap[String(amsId)];
+    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
+    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+    return extruderId === 1 ? 'L' : 'R';
+  }, [isDualNozzle, amsExtruderMap]);
+
+  // Assign spool to AMS slot — single API call, backend handles both
+  // DB record AND MQTT auto-configuration (same as SpoolStation).
+  const configureMutation = useMutation({
+    mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
+      if (!printerId) throw new Error('No printer selected');
+
+      await api.assignSpool({
+        spool_id: spool.id,
+        printer_id: printerId,
+        ams_id: amsId,
+        tray_id: trayId,
+      });
+
+      // Save slot preset mapping so ConfigureAmsSlotModal can show the preset
+      // (same as ConfigureAmsSlotModal does after configuring a slot)
+      if (spool.slicer_filament) {
+        const base = spool.slicer_filament.includes('_')
+          ? spool.slicer_filament.split('_')[0]
+          : spool.slicer_filament;
+        // Convert filament_id (GFL05) → setting_id (GFSL05); user presets (P*) pass through
+        const presetId = base.startsWith('GF') && !base.startsWith('GFS')
+          ? 'GFS' + base.slice(2)
+          : base;
+        const presetName = spool.subtype
+          ? `${spool.material} ${spool.subtype}`
+          : spool.material;
+        try {
+          await api.saveSlotPreset(printerId, amsId, trayId, presetId, presetName, 'cloud');
+        } catch (e) {
+          console.warn('Failed to save slot preset mapping:', e);
+        }
+      }
+    },
+    onSuccess: () => {
+      setStatusType('success');
+      setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
+      queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
+      setTimeout(() => onClose(), 1500);
+    },
+    onError: (err) => {
+      setStatusType('error');
+      setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));
+    },
+  });
+
+  const isWaiting = configureMutation.isPending;
+
+  const handleSlotClick = useCallback((amsId: number, trayId: number) => {
+    if (isWaiting) return;
+    setStatusType('info');
+    setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
+    configureMutation.mutate({ amsId, trayId });
+  }, [isWaiting, configureMutation, t]);
+
+  // Build single-slot items (HT + External)
+  const singleSlots = useMemo(() => {
+    const items: {
+      key: string; label: string; amsId: number; trayId: number;
+      tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
+    }[] = [];
+
+    for (const unit of htAms) {
+      const tray = unit.tray?.[0] || {
+        id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
+        tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
+        cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
+      };
+      items.push({
+        key: `ht-${unit.id}`, label: getAmsName(unit.id),
+        amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
+        nozzleSide: getNozzleSide(unit.id),
+      });
+    }
+
+    for (const extTray of vtTrays) {
+      const extTrayId = extTray.id ?? 254;
+      items.push({
+        key: `ext-${extTrayId}`,
+        label: isDualNozzle
+          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
+          : t('printers.ext', 'Ext'),
+        amsId: 255, trayId: extTrayId - 254, tray: extTray,
+        isEmpty: isTrayEmpty(extTray),
+        nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
+      });
+    }
+
+    return items;
+  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide]);
+
+  if (!isOpen) return null;
+
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  return (
+    <div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
+      {/* Header */}
+      <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
+        <div className="flex items-center gap-3 min-w-0">
+          <div className="w-7 h-7 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
+          <div className="min-w-0">
+            <h2 className="text-sm font-semibold text-zinc-100 truncate">
+              {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
+              <span className="font-normal text-zinc-500 ml-2">
+                {spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
+              </span>
+            </h2>
+          </div>
+        </div>
+        <button
+          onClick={onClose}
+          disabled={isWaiting}
+          className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50"
+        >
+          <X className="w-5 h-5" />
+        </button>
+      </div>
+
+      {/* Status message */}
+      {statusMessage && (
+        <div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${
+          statusType === 'info'
+            ? 'bg-blue-500/10 border-blue-500/40'
+            : statusType === 'success'
+              ? 'bg-green-500/10 border-green-500/40'
+              : 'bg-red-500/10 border-red-500/40'
+        }`}>
+          {statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />}
+          {statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-400 shrink-0" />}
+          {statusType === 'error' && <XCircle className="w-4 h-4 text-red-400 shrink-0" />}
+          <span className={`text-sm ${
+            statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
+          }`}>{statusMessage}</span>
+        </div>
+      )}
+
+      {/* AMS slots */}
+      <div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
+        {!isConnected && printerId ? (
+          <div className="flex-1 flex items-center justify-center">
+            <div className="text-center text-white/50">
+              <p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
+            </div>
+          </div>
+        ) : amsUnits.length === 0 && vtTrays.length === 0 ? (
+          <div className="flex-1 flex items-center justify-center">
+            <div className="text-center text-white/50">
+              <Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
+              <p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
+              <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
+            </div>
+          </div>
+        ) : (
+          <>
+            {/* Regular AMS — 2-col grid */}
+            {regularAms.length > 0 && (
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
+                {regularAms.map((unit) => (
+                  <AmsUnitCard
+                    key={unit.id}
+                    unit={unit}
+                    activeSlot={null}
+                    onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
+                    isDualNozzle={isDualNozzle}
+                    nozzleSide={getNozzleSide(unit.id)}
+                  />
+                ))}
+              </div>
+            )}
+
+            {/* Single-slot items (HT + External) */}
+            {singleSlots.length > 0 && (
+              <div className="flex gap-2 shrink-0">
+                {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide }) => {
+                  const color = trayColorToCSS(tray.tray_color);
+                  return (
+                    <div
+                      key={key}
+                      onClick={() => handleSlotClick(amsId, trayId)}
+                      className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${
+                        isWaiting ? 'opacity-50 pointer-events-none' : ''
+                      }`}
+                    >
+                      <div className="relative w-10 h-10 shrink-0">
+                        {isEmpty ? (
+                          <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
+                            <div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
+                          </div>
+                        ) : (
+                          <svg viewBox="0 0 56 56" className="w-full h-full">
+                            <circle cx="28" cy="28" r="26" fill={color} />
+                            <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
+                            <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
+                            <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
+                            <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
+                          </svg>
+                        )}
+                      </div>
+                      <div className="min-w-0">
+                        <div className="flex items-center gap-1">
+                          <span className="text-xs text-white/50 font-medium">{label}</span>
+                          {nozzleSide && <NozzleBadge side={nozzleSide} />}
+                        </div>
+                        <div className="text-sm text-white/80 truncate">
+                          {isEmpty ? 'Empty' : tray.tray_type || '?'}
+                        </div>
+                      </div>
+                      {!isEmpty && tray.remain != null && tray.remain >= 0 && (
+                        <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
+                          <div
+                            className="w-full rounded-full"
+                            style={{
+                              height: `${tray.remain}%`,
+                              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
+                            }}
+                          />
+                        </div>
+                      )}
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </>
+        )}
+      </div>
+
+      {/* Footer */}
+      <div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
+        <button
+          onClick={onClose}
+          disabled={isWaiting}
+          className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50"
+        >
+          {statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
+        </button>
+      </div>
+    </div>
+  );
+}

+ 12 - 11
frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx

@@ -7,7 +7,7 @@ const navItems = [
     labelKey: 'spoolbuddy.nav.dashboard',
     fallback: 'Dashboard',
     icon: (
-      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
         <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
       </svg>
     ),
@@ -17,18 +17,19 @@ const navItems = [
     labelKey: 'spoolbuddy.nav.ams',
     fallback: 'AMS',
     icon: (
-      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
         <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
       </svg>
     ),
   },
   {
-    to: '/spoolbuddy/inventory',
-    labelKey: 'spoolbuddy.nav.inventory',
-    fallback: 'Inventory',
+    to: '/spoolbuddy/write-tag',
+    labelKey: 'spoolbuddy.nav.writeTag',
+    fallback: 'Write',
     icon: (
-      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
+      <svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
+        <path strokeLinecap="round" strokeLinejoin="round" d="M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" />
       </svg>
     ),
   },
@@ -37,7 +38,7 @@ const navItems = [
     labelKey: 'spoolbuddy.nav.settings',
     fallback: 'Settings',
     icon: (
-      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
         <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
         <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
       </svg>
@@ -49,14 +50,14 @@ export function SpoolBuddyBottomNav() {
   const { t } = useTranslation();
 
   return (
-    <nav className="h-12 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-stretch shrink-0">
+    <nav className="h-14 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-stretch shrink-0">
       {navItems.map((item) => (
         <NavLink
           key={item.to}
           to={item.to}
           end={item.to === '/spoolbuddy'}
           className={({ isActive }) =>
-            `flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
+            `flex-1 flex flex-col items-center justify-center gap-1 transition-colors ${
               isActive
                 ? 'text-bambu-green bg-bambu-dark'
                 : 'text-white/50 hover:text-white/70 hover:bg-bambu-dark-tertiary'
@@ -64,7 +65,7 @@ export function SpoolBuddyBottomNav() {
           }
         >
           {item.icon}
-          <span className="text-[10px] font-medium">{t(item.labelKey, item.fallback)}</span>
+          <span className="text-xs font-medium">{t(item.labelKey, item.fallback)}</span>
         </NavLink>
       ))}
     </nav>

+ 106 - 15
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -1,15 +1,53 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
 import { Outlet } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+import { api, spoolbuddyApi } from '../../api/client';
+import { VirtualKeyboard } from '../VirtualKeyboard';
 
 export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
+  const [blanked, setBlanked] = useState(false);
+  const [displayBrightness, setDisplayBrightness] = useState(100);
+  const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
+  const lastActivityRef = useRef(Date.now());
+  const { i18n } = useTranslation();
   const sbState = useSpoolBuddyState();
 
+  // Sync language from backend settings (kiosk has its own browser with empty localStorage)
+  const { data: appSettings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+  useEffect(() => {
+    if (appSettings?.language && appSettings.language !== i18n.language) {
+      i18n.changeLanguage(appSettings.language);
+    }
+  }, [appSettings?.language, i18n]);
+
+  // Query device data to initialize display settings on any page
+  const { data: devices = [] } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 30000,
+  });
+  const device = devices[0];
+
+  // Sync display settings from device on initial load
+  const initializedRef = useRef(false);
+  useEffect(() => {
+    if (device && !initializedRef.current) {
+      setDisplayBrightness(device.display_brightness);
+      setDisplayBlankTimeout(device.display_blank_timeout);
+      initializedRef.current = true;
+    }
+  }, [device]);
+
   // Force dark theme on mount, restore on unmount
   useEffect(() => {
     const root = document.documentElement;
@@ -29,21 +67,70 @@ export function SpoolBuddyLayout() {
     }
   }, [sbState.deviceOnline]);
 
+  // Track user activity for screen blank
+  const resetActivity = useCallback(() => {
+    lastActivityRef.current = Date.now();
+    setBlanked(false);
+  }, []);
+
+  useEffect(() => {
+    window.addEventListener('pointerdown', resetActivity);
+    window.addEventListener('keydown', resetActivity);
+    return () => {
+      window.removeEventListener('pointerdown', resetActivity);
+      window.removeEventListener('keydown', resetActivity);
+    };
+  }, [resetActivity]);
+
+  // Screen blank timer
+  useEffect(() => {
+    if (displayBlankTimeout <= 0) return;
+    const interval = setInterval(() => {
+      if (Date.now() - lastActivityRef.current >= displayBlankTimeout * 1000) {
+        setBlanked(true);
+      }
+    }, 1000);
+    return () => clearInterval(interval);
+  }, [displayBlankTimeout]);
+
+  // CSS brightness filter (software dimming)
+  const brightnessStyle = displayBrightness < 100
+    ? { filter: `brightness(${displayBrightness / 100})` } as const
+    : undefined;
+
   return (
-    <div className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden">
-      <SpoolBuddyTopBar
-        selectedPrinterId={selectedPrinterId}
-        onPrinterChange={setSelectedPrinterId}
-        deviceOnline={sbState.deviceOnline}
-      />
-
-      <main className="flex-1 overflow-y-auto">
-        <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
-      </main>
-
-      <SpoolBuddyStatusBar alert={alert} />
-      <SpoolBuddyBottomNav />
-    </div>
+    <>
+      <div
+        className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden"
+        style={brightnessStyle}
+      >
+        <SpoolBuddyTopBar
+          selectedPrinterId={selectedPrinterId}
+          onPrinterChange={setSelectedPrinterId}
+          deviceOnline={sbState.deviceOnline}
+        />
+
+        <main className="flex-1 overflow-y-auto">
+          <Outlet context={{
+            selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
+            displayBrightness, setDisplayBrightness,
+            displayBlankTimeout, setDisplayBlankTimeout,
+          }} />
+        </main>
+
+        <SpoolBuddyStatusBar alert={alert} />
+        <SpoolBuddyBottomNav />
+        <VirtualKeyboard />
+      </div>
+
+      {/* Screen blank overlay — touch to wake */}
+      {blanked && (
+        <div
+          className="fixed inset-0 bg-black z-[9999]"
+          onPointerDown={(e) => { e.stopPropagation(); resetActivity(); }}
+        />
+      )}
+    </>
   );
 }
 
@@ -53,4 +140,8 @@ export interface SpoolBuddyOutletContext {
   setSelectedPrinterId: (id: number) => void;
   sbState: ReturnType<typeof useSpoolBuddyState>;
   setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;
+  displayBrightness: number;
+  setDisplayBrightness: (brightness: number) => void;
+  displayBlankTimeout: number;
+  setDisplayBlankTimeout: (timeout: number) => void;
 }

+ 2 - 2
frontend/src/components/spoolbuddy/SpoolBuddyStatusBar.tsx

@@ -29,9 +29,9 @@ export function SpoolBuddyStatusBar({ alert }: SpoolBuddyStatusBarProps) {
     : 'border-bambu-dark-tertiary';
 
   return (
-    <div className={`h-8 bg-bambu-dark-secondary border-t-2 ${borderColor} flex items-center px-3 gap-3 shrink-0`}>
+    <div className={`h-9 bg-bambu-dark-secondary border-t-2 ${borderColor} flex items-center px-3 gap-3 shrink-0`}>
       {/* Status LED */}
-      <div className={`w-3 h-3 rounded-full ${statusColor} animate-pulse`} />
+      <div className={`w-3.5 h-3.5 rounded-full ${statusColor} animate-pulse`} />
 
       {/* Status message */}
       <div className="flex-1 text-sm text-white/50 truncate">

+ 38 - 26
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -1,8 +1,9 @@
-import { useState, useEffect } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useState, useEffect, useMemo } from 'react';
+import { useQuery, useQueries } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { WifiOff } from 'lucide-react';
 import { api, type Printer } from '../../api/client';
+import { formatTimeOnly } from '../../utils/date';
 
 interface SpoolBuddyTopBarProps {
   selectedPrinterId: number | null;
@@ -19,12 +20,31 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     queryFn: () => api.getPrinters(),
   });
 
-  // Auto-select first printer
+  // Fetch status for each printer to determine which are online
+  const statusQueries = useQueries({
+    queries: printers.map((printer: Printer) => ({
+      queryKey: ['printerStatus', printer.id],
+      queryFn: () => api.getPrinterStatus(printer.id),
+      refetchInterval: 10000,
+    })),
+  });
+
+  const onlinePrinters = useMemo(() => {
+    return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);
+  }, [printers, statusQueries]);
+
+  // Auto-select first online printer
   useEffect(() => {
-    if (!selectedPrinterId && printers.length > 0) {
-      onPrinterChange(printers[0].id);
+    const currentStillOnline = onlinePrinters.some((p: Printer) => p.id === selectedPrinterId);
+    if ((!selectedPrinterId || !currentStillOnline) && onlinePrinters.length > 0) {
+      onPrinterChange(onlinePrinters[0].id);
     }
-  }, [printers, selectedPrinterId, onPrinterChange]);
+  }, [onlinePrinters, selectedPrinterId, onPrinterChange]);
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
 
   // Clock - update every second for kiosk display
   useEffect(() => {
@@ -32,19 +52,11 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     return () => clearInterval(timer);
   }, []);
 
-  const formatTime = (date: Date) =>
-    date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-
   return (
-    <div className="h-11 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
+    <div className="h-12 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
       {/* Logo */}
-      <div className="flex items-center gap-2 shrink-0">
-        <div className="w-6 h-6 rounded bg-bambu-green flex items-center justify-center">
-          <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
-          </svg>
-        </div>
-        <span className="text-white font-semibold text-sm">SpoolBuddy</span>
+      <div className="flex items-center shrink-0">
+        <img src="/img/spoolbuddy_logo_dark_small.png" alt="SpoolBuddy" width={113} height={28} className="h-7 w-auto" />
       </div>
 
       {/* Printer selector - centered */}
@@ -52,12 +64,12 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
         <select
           value={selectedPrinterId ?? ''}
           onChange={(e) => onPrinterChange(Number(e.target.value))}
-          className="bg-bambu-dark text-white text-sm px-3 py-1.5 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[150px]"
+          className="bg-bambu-dark text-white text-base px-4 py-2 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[180px]"
         >
-          {printers.length === 0 ? (
-            <option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
+          {onlinePrinters.length === 0 ? (
+            <option value="">{t('spoolbuddy.status.noPrinters', 'No printers online')}</option>
           ) : (
-            printers.map((printer: Printer) => (
+            onlinePrinters.map((printer: Printer) => (
               <option key={printer.id} value={printer.id}>
                 {printer.name}
               </option>
@@ -69,7 +81,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
       {/* Right side indicators */}
       <div className="flex items-center gap-3 shrink-0">
         {/* WiFi signal bars */}
-        <div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
+        <div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}>
           {deviceOnline ? (
             <div className="flex items-end gap-0.5 h-4">
               {[1, 2, 3, 4].map((level) => (
@@ -87,13 +99,13 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
 
         {/* Device LED */}
         <div className="flex items-center gap-1.5">
-          <div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />
-          <span className="text-xs text-white/50">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
+          <div className={`w-3 h-3 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />
+          <span className="text-sm text-white/50">{deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}</span>
         </div>
 
         {/* Clock */}
-        <span className="text-white/50 text-sm font-mono min-w-[50px] text-right">
-          {formatTime(currentTime)}
+        <span className="text-white/50 text-base font-mono min-w-[50px] text-right">
+          {formatTimeOnly(currentTime, settings?.time_format || 'system')}
         </span>
       </div>
     </div>

+ 30 - 11
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -24,12 +24,12 @@ function getDefaultCoreWeight(): number {
 interface SpoolInfoCardProps {
   spool: MatchedSpool;
   scaleWeight: number | null;
-  weightStable: boolean;
   onClose?: () => void;
   onSyncWeight?: () => void;
+  onAssignToAms?: () => void;
 }
 
-export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyncWeight }: SpoolInfoCardProps) {
+export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms }: SpoolInfoCardProps) {
   const { t } = useTranslation();
   const [syncing, setSyncing] = useState(false);
   const [synced, setSynced] = useState(false);
@@ -66,7 +66,7 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
   const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
 
   const handleSyncWeight = async () => {
-    if (scaleWeight === null || !weightStable) return;
+    if (scaleWeight === null) return;
     setSyncing(true);
     try {
       await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
@@ -131,7 +131,7 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
       </div>
 
       {/* Details grid */}
-      <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-3 w-full">
+      <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full">
         <div className="flex justify-between">
           <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
           <span className="font-mono text-zinc-300">{grossWeight !== null ? `${grossWeight}g` : '\u2014'}</span>
@@ -150,16 +150,16 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
             <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
               {grossWeight}g
               {isMatch ? (
-                <Check className="w-3 h-3" />
+                <Check className="w-3.5 h-3.5" />
               ) : (
                 <>
-                  <AlertTriangle className="w-3 h-3" />
+                  <AlertTriangle className="w-3.5 h-3.5" />
                   <button
                     onClick={handleSyncWeight}
                     className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
                     title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
                   >
-                    <RefreshCw className="w-3.5 h-3.5" />
+                    <RefreshCw className="w-4 h-4" />
                   </button>
                 </>
               )}
@@ -178,13 +178,23 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
 
       {/* Action buttons */}
       <div className="flex gap-2 justify-center">
+        {onAssignToAms && (
+          <button
+            onClick={onAssignToAms}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
+          </button>
+        )}
         <button
           onClick={handleSyncWeight}
-          disabled={!weightStable || scaleWeight === null || syncing}
+          disabled={scaleWeight === null || syncing}
           className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
             synced
               ? 'bg-green-600/20 text-green-400'
-              : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
+              : onAssignToAms
+                ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
+                : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
           }`}
         >
           {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
@@ -207,10 +217,11 @@ interface UnknownTagCardProps {
   scaleWeight: number | null;
   coreWeight?: number;
   onLinkSpool?: () => void;
+  onAddToInventory?: () => void;
   onClose?: () => void;
 }
 
-export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onClose }: UnknownTagCardProps) {
+export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {
   const { t } = useTranslation();
   const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();
   const grossWeight = scaleWeight !== null
@@ -240,10 +251,18 @@ export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, o
         </div>
       )}
       <div className="flex flex-wrap gap-2 justify-center">
+        {onAddToInventory && (
+          <button
+            onClick={onAddToInventory}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
+          </button>
+        )}
         {onLinkSpool && (
           <button
             onClick={onLinkSpool}
-            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
           >
             <svg className="w-4 h-4 inline-block mr-1.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
               <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />

+ 362 - 0
frontend/src/components/spoolbuddy/TagDetectedModal.tsx

@@ -0,0 +1,362 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Check, RefreshCw, AlertTriangle, X } from 'lucide-react';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import { spoolbuddyApi } from '../../api/client';
+import { SpoolIcon } from './SpoolIcon';
+
+// Storage key for default core weight (shared with SpoolInfoCard)
+const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
+
+function getDefaultCoreWeight(): number {
+  try {
+    const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);
+    if (stored) {
+      const weight = parseInt(stored, 10);
+      if (weight >= 0 && weight <= 500) return weight;
+    }
+  } catch {
+    // Ignore errors
+  }
+  return 250;
+}
+
+interface TagDetectedModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  spool: MatchedSpool | null;
+  tagUid: string | null;
+  scaleWeight: number | null;
+  weightStable: boolean;
+  onSyncWeight: () => void;
+  onAssignToAms: () => void;
+  onLinkSpool?: () => void;
+  onAddToInventory: () => void;
+}
+
+export function TagDetectedModal({
+  isOpen,
+  onClose,
+  spool,
+  tagUid,
+  scaleWeight,
+  weightStable,
+  onSyncWeight,
+  onAssignToAms,
+  onLinkSpool,
+  onAddToInventory,
+}: TagDetectedModalProps) {
+  const [syncing, setSyncing] = useState(false);
+  const [synced, setSynced] = useState(false);
+
+  // Reset sync state when spool changes
+  useEffect(() => {
+    setSyncing(false);
+    setSynced(false);
+  }, [spool?.id]);
+
+  // Handle escape key
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    if (e.key === 'Escape') onClose();
+  }, [onClose]);
+
+  useEffect(() => {
+    if (isOpen) {
+      document.addEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = 'hidden';
+    }
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = '';
+    };
+  }, [isOpen, handleKeyDown]);
+
+  if (!isOpen) return null;
+
+  const handleSyncWeight = async () => {
+    if (scaleWeight === null || !weightStable || !spool) return;
+    setSyncing(true);
+    try {
+      await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
+      setSynced(true);
+      onSyncWeight();
+      setTimeout(() => setSynced(false), 3000);
+    } catch (e) {
+      console.error('Failed to sync weight:', e);
+    } finally {
+      setSyncing(false);
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-fade-in" onClick={onClose}>
+      <div
+        className="bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {spool ? (
+          <KnownSpoolView
+            spool={spool}
+            scaleWeight={scaleWeight}
+            weightStable={weightStable}
+            syncing={syncing}
+            synced={synced}
+            onSyncWeight={handleSyncWeight}
+            onAssignToAms={onAssignToAms}
+            onClose={onClose}
+          />
+        ) : (
+          <UnknownTagView
+            tagUid={tagUid}
+            scaleWeight={scaleWeight}
+            onAddToInventory={onAddToInventory}
+            onLinkSpool={onLinkSpool}
+            onClose={onClose}
+          />
+        )}
+      </div>
+    </div>
+  );
+}
+
+// --- Known spool view ---
+
+interface KnownSpoolViewProps {
+  spool: MatchedSpool;
+  scaleWeight: number | null;
+  weightStable: boolean;
+  syncing: boolean;
+  synced: boolean;
+  onSyncWeight: () => void;
+  onAssignToAms: () => void;
+  onClose: () => void;
+}
+
+function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onSyncWeight, onAssignToAms, onClose }: KnownSpoolViewProps) {
+  const { t } = useTranslation();
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  const coreWeight = (spool.core_weight && spool.core_weight > 0)
+    ? spool.core_weight
+    : getDefaultCoreWeight();
+
+  const grossWeight = scaleWeight !== null
+    ? Math.round(Math.max(0, scaleWeight))
+    : null;
+
+  const remaining = grossWeight !== null
+    ? Math.round(Math.max(0, grossWeight - coreWeight))
+    : null;
+
+  const labelWeight = Math.round(spool.label_weight || 1000);
+  const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
+  const fillColor = fillPercent !== null
+    ? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'
+    : '#808080';
+
+  // Weight comparison
+  const netWeight = Math.max(0, (spool.label_weight || 0) - (spool.weight_used || 0));
+  const calculatedWeight = netWeight + coreWeight;
+  const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;
+  const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
+
+  return (
+    <div className="p-6">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-5">
+        <h2 className="text-lg font-semibold text-zinc-100">
+          {t('spoolbuddy.modal.spoolDetected', 'Spool Detected')}
+        </h2>
+        <button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
+          <X className="w-5 h-5" />
+        </button>
+      </div>
+
+      {/* Spool info */}
+      <div className="flex items-start gap-5 mb-5">
+        <div className="relative shrink-0">
+          <SpoolIcon color={colorHex} isEmpty={false} size={100} />
+          {fillPercent !== null && (
+            <div
+              className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
+              style={{ backgroundColor: fillColor }}
+            >
+              {fillPercent}%
+            </div>
+          )}
+        </div>
+
+        <div className="flex-1 min-w-0 pt-1">
+          <h3 className="text-lg font-semibold text-zinc-100">
+            {spool.color_name || 'Unknown color'}
+          </h3>
+          <p className="text-sm text-zinc-400">
+            {spool.brand} &bull; {spool.material}
+            {spool.subtype && ` ${spool.subtype}`}
+          </p>
+
+          {remaining !== null && (
+            <div className="mt-3">
+              <div className="flex items-baseline gap-2">
+                <span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
+                <span className="text-sm text-zinc-500">/ {labelWeight}g</span>
+              </div>
+              <p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
+
+              <div className="mt-2 max-w-xs">
+                <div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
+                  <div
+                    className="h-full rounded-full transition-all duration-500"
+                    style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}
+                  />
+                </div>
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Details grid */}
+      <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-900/50 rounded-lg p-4 mb-5">
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
+          <span className="font-mono text-zinc-300">{grossWeight !== null ? `${grossWeight}g` : '\u2014'}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
+          <span className="font-mono text-zinc-300">{coreWeight}g</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
+          <span className="font-mono text-zinc-300">{labelWeight}g</span>
+        </div>
+        <div className="flex justify-between items-center">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
+          {grossWeight !== null ? (
+            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
+              {grossWeight}g
+              {isMatch ? <Check className="w-3.5 h-3.5" /> : <AlertTriangle className="w-3.5 h-3.5" />}
+            </span>
+          ) : (
+            <span className="text-zinc-500">{'\u2014'}</span>
+          )}
+        </div>
+        <div className="flex justify-between items-center">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
+          <span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
+            {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
+          </span>
+        </div>
+      </div>
+
+      {/* Action buttons */}
+      <div className="flex gap-3">
+        <button
+          onClick={onAssignToAms}
+          className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
+        </button>
+        <button
+          onClick={onSyncWeight}
+          disabled={!weightStable || scaleWeight === null || syncing}
+          className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${
+            synced
+              ? 'bg-green-600/20 text-green-400'
+              : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
+          }`}
+        >
+          {syncing ? (
+            <RefreshCw className="w-4 h-4 animate-spin inline-block mr-1.5" />
+          ) : synced ? (
+            <Check className="w-4 h-4 inline-block mr-1.5" />
+          ) : null}
+          {syncing
+            ? t('spoolbuddy.modal.syncing', 'Syncing...')
+            : synced
+              ? t('spoolbuddy.modal.weightSynced', 'Synced!')
+              : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
+        </button>
+        <button
+          onClick={onClose}
+          className="px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.dashboard.close', 'Close')}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+// --- Unknown tag view ---
+
+interface UnknownTagViewProps {
+  tagUid: string | null;
+  scaleWeight: number | null;
+  onAddToInventory: () => void;
+  onLinkSpool?: () => void;
+  onClose: () => void;
+}
+
+function UnknownTagView({ tagUid, scaleWeight, onAddToInventory, onLinkSpool, onClose }: UnknownTagViewProps) {
+  const { t } = useTranslation();
+  const grossWeight = scaleWeight !== null
+    ? Math.round(Math.max(0, scaleWeight))
+    : null;
+
+  return (
+    <div className="p-6">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-5">
+        <h2 className="text-lg font-semibold text-zinc-100">
+          {t('spoolbuddy.modal.newTagDetected', 'New Tag Detected')}
+        </h2>
+        <button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
+          <X className="w-5 h-5" />
+        </button>
+      </div>
+
+      {/* Tag info */}
+      <div className="flex flex-col items-center text-center mb-6">
+        <div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center mb-4">
+          <svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
+          </svg>
+        </div>
+
+        <p className="text-sm text-zinc-500 font-mono mb-3">{tagUid}</p>
+
+        {grossWeight !== null && (
+          <div className="text-sm text-zinc-400">
+            <span className="font-mono font-semibold text-zinc-200 text-lg">{grossWeight}g</span>
+            <span className="ml-2">{t('spoolbuddy.dashboard.onScale', 'on scale')}</span>
+          </div>
+        )}
+      </div>
+
+      {/* Action buttons */}
+      <div className="flex gap-3">
+        <button
+          onClick={onAddToInventory}
+          className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
+        </button>
+        {onLinkSpool && (
+          <button
+            onClick={onLinkSpool}
+            className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
+          </button>
+        )}
+        <button
+          onClick={onClose}
+          className="px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.dashboard.close', 'Close')}
+        </button>
+      </div>
+    </div>
+  );
+}

+ 14 - 0
frontend/src/contexts/AuthContext.tsx

@@ -30,6 +30,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 
   const checkAuthStatus = async () => {
     try {
+      // Bootstrap: if URL has ?token= param, store it and strip from URL.
+      // Allows SpoolBuddy kiosk to pass API key via URL on first load.
+      const urlParams = new URLSearchParams(window.location.search);
+      const urlToken = urlParams.get('token');
+      if (urlToken) {
+        setAuthToken(urlToken);
+        urlParams.delete('token');
+        const cleanSearch = urlParams.toString();
+        const cleanUrl = window.location.pathname
+          + (cleanSearch ? `?${cleanSearch}` : '')
+          + window.location.hash;
+        window.history.replaceState({}, '', cleanUrl);
+      }
+
       const status = await api.getAuthStatus();
       if (!mountedRef.current) return;
       setAuthEnabled(status.auth_enabled);

+ 2 - 9
frontend/src/contexts/ToastContext.tsx

@@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCi
 import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
+import { formatFileSize } from '../utils/file';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
@@ -76,14 +77,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
   const dispatchToastId = 'background-dispatch';
   const lastDispatchSummaryRef = useRef<string | null>(null);
 
-  const formatBytes = useCallback((bytes: number) => {
-    if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
-    if (bytes < 1024) return `${bytes} B`;
-    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
-  }, []);
-
   // Clean up all timeouts on unmount
   useEffect(() => {
     const timeouts = timeoutRefs.current;
@@ -502,7 +495,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                           )}
                           {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
                             <div className="text-[11px] text-bambu-gray truncate">
-                              {formatBytes(job.uploadBytes)} / {formatBytes(job.uploadTotalBytes)}
+                              {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
                               {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
                             </div>
                           )}

Some files were not shown because too many files changed in this diff