Commit Graph
364 Commits
Author SHA1 Message Date
maziggy 6569d5d1a7 refactor(timelapse): extract _maybe_start_layer_timelapse + rewrite test
The CI-only failure on test_layer_timelapse_expected_archive came from
  the test driving the entire on_print_start flow through ~12 patches and
  a MagicMock printer, which behaved differently between Python 3.11 (CI)
  and 3.13 (local) — execution stopped silently somewhere in the
  expected-archive path under CI's pytest-xdist parallelism but completed
  locally.

  Fix root-shape instead of fix the symptom:

  1. Extract the three identical start_session call sites in on_print_start
     (expected-archive promotion at 2030, fallback archive at 2554, fresh
     archive at 2644) into one helper _maybe_start_layer_timelapse() with
     the same external_camera_enabled / external_camera_url guard. The
     three inline blocks had already started drifting (#1353 originally
     only fixed one of them on the first pass) — the helper keeps them
     locked together going forward.

  2. Rewrite the test to call the helper directly. Uses SimpleNamespace
     (strict attribute access) instead of MagicMock (default-truthy), no
     DB mocking, no event loop, no parallel-state surface. Four small
     cases instead of two integration-style ones: enabled→starts,
     disabled→skips, URL-missing→skips, camera_type default 'mjpeg'.
2026-05-16 14:35:47 +02:00
maziggy 18db5ed038 fix(tests): disable plate detection in test_layer_timelapse_expected_archive
Test was passing locally but failing in CI on the merge of v0.2.4.1.
  Root cause: MagicMock returns a truthy default for any unset attribute,
  so mock_printer.plate_detection_enabled was truthy and the production
  code's plate detection block ran for real. Locally with ffmpeg present
  the capture path completed cleanly (try/except swallowed errors) and
  flow continued to start_session. In CI without ffmpeg, the failure path
  in capture_camera_frame_bytes prevented execution from reaching the
  expected-archive branch's start_session call, so the assert_called_once
  on the patched start_session tripped.

  Plate detection isn't the subject under test (start_session in the
  expected-archive branch is). Setting plate_detection_enabled = False
  explicitly on the mock skips that block entirely and makes the test
  robust to environmental differences. Test also drops from ~9s to ~2s
  since real frame-capture attempts no longer run.
2026-05-16 14:12:05 +02:00
maziggy c5132e9839 chore(tests): replace /tmp/ literal in test_layer_timelapse_expected_archive.py to clear Bandit B108
Synthetic value on mock_archive.file_path was "/tmp/fake.3mf" - Bandit
  flagged it as "Probable insecure usage of temp file/directory" even
  though the string is never used as a filesystem path. Swapped to
  "/test/archives/fake.3mf", matching the pattern used in commit 9f1188d7
  to clear the same finding on test_library_trash_api.py and
  test_pending_upload_display_name.py. No suppression comment needed -
  the new path no longer matches Bandit's regex.
2026-05-16 13:45:28 +02:00
maziggy ce5f4e5f1a fix(camera): don't open competing socket while a viewer is attached (#1348)
Obico polling could freeze the live camera stream within seconds of opening
  the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame
  saw an empty _last_frames[printer_id] entry (stream startup before the first
  JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell
  through to capture_camera_frame_bytes() and opened a second RTSP socket. On
  firmwares that allow only one camera connection, that second socket forced
  the printer to drop the live fan-out connection - the viewer's ffmpeg then
  hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing
  the next Obico poll, and the broadcaster pump exited.

  Widen the gate from "do we have a buffered frame?" to "is any fan-out stream
  registered for this printer?". New is_stream_active() helper checks
  _active_streams / _active_chamber_streams independently of buffer state.
  _capture_frame consults it first: if a viewer is attached, it returns the
  buffered frame when available or None (skip this poll cycle) when not. Never
  opens a competing socket while a viewer is connected.

  Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag).
  Benefit: zero competing-socket events while any viewer is connected.

  try_get_active_buffered_frame() refactored to delegate to is_stream_active()
  so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged
  behaviorally (snapshot is a user-initiated single-shot; falling through to
  fresh capture on an empty buffer is the desired behavior there).
2026-05-16 12:31:11 +02:00
maziggy 856b849ffa fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
2026-05-16 11:34:40 +02:00
maziggy 072b8c8ce1 fix(vp): preserve AMS/vt_tray/net across incremental push_status updates (#1371)
The bridge cache replaced _latest_print_state wholesale on every
  push_status arrival. Bambu firmware sends full pushall responses
  (with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
  requests, but ~1 Hz incremental updates with only the fields that
  changed. The first incremental push after a pushall therefore wiped
  AMS info from the bridge cache, and slicers reading the cache (via
  the VP's 1 Hz status push) saw a stripped-down state with no AMS
  visible until the next pushall — typically only on a manual printer
  power-cycle.

  Preserve a small set of slicer-visible sticky keys from the previous
  cache when the incoming push doesn't carry them: ams, vt_tray,
  ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
  same pattern Bambuddy uses for its own internal state.raw_data.
2026-05-16 09:21:19 +02:00
maziggy 5680f5d34b fix(scheduler): watchdogs no longer falsely treat FINISH->IDLE as "print landed" (#1370)
Both the queue-side _watchdog_print_start and the direct-dispatch
  _verify_print_response used `status.state != pre_state` to decide
  whether a project_file command had been accepted. When a printer was
  in FINISH at dispatch time (un-dismissed post-print prompt from a
  prior job), the firmware silently rejected the new command; if the
  user then dismissed the screen prompt, the printer moved FINISH ->
  IDLE and the watchdog returned early as "command landed" — leaving
  the queue row stuck at status='printing' indefinitely and the
  scheduler permanently marking the printer as busy.

  Narrow the "command landed" check in both verifiers to an allow-list
  of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
  Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
  the revert. The subtask_id-advance signal stays in place for H2D's
  slow FINISH -> PREPARE transition (#1078).

  Also wrap _watchdog_print_start's revert commit and
  printer_manager._persist_awaiting_plate_clear in run_with_retry so
  SQLite single-writer contention can't silently drop these writes.
  The revert path returns a tristate sentinel so the post-revert MQTT
  session-recovery logic only runs when we actually reverted (or the
  commit failed) — not when on_print_complete had already cleared the
  row, where a forced reconnect could break a healthy concurrent print.
2026-05-16 09:05:05 +02:00
maziggy 7aa5ff0156 fix(ams): detect spool removal on X1C firmware that reports power_on_flag=False (#1365)
The #765 guard against shutdown-time data wipes skipped any AMS update
  with power_on_flag=False, but some X1C firmware emits power_on_flag=False
  while idle with tray_exist_bits still reflecting the real slot inventory.
  Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
  the bitfield path is the only signal — muting it left spool removals
  undetected until a manual reconnect.

  Narrow the skip to the exact shutdown pattern: zero bits AND
  power_on_flag=False. Non-zero bits with power_on_flag=False are now
  applied. The #765 shutdown protection is preserved (its regression test
  uses tray_exist_bits='0' and still passes); newer firmwares are
  unaffected because their per-tray state path catches the removal first.
2026-05-16 08:16:06 +02:00
maziggy 5e88ce13f0 fix(notifications): accept discordapp.com webhook URLs (#1363)
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
  hostnames serve the same webhooks. The validation now accepts either
  prefix while keeping the check itself in place to catch the
  paste-the-wrong-thing error.
2026-05-16 08:00:39 +02:00
maziggy b5a83924eb fix(cost): top-up untracked filament at default rate so multi-color
archives stop reporting near-zero cost (#1344)

  Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
  global default filament cost set to $10/kg. archive.py initial cost
  calc was correct (~$1.10), then usage_tracker.on_print_complete
  overwrote archive.cost with sum(r.cost for r in results) -- where
  results only includes AMS trays mapped to a spool in Bambuddy's
  inventory. On a multi-color print where 3 of 4 used trays had no
  inventory spool, only the one tracked slot's tiny share (~1g) survived
  and the archive recorded $0.01.

  The overwrite logic dates to #505 (Feb 2026) and is correct for
  fully-tracked single-color prints, but the multi-color slicer feature
  in 0.2.4 (988c0055) made the partial-inventory state common -- users
  slice + print multi-color from Bambuddy without first setting up an
  inventory entry for every tray.

  Cover the gap: any filament weight not represented in results gets
  charged at the global default rate. For a fully-tracked print,
  untracked grams = 0 and the top-up adds nothing, so the single-color
  behavior is preserved. For a partial print, the missing slots are
  priced at the user's documented default rate so the archive cost
  reflects the whole print.

  Three call sites updated to share the same logic:
    - usage_tracker.py: live cost-update on print complete
    - archives.py rescan_archive: per-archive manual recalc
    - archives.py recalculate_all_costs: bulk recalc button
2026-05-15 15:43:37 +02:00
maziggy f2e3de0a63 fix(camera): start layer timelapse for queue/VP-dispatched prints (#1353)
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
  print queue and got no MP4 output even though the log said "Stitching layer
  timelapse for printer 1" after each print. Support bundle confirmed the
  external camera was working (Obico was polling the snapshot URL fine for
  plate detection).

  Root cause: start_session() only ran in the two new-archive paths in
  on_print_start (fallback_archive at main.py:2510 and regular new-archive at
  2600). The expected-archive branch at main.py:1981-2052 — where every
  reprint and every queue/VP-dispatched print lands — updated the existing
  archive row to status=printing but never started a timelapse session.

  So _background_layer_timelapse ran at print complete, called tl_complete(),
  found nothing in _active_sessions, returned None silently, and the wrapper
  at main.py:3917 produced no log message for the no-session case. Every
  print through the queue silently lost its timelapse — likely the reason
  this hasn't been caught before (direct slice-and-send-to-printer prints
  take the new-archive path and work fine).

  Fix: mirror the same start_session() call in the expected-archive branch,
  guarded by the same external_camera_enabled + external_camera_url check the
  other two paths use.

  Also reworded the snapshot URL help text across all 8 locales to make clear
  that timelapse and plate detection each require their own per-printer
  toggle — the URL is just the image source they pull from when active. The
  previous wording read as if filling in the URL was sufficient.
2026-05-15 12:46:59 +02:00
maziggy 7d3af9834c fix(inventory): emulate state=9 for bare-tray empty-slot signal on P1S/A1 (#1322)
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
  payload and found that P1S and A1 Mini send only {"id": N} for a
  physically empty slot — no state, no tray_type, no other fields. Without
  that signal, the assign-spool path was firing one wasted MQTT publish per
  click on a truly-empty slot (firmware dropped it silently, but still).

  The AMS parser in printer_manager.py now detects the bare-tray shape and
  promotes it to state=9 — the firmware's explicit "no spool" code — which
  lets the existing state in {9, 10} short-circuit in the inventory route
  apply automatically.

  The detection is intentionally narrow:

      len(tray) == 1 and "id" in tray and state is None

  so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
  and tray_type="") has more than one key and stays unaffected — the #1322
  root fix is preserved.
2026-05-15 12:27:01 +02:00
maziggy d6364646f8 feat(auth): manual LDAP user provisioning from the UI (#1298)
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
  with no UI path to onboard new users — the create-user form had zero LDAP
  awareness and the only workaround was hand-editing the database.

  Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
  disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
  that returns up to 25 matches via the service-account bind, annotated with
  already_provisioned so existing usernames render disabled. Clicking
  "Provision user" re-resolves via the service bind and creates the user
  through the same _provision_ldap_user helper the auto-provision login path
  uses, so group mapping, default-group fallback, and email sync are identical
  regardless of which path created the user.

  The picker component is shared across all four create-user modal paths
  (UsersPage basic + advanced, SettingsPage basic + advanced).

  Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
  - Open the search connection with check_names=False so ldap3 doesn't reject
    the cross-schema OR filter (sAMAccountName/displayName are AD-only)
  - Request attributes=["*"] because ldap3's build_attribute_selection
    validates each named attribute against the server schema regardless of
    check_names, and only the * wildcard is in its hard-coded exclusion list

  Login/lookup paths keep check_names=True so typos in user_filter still fail
  loudly.

  Backend
  - New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
    by USERS_CREATE; 503 details include ldap3 exception class + message)
  - Extract _open_service_connection + _extract_user_info helpers so
    authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
    bind and attribute-extraction logic

  Frontend
  - New LdapUserPicker component (debounced search, result list, provision
    mutation, already-provisioned guard, error surface)
  - Tab toggle wired into UsersPage and SettingsPage modals, plus
    CreateUserAdvancedAuthModal props
  - 14 i18n keys added to en.ts (other locales fall back to English)
2026-05-15 12:05:43 +02:00
maziggy 405dd1525b fix(firmware): keep download-URL resolution working when bambulab.com 403s (#1350)
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
  misleading error "Firmware file is not available from Bambu Lab" while the
  logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
  only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
  on the reporter's network, and the buildId was cached in memory only, so a
  single 403 broke download-URL resolution for the rest of the session.

  - Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
    so the request stops tripping Cloudflare's "bare scraper" signal.
  - Persist the buildId to <data_dir>/firmware/build_id.json so a transient
    403 or a backend restart can't wipe a previously-valid buildId.
  - Add a download_page_unreachable flag and use it in the prepare-update flow
    to render an honest error ("page unreachable from this network — try later
    or download manually from bambulab.com") instead of implying Bambu doesn't
    have the file.
  - Retry the per-model JSON once when a cached buildId returns 404 (page
    rebuild), give up gracefully on 403 without churning.
2026-05-15 10:06:55 +02:00
Sn0rrii 8a7598f6b5 feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333)

Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
2026-05-15 08:50:37 +02:00
maziggy ccf985abfc feat(slicing): build-plate override in the SliceModal (#1337)
Slicing an STL via the integrated slicer always defaulted to whatever
  curr_bed_type lived in the chosen process preset (typically "Cool
  Plate"), which the slicer CLI rejected for high-temp filaments with
  "Plate 1: Cool Plate does not support filament 1". The user had no
  way to switch plates without cloning the preset in BambuStudio.

  The Slice modal now exposes a Build plate dropdown with the six
  canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
  SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
  Smooth PEI Plate) plus an "Auto (use process preset)" option that
  preserves the previous behavior. Positioned between Process profile
  and Filament rows so a long filament list never pushes it off the
  modal's scrolled viewport, and always enabled regardless of whether
  the user picked a Printer Preset Bundle.

  A new bed_type field on SliceRequest flows through both dispatch
  paths:
  - Resolved-preset path: _patch_process_bed_type overwrites
    curr_bed_type on the process JSON before forwarding to the sidecar.
    Works end-to-end today, no sidecar change needed.
  - Bundle dispatch path: slice_with_bundle adds a bedType form field
    to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
    fork) needs a matching change to honor it as --curr_bed_type on
    the CLI invocation; until then the field is silently ignored and
    the slice runs with the bundle's default plate.
2026-05-14 15:28:41 +02:00
a1d6fb22ae fix(spoolman): filter external library lookup by Bambu Lab manufacturer (#1330)
Bambuddy's external SpoolmanDB lookup in `_find_or_create_filament` matched
on material+color only, with no manufacturer filter. Because SpoolmanDB is a
multi-vendor catalog and entries are roughly ID-sorted, the first hit for
any common combination is almost always a competitor — `bambulab_pla_black_1000_175_n`
is the 15th entry for PLA + `#000000`. Bambu Lab RFID spools were being
labeled with competitor product names (`3DJAKE Black`, `3DXTECH™ Black`, etc).

Restrict the external-library loop to entries whose manufacturer is
`"Bambu Lab"` (with `id.startswith("bambulab_")` as a defensive fallback
for schema drift). When multiple Bambu Lab candidates exist, prefer the
entry whose `name` equals the AMS `tray_sub_brands` so `"PLA Basic"` wins
over generic `"Black"` when both are present. Forward `density` from the
chosen external entry so it is no longer overwritten by the PLA-default
1.24 in `create_filament`.

Six unit tests added: internal short-circuit preserved, non-Bambu external
entries skipped, PLA Basic > generic PLA tiebreaker, no-match fallback,
id-prefix defensive fallback, density propagation.

Fixes #1309

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
2026-05-14 09:14:24 +02:00
maziggy a2c9eef87c fix(safety): invert bed-jog Z direction on A1 / A1 Mini bed-slingers (#1334)
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
  control sent the nozzle straight into the build plate. Reporter
  triggered it with the 50 mm step and crashed their nozzle.

  Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
  where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
  so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
  gap). The frontend maps "Up" to negative distance with that convention
  in mind.

  A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
  firmware uses standard cartesian Z (Z+ = toolhead up). On those models
  G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
  classification at the bed-jog code path, so every printer got the same
  X1-convention G-code.

  Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
  existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
  already-defined A1_MODELS frozenset which covers display names and
  internal codes N1 / N2S). The bed-jog route now inverts the signed
  distance before emitting G-code when the printer model is in that set,
  so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
  regardless of which physical part moves. Frontend untouched, single
  source of truth lives in the backend, keyed off the Printer.model
  column. Route Query description and docstring updated to spell out the
  new contract: distance is the gap adjustment, not the raw Z value.
2026-05-14 08:58:33 +02:00
maziggy b8e350c3bf fix(spoolman): persist color_name edits and stop form round-tripping the subtype synth fallback (#1319)
Editing a spool's color name on Spoolman-backed inventory appeared to
  accept the new value but the inventory list column and the next edit
  showed it back to the subtype. Three layers stacked to produce this:

  1. find_or_create_filament matches by material/name/color_hex/vendor —
     color_name is intentionally not part of the match key, but on a
     match it returned the existing filament's id unchanged, silently
     dropping the new value.
  2. The read helper falls back to subtype when filament.color_name is
     empty (kept on purpose: without it Spoolman installs that don't
     fill the field render every spool as "Unknown color").
  3. The edit form prefilled color_name from spool.color_name — which
     on those installs was the synth value. Changing subtype but not
     color_name silently round-tripped the OLD subtype back to Spoolman
     as if it were a real user-set color_name.

  Fixes:
  - find_or_create_filament now patches the matched filament's
    color_name via the existing patch_filament wrapper when the request
    differs. Parameter convention: None = don't touch, "" = explicit
    clear, any other string = set/update. A patch failure is logged but
    does not block the match.
  - The PATCH route uses model_fields_set to distinguish "field omitted"
    from "field explicitly set to null" (mirrors the existing
    storage_location pattern at the same site).
  - The map helper returns color_name_is_synthesized: bool. The edit
    form leaves the input blank when true, so the user sees the real
    stored state and can't accidentally round-trip the synth value back.
2026-05-14 08:27:06 +02:00
Sn0rrii 4d8dbc8336 fix(auth): cleanup orphan OIDC/MFA rows when user is deleted (#1285) (#1295)
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285)

Three User-FK tables (user_oidc_links, user_totp, user_otp_codes)
declare ON DELETE CASCADE in their models, but SQLite ships with
PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored
for APIKey in PR #1182). Without explicit DELETEs, deleting a user
on SQLite leaves orphan rows behind:
2026-05-13 13:23:21 +02:00
maziggy 52d6ac419a feat(support): record slicer CLI versions; harden sidecar update docs
Issue #1312 follow-up. Investigation traced the "Name cannot be empty"
  report to a sidecar image pre-dating the /profiles/bundle endpoint
  addition. Two changes so the next occurrence is self-diagnosable from
  the support bundle without a manual curl.

  Backend: new _fetch_slicer_health(url) helper does a 2s GET on /health,
  walks every non-dataPath key under checks looking for a version field
  (the wrapper labels both sidecars as checks.orcaslicer regardless of
  which CLI is bundled). _collect_slicer_api_info now exposes
  bambu_studio_version and orcaslicer_version. Strips trailing slash
  before appending /health to avoid double-slash 404s.

  Docs: bambuddy-wiki/docs/features/slicer-api.md gains a Quick Start
  callout that branch-built sidecars don't auto-update, a corrected
  /health troubleshooting entry (both "unknown" version and "orcaslicer"
  field name on bambu-studio-api are cosmetic wrapper bugs, not stale-
  image indicators), a new "Name cannot be empty" troubleshooting entry,
  and an Updating section that requires --no-cache --pull together
  (BuildKit caches the git context separately from layers, so --no-cache
  alone silently reuses the old checkout).
2026-05-13 11:38:36 +02:00
maziggy 9cde2e37fd feat(slicer): log sidecar reject reason on bundle import failure (#1312)
The route mapped sidecar 4xx/5xx to HTTPException with detail but never
  logged it. Reporters seeing a 400 toast were giving us only the status
  code, not the reason, and the access-log line was all that landed in
  support bundles.

  Add WARNING-level logs on each error branch (400 SlicerInputError,
  503 SlicerApiUnavailableError, 502 SlicerApiError) with the sidecar's
  own message + the filename / byte count / configured URL. Next reporter
  on this code path produces a support bundle that contains the answer.
2026-05-13 10:43:13 +02:00
maziggy 1cf209d56b feat(support): audit bundle for new features; fix two leaks + slicer reachability
The settings-table passthrough auto-captured everything in `settings` (with
  sensitive-key redaction), but features storing config in dedicated tables
  were invisible. Triaging recent OIDC / 2FA / group bugs and the X1C slicer
  investigation needed data that wasn't in the bundle.

  New blocks in _collect_support_info:
    - auth: OIDC providers (cleartext names, no secrets), TOTP / OTP /
      API-key / long-lived-token / group counts
    - library: file / folder / external / trash / makerworld totals
    - inventory: spool + k-profile counts
    - queue: pending count, oldest pending age
    - maintenance: items total + enabled
    - integrations.github_backup: providers used + recent failures
    - integrations.slicer_api: enabled, URL source, reachability ping
    - per-printer obico_enabled flag

  Plus three smaller fixes caught testing against a real bundle:
    - mqtt_broker no longer leaks (broker keyword added)
    - virtual_printer_tailscale_auth_key no longer leaks (auth_key keyword
      + tskey- value-prefix safety net for future Tailscale settings)
    - slicer-API reachability check now mirrors the route's three-level URL
      precedence (DB → env var → default), instead of only looking at the
      DB setting. Previously returned null for every installation running
      the sidecar via env var or default port — i.e. most of them.
2026-05-13 10:35:45 +02:00
maziggy 82c90c6387 fix(mqtt): skip print-start fire on first RUNNING after Bambuddy startup (#1304)
Restarting Bambuddy mid-print misfired the plate-check + archive flow.
  The is_new_print guard treated _previous_gcode_state=None → RUNNING as
  a transition, but None just means we haven't seen any prior state yet —
  catch-up from a printer that was already running, not a fresh start.

  Add `_previous_gcode_state is not None` to the guard. _was_running still
  flips on unconditionally, so completion detection is unchanged. 3 tests
  that asserted the buggy behavior now seed an explicit prior state; new
  regression test pins the contract for the reporter's exact scenario.
2026-05-13 09:57:20 +02:00
maziggy db308aa80b fix(library): defer external-scan STL thumbnails + Path coerce (#1299)
External scan hung on a 1200-subdir NAS because (1) every STL crashed
  with TypeError ('str / str') inside generate_stl_thumbnail and (2)
  thumbnail generation ran synchronously per file, so the FE timed out
  before db.commit() and nothing was persisted.

  stl_thumbnail.py now coerces inputs to Path defensively, and
  scan_external_folder defers STL thumbnail generation to a background
  asyncio task that opens its own session and processes each file
  post-commit. Subdirs appear in the sidebar immediately; thumbnails
  backfill over the next seconds/minutes.
2026-05-13 09:21:20 +02:00
maziggy 1d6e1b9e88 fix(inventory): persist storage_location for internal spools (#1291)
The column existed on the Spool ORM model but was missing from
  SpoolBase, SpoolUpdate, and SpoolResponse. Pydantic silently
  dropped writes and reads omitted the field, so the inventory
  table always showed "—" in the Storage Location column even
  after saving. Adding the field to the two schemas is enough —
  the update route already uses model_dump + setattr.
2026-05-12 16:02:24 +02:00
maziggy 0c92a4d326 fix(vp): broadcast archive_created so Archives page refreshes live (#1282)
Real-printer prints broadcast archive_created from the MQTT print_start
  handler, which the Archives page listens for to invalidate its query
  cache. The VP file-receive paths created the archive in the DB but
  never emitted the event, so the new card only appeared after a tab
  switch triggered refetch-on-focus.

  Added a small _broadcast_archive_created helper on VirtualPrinterInstance
  and called it from _archive_file (immediate mode) and _add_to_print_queue
  (queue mode). Review mode is unaffected — it creates a PendingUpload,
  not a PrintArchive. Broadcast errors are swallowed at debug level so a
  transient WebSocket issue can't break the file-receive flow.
2026-05-12 13:36:32 +02:00
maziggy 59f7d736e3 Added BACKERS.md 2026-05-12 12:18:56 +02:00
maziggy 0d6171dc9a fix(vp): emit FINISH after FTP upload so Print-flow slicers unwedge (#1280)
Bambuddy's VP supports two slicer flows: Send (file upload only — what
  queue/immediate/review modes are designed for) and Print (file upload
  + start-print, intended for proxy mode). When a user clicks Print
  against a non-proxy mode the VP must still respond gracefully — the
  file is fine to receive, just the start-print never happens. Instead
  the slicer wedged at "Downloading...(0%)" and blocked the next
  dispatch with "The printer is busy with another print job".

  Cause: on_file_received transitioned gcode_state PREPARE -> IDLE
  directly. Print-flow slicers watch the state cycle and only release
  their in-flight-job lock on PREPARE -> ... -> FINISH (or FAILED).
  PREPARE -> IDLE looks like "printer abandoned my job" and keeps the
  prior job pinned in the slicer's memory.

  Fix: transition PREPARE -> FINISH with prepare_percent=100. The 1-Hz
  periodic status push broadcasts the new state to every connected
  slicer within a second. Send-flow slicers don't watch this state so
  the change is a no-op for them; Print-flow slicers see the FINISH
  they were waiting for and unwedge.
2026-05-12 10:19:34 +02:00
maziggy 7596725550 fix(mqtt): external-spool ams_filament_setting must use global tray_id (#1279)
ams_set_filament_setting and reset_ams_slot encoded the single-external
  case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0"
  comment was a misread of the printer's response (which echoes the local
  slot position), not the request semantics.

  Captured BambuStudio -> X1C exchange shows the request encoding is
  {ams_id: 255, tray_id: 254, slot_id: 0} (global tray index in tray_id).
  The previous code's tray_id: 0 is what the P1S in #1279 rejects with
  result: "fail", which silently broke external-spool filament selection
  on every Bambu printer with no AMS or external spool in active use.

  Dual-external (H2D) branch was not in the captured exchange and is
  explicitly pinned at the legacy encoding pending a Studio -> H2D capture.
2026-05-12 09:44:20 +02:00
maziggy 4ee4bdb0d3 fix(archives): scan_timelapse picked stale video at false offset (#1278)
scan_timelapse's Strategy 2 matched filename timestamps against both
  archive.started_at and archive.completed_at across seven hypothesised tz
  offsets. The filename is always print-START time, so the end-time branch
  was a semantic mistake — and the dense offset set [0, +-1, +-7, +-8]
  let an unrelated video coincidentally land within minutes of any later
  archive at some offset.

  Extract Strategy 2 into _match_timelapse_by_timestamp(): compare only
  against start time, and refuse to auto-pick when the next-best different
  video is within a 15-minute ambiguity margin. The route then returns
  available_files and the frontend's existing manual-selection dialog
  takes over — which is the fallback the reporter explicitly asked for.

  Surfaces in LAN-Only mode where the printer can't reach NTP and its
  clock drifts (e.g. P2S filenames in CST while server is in UTC, the
  8h offset that exposed this bug).
2026-05-12 09:20:53 +02:00
maziggy 6fe00adb23 fix(spoolman): resolve -1 in ams_mapping to external spool (#1276)
BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat
  ams_mapping array — a convention already documented in
  bambu_mqtt.py:start_print(). The spoolman tracking helper was treating
  -1 as "unmapped, use position-based default", which mapped slot_id=1
  to AMS tray 0 and credited external-spool prints to whatever Spoolman
  spool happened to be linked to AMS slot 0. The reporter's TPU prints
  on an H2S were credited to a PLA spool for ~49g over 4 prints before
  being noticed (regression of #853).

  When slot_to_tray[slot_id-1] == -1 and ams_trays contains 254/255,
  return the external tray ID directly. Prefers 254 over 255 (matches
  single-nozzle tray_now reporting + the vir_slot id=255->254 remap in
  bambu_mqtt.py:864). Legacy fall-through preserved for callers that
  don't pass ams_trays.

  Root cause investigation and patch by @ojimpo.
2026-05-12 09:07:49 +02:00
maziggy ae43ced0ef fix(vp): honor workflow default print options in queue-mode receive (#1235)
Prints sent from a slicer to a VP in print_queue mode arrived in the
  queue with bed_levelling / flow_cali / vibration_cali / layer_inspect /
  timelapse set to the SQLAlchemy column defaults, ignoring the user's
  workflow page settings entirely. The manual POST /print-queue endpoint
  reads these from the request body (frontend pulls them from settings
  before submitting), but manager._add_to_print_queue constructed the
  PrintQueueItem without touching any of those fields.

  Read default_bed_levelling and the other four settings via get_setting
  and pass them explicitly. _bool_setting helper handles the None ->
  AppSettings default fallback.
2026-05-12 08:59:14 +02:00
maziggy af52c4f2ff fix(spoolman): allow AMS-HT ams_id range in slot-assignment table (#1274)
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single
  tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7
  and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died
  with `CHECK constraint failed: ck_ams_id_range`. The internal
  spool_assignment table has no such constraint and works fine.

  Widen the formula to (0-7) OR (128-191) OR 255 in the model, the
  CREATE TABLE DDL, and an idempotent in-place migration for existing
  installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula
  in sqlite_master, rebuild via _v2 rename pattern).
2026-05-12 08:48:30 +02:00
maziggy c097140e4c fix(camera): share broadcaster buffered frame with Obico + /camera/snapshot (#1271)
The MJPEG fan-out broadcaster from #1089 only solved viewer-side
  concurrency. Obico polling (every 5s) and the manual /camera/snapshot
  endpoint kept opening their own fresh RTSP sockets, which X1/H2/P2
  firmwares tolerated but X2D firmware 01.01.00.00 enforces strict
  single-connection on — every poll kicked the live stream.

  Add try_get_active_buffered_frame(printer_id): returns the broadcaster's
  last buffered frame when a viewer is connected, None otherwise. Obico
  and /camera/snapshot consult it before opening a fresh socket. When no
  viewer is active they fall through to the existing fresh-capture path.

  plate_detection and layer_timelapse intentionally not converted.
2026-05-12 08:36:08 +02:00
maziggy b334d7edc9 fix(spoolman): per-print 3MF tracking is the only weight writer (#1119)
Spoolman had two mutually-exclusive weight paths gated on the
  `disable_weight_sync` flag. The default (False) used AMS remain%
  x tray_weight auto-sync, which silently dropped non-BL spools
  because the AMS doesn't report tray_weight without RFID. The
  inventory_remaining fallback would have covered it, but the
  spool_assignment table it reads from is wiped on Spoolman
  activation, so non-BL spools got no weight updates at all.

  Match the internal Filament Inventory: per-print tracking always
  runs, AMS auto-sync no longer writes remaining_weight (it still
  maintains spool metadata and slot assignments). The setting
  becomes a no-op; left in the schema and UI for backwards compat.

  - store_print_data: drop the disable_weight_sync early return
  - sync_ams_tray callsites in main.py + routes/spoolman.py: force
    disable_weight_sync=True so weight is never written by AMS sync
  - new regression test confirming tracking runs with flag=false
2026-05-12 08:15:28 +02:00
maziggy 4b7df9f30b fix(usage-tracker): skip remain% fallback for trays not used by print (#1269)
The AMS remain% delta path charged every tray with a delta, not just
  trays involved in the print. Swapping a spool in an UNUSED slot mid-
  print made the slot report remain=0 (fresh spool, no tag), versus a
  print-start snapshot of 100%, so the originally-assigned spool got
  charged the full 1000g.

  Build print_used_keys from ams_mapping, tray_change_log, and
  tray_now_at_start, and skip fallback for trays not in that set.
  Legacy "scan every tray" behavior preserved when none of the three
  signals are present.
2026-05-12 07:47:19 +02:00
maziggy b444921021 test: drop racy printer_state_to_dict patch; assert structural shape
The patch on printer_state_to_dict raced against the broadcast coroutine
  under pytest-xdist's parallel workers. Mostly won locally, lost
  occasionally on CI — surfaced first as AttributeError on .kprofiles,
  then (after _fake_state was hardened) as a dict-content mismatch
  between the patched return value and the real 36-key dict.

  Fix: stop patching printer_state_to_dict; let it run for real against
  the complete _fake_state stub. Assertions now check the broadcast fired
  with the right printer_id and a dict containing awaiting_plate_clear,
  not the exact dict shape — that decouples the test from
  printer_state_to_dict's evolving body.
2026-05-11 14:08:23 +02:00
maziggy c36bfa24d4 test: harden _fake_state against printer_state_to_dict mock-leakage
The two TestBroadcastStatusChange / TestEndToEndUnderRunningLoop tests
  patch printer_state_to_dict to return a fixed dict, but on parallel
  xdist runners (CI's pytest -n 30) the patch occasionally didn't catch
  the call and the real function ran against the 4-field SimpleNamespace
  fake — first attr access (.kprofiles) AttributeError'd, swallowed by
  the try/except in _broadcast_status_change, send_status never awaited,
  the assertion failed.

  Filled _fake_state with every attribute the real printer_state_to_dict
  reads (iterables empty, scalars None, stg_cur=0 for the int comparison
  in get_derived_status_name). Test now passes whether or not the patch
  lands.
2026-05-11 13:58:16 +02:00
BurntOutHylian 7afb303ffd feat(#1239): Update Gitea and Forgejo due to API changes from initial cut (#1255)
feat(#1239): first cut at Gitea backups silently failing after 1st run
feat(#1239): Added Token Scope for Forgejo edge case. Also included: test coverage for fixes
2026-05-11 12:27:41 +02:00
maziggy 83a83ed724 feat(labels): add 40x30 mm template, hex colour code, bolder brand (issue #809 follow-up)
Three enhancements requested by @oliboehm after the V1 label-printing
  ship in #809:

  - New box_40x30 single-label template (common DK/Brother roll size,
    good for filament-bag and storage-bin labels). Routes through the
    existing roomy layout since height >= 20 mm.

  - Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on
    every label - useful when several near-identical material/colour
    spools sit next to each other and the swatch alone isn't enough to
    tell them apart. Skipped silently when rgba is None or malformed.

  - Brand line bumped to Helvetica-Bold (was regular) and a couple of
    points larger on both layouts so it reads cleanly at arm's length.

  Wired through the SpoolLabelTemplate union, the modal's
  TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n
  key in all 8 locales (native translations for de/fr/it/ja/pt-BR/
  zh-CN/zh-TW). Modal regression test widened from 4 to 5 template
  buttons. Three new renderer tests pin the hex-code render, the
  hex-code skip on invalid rgba, and the bold-brand font reference.
2026-05-09 10:10:20 +02:00
maziggy f5ecc61cda fix(spoolbuddy): lower /update permission to INVENTORY_UPDATE so kiosk's own Settings -> Update button works
The kiosk's Settings -> Update Daemon button returned "API keys cannot
  be used for administrative operations" because POST /spoolbuddy/devices/
  {id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
  is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
  Every kiosk-side request tripped the deny-list before the API key's
  scope set (Read / Print Queue / Control / Legacy) was even consulted.

  Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
  (Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
  in that audit on the reasoning "replaces the daemon binary, different
  threat surface" — but that's wrong: restart_daemon already replaces
  the running daemon process, so daemon-replacement is not a step up in
  blast radius. The SSH update is also strictly scoped to the one device
  the operator physically controls (git fetch + pip install + systemctl
  restart on that host) — same threat profile as the system commands
  already running on INVENTORY_UPDATE.

  Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
  INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
  (calibration/tare, display, cancel-write, system/command,
  system/command-result, update-status). The main Bambuddy in-app updater
  at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
  the Bambuddy host and is correctly fenced behind the deny-list.
2026-05-08 14:28:41 +02:00
maziggy 3f58fc74b4 fix(http): RFC 6266-encode Content-Disposition so non-ASCII filenames don't crash response (issue #1245)
Reported by @1000Delta. The printer file download (and three sibling
  endpoints) raised UnicodeEncodeError: 'latin-1' codec can't encode
  characters... on any filename outside U+0000..U+00FF (Chinese,
  Japanese, Arabic, accented Latin), because the route pushed `filename`
  straight into Content-Disposition: attachment; filename="...".
  Starlette/uvicorn encodes response headers as latin-1, so the assignment
  crashed at write-time.

  New backend/app/utils/http.py::build_content_disposition emits both an
  ASCII-stripped legacy filename="..." fallback and an RFC 5987
  filename*=UTF-8''<percent-encoded> parameter. Every modern browser
  prefers the *= form, so the original Unicode filename round-trips
  through Save-As intact.

  Same shape was latent in three siblings and fixed in the same PR
  (no deferred follow-ups): archive QR endpoint (archive.print_name
  from 3MF metadata), project ZIP export (project.name — the existing
  isalnum() sanitiser passes non-ASCII through), and the PDF label
  streamer (latent today, callers ASCII-only but the helper hardens it).
2026-05-08 14:06:40 +02:00
MartinNYHC b30a283184 Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-08 11:52:42 +02:00
maziggy ceffcfaef6 fix(vp): overlay storage indicators on cached push so slicer pre-flight passes for P1S/A1 targets (issue #1228)
Slicer "Send to printer" worked on 0.2.3.2 with a queue-mode VP and
  started failing on 0.2.4b3 with BambuStudio's generic "storage needs
  to be inserted before send to printer" error. Multiple users
  reported it across P1S, P2S, Docker bridge, macvlan, and host
  networking. @rtadams89's debug-level support archive showed the
  smoking gun: slicer establishes MQTT TLS, gets pushall +
  get_version, then never opens an FTP connection — pre-flight
  rejects before any data transfer.

  The 0.2.3.2 synthetic stub baked in three SD/storage indicators
  that BambuStudio's "Send" pre-flight reads: home_flag with bit 8
  (HAS_SDCARD_NORMAL, 0x100), sdcard=True, and a storage:{free,total}
  block. The 0.2.4b3 cached-as-base slicer-mirror (7dea33d0) passes
  the live target's push_status through with only an IP rewrite — if
  the real firmware doesn't report those fields (P1S/A1 with no SD
  card, older field shapes, confirmed on P1S firmware 01.10.00.00),
  the slicer sees "no storage" and aborts. H2D and X1C reproductions
  worked because those firmwares do report the indicators.

  In _send_status_report's cached-as-base branch, after copying the
  cache and applying the existing protocol/upload-state overrides:

  - home_flag |= 0x100 (preserves any other bits the real printer set)
  - sdcard = True (force-set even when real says False)
  - storage = setdefault(...) (only fills in if missing — real values
    pass through unchanged when the printer reports them)

  For VP usage the slicer uploads via FTPS to Bambuddy's filesystem
  at /app/data/virtual_printer/uploads/<vpid>/; the printer's actual
  SD card is irrelevant on that path, so forcing "storage available"
  is correct for the queue / immediate / review modes the
  cached-as-base path covers.
2026-05-08 09:19:09 +02:00
Sn0rrii 90743cfa39 feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231)
chore(i18n): extend parity gate to all locales with strict/info tiers

  Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
  drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
  STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
  report informationally until their drift is caught up. ja notably has 27 real
  placeholder bugs worth fixing before promotion to strict.
2026-05-08 09:01:51 +02:00
maziggy 233808956b ● fix(backup): Gitea wraps GitCommit in Commit schema — extract tree SHA from both shapes (issue #1224 follow-up)
Subsequent backups against Gitea 1.24+ failed with the opaque
  "Backup failed: 'tree'" message after the initial-backup fix landed in
  7ee89b56. Root cause: Gitea's GET /repos/{owner}/{repo}/git/commits/{sha}
  returns the wrapped Commit schema where the tree lives at
  data["commit"]["tree"]["sha"], whereas GitHub's same-named Git Database
  endpoint returns the unwrapped GitCommit schema with tree at the top
  level. The bare commit_response.json()["tree"]["sha"] lookup at
  gitea.py:109 raised KeyError: 'tree' and the broad except in push_files
  surfaced it as the opaque "Backup failed: 'tree'" string — masking the
  real shape mismatch.

  Adds a _commit_tree_sha() helper that tries the flat shape first
  (GitHub-compatible / older Gitea) and falls back to the wrapped shape
  (Gitea 1.24+, Forgejo). Returns None on truly malformed responses;
  push_files maps that to a clear "Failed to extract tree SHA from commit
  response" instead of leaking a KeyError repr. Keeps the existing-files
  diff working on both shapes so subsequent backups don't re-upload every
  blob — preferred over the .get()-and-skip approach which would have
  required also dropping base_tree from the tree POST and re-uploading
  unchanged files on every backup.
2026-05-08 07:00:22 +02:00
MartinNYHC dac2a31192 Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit 55d71498e9.
2026-05-07 11:30:31 +02:00
Sn0rrii 55d71498e9 feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker (#1114)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-07 11:15:24 +02:00
maziggy 972e635233 fix(spool-tag-matcher): filter catalog lookup by material variant, not hex alone (issue #1227)
Three Bambu Lab catalog rows share #FFFFFF — Jade White (PLA Basic),
  Ivory White (PLA Matte), White (PLA Silk). The catalog lookup in
  create_spool_from_tray filtered by manufacturer + hex only with no
  ORDER BY, so SQLite returned rows in rowid order and the first-inserted
  entry (Jade White) won every RFID-driven spool creation regardless of
  the actual material the AMS reported. Inserting an Ivory White PLA
  Matte roll always produced a spool named "Jade White".

  Same class of bug bites any other shared-hex pair across PLA Basic /
  Matte / Silk; the whites were just the most visible.

  Fix: add a material filter using tray_sub_brands (the printer-reported
  material variant — "PLA Matte" / "PLA Basic" / "PLA Silk"), which
  matches the catalog's `material` column directly. Use the raw
  tray_sub_brands value (captured before the gradient/dual/tri-color
  subtype upgrade) because the catalog stores "PLA Basic" for gradient
  rolls too — the upgraded subtype lives on the spool, not the catalog.

  Also add ORDER BY id to the query so the fallback path (empty
  tray_sub_brands — third-party spools / OpenTag tags) is deterministic
  across SQLite + PostgreSQL instead of DB-implementation-defined.

  Tests: 4 new in test_spool_tag_matcher.py — Ivory White PLA Matte
  resolves to Ivory not Jade (the regression pin), PLA Silk White
  resolves to White, Jade White PLA Basic still works with all three
  #FFFFFF entries seeded, and the empty-sub_brands fallback stays
  deterministic via the new ORDER BY.

  Existing spools already mis-named in the database don't auto-correct
  on next AMS read — the matcher only fires on new RFID-driven creation.
  Affected users need a manual rename in Inventory after upgrading.
2026-05-07 10:31:27 +02:00