Commit Graph
1234 Commits
Author SHA1 Message Date
maziggy c42e923e4c fix(archives): MQTT-derived filament type/color on fallback archives (#1533)
When the source .3mf can't be downloaded at print start (P1S/A1/P2S
  firmwares lock the file mid-print), main.py creates a fallback
  PrintArchive with file_path="" and every filament field NULL — even
  though the MQTT payload already has the AMS state and the slicer's
  slot-per-print-filament mapping (data["ams"]["ams"] and
  data["ams_mapping"]).

  New _extract_filament_data_from_mqtt(data, ams_mapping) builds a
  {global_tray_id: (type, color)} map from the AMS units, then narrows
  to slots referenced by ams_mapping (slicer order preserved, -1 VT-tray
  sentinels skipped) or falls back to every loaded slot when no mapping
  is present. Returns comma-separated filament_type and filament_color
  matching the 3MF-extraction shape, so the inventory page, Quick Stats
  rollup, and len(filament_type.split(",")) per-print count behave
  identically for fallback rows.

  The constructor at the fallback site now passes the resulting values
  into the PrintArchive row.

  This does NOT recover per-filament gram usage — that needs the .3mf's
  slice_info.config or a deeper layer-delta integration via usage_tracker.
  The reporter (maker-space lead evaluating Bambuddy partly for AMS
  expansion planning) asked specifically for "the number of filaments
  used", which is what this gives them.

  15 unit tests cover empty/malformed payloads, the no-mapping path,
  mapping filtering and reordering, VT-tray sentinels, dual-AMS global
  ids, column-limit truncation, and defensive garbage handling.
2026-05-26 11:50:41 +02:00
maziggy 4343bd60b1 fix(notifications): honest UA + Cloudflare-challenge detection on ntfy (#1534)
The notification service's httpx client was the only outbound client in
  the codebase still leaking python-httpx/<version> as User-Agent; all
  other clients identify as Bambuddy/1.0 since the May 2026 compliance
  pass. Bring it in line.

  The reporter's ntfy server was behind a Cloudflare Tunnel and CF returned
  its JS challenge page (Just a moment...) to every API request — confirmed
  by reproducing the same 403 with curl. Cloudflare can't be solved from a
  backend, so add detection for the challenge shape (Server: cloudflare or
  cf-mitigated header, or <!DOCTYPE html>...Just a moment... body) and
  return an actionable error message that points at the real fix on the
  user's CF side instead of dumping the raw HTML.

  Normal 403s (auth failures with plain text bodies) still surface the
  original body so genuine errors stay debuggable.
2026-05-26 11:21:03 +02:00
maziggy e9beb1e8fc fix(archives): handle fallback archives in source-3MF upload (#1531)
Archives created from prints Bambuddy didn't archive (cloud / Handy /
  SD-card prints) carry file_path="". The two source-upload routes
  computed the destination as (base_dir / archive.file_path).parent /
  "source", which collapsed to base_dir.parent / "source" for fallback
  rows — sending the file to /app/source/ (outside the data volume,
  orphaned on container restart) and raising 500 on the final
  relative_to.

  Centralise the destination math in _resolve_source_3mf_path. Normal
  archives keep the <archive>/source/<filename> layout. Fallback
  archives land at <base_dir>/archive/no_source/<id>/<filename>, which
  stays inside the data volume and is addressable by every existing
  read site. The helper also asserts the resolved directory is under
  base_dir.resolve() so a corrupted row fails with a clear message
  instead of writing outside the volume.

  Both upload routes (upload_source_3mf and upload_source_3mf_by_name)
  now route through the helper. Two regression tests in
  TestUploadSourceThreeMF pin both branches.
2026-05-26 11:04:46 +02:00
maziggy 4387a09162 fix(spoolbuddy): route weight sync by inventory mode exclusively (#1530)
POST /spoolbuddy/scale/update-spool-weight tried the local DB first
  and only fell back to Spoolman on a local miss. Combined with
  nfc/tag-scanned's post-#1119 always-Spoolman routing, a stale local
  Spool row sharing a numeric id with a Spoolman spool would absorb
  the sync silently while the Spoolman row stayed unchanged.

  Mirror the routing already used by nfc/tag-scanned: pick the branch
  via _get_spoolman_client_or_none() and never cross. Local mode now
  returns 404 on a local miss instead of falling through.

  New TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman
  asserts both directions: Spoolman gets the update, the colliding
  local row's weight_used and last_scale_weight are untouched.
2026-05-26 10:36:37 +02:00
maziggy 554a73070f fix(maintenance): paused prints no longer accumulate runtime hours (#1521)
PAUSE counted toward runtime_seconds equally with RUNNING, inflating
  hours-based maintenance thresholds (rod lube, belt check, nozzle clean)
  by however long overnight or extended pauses lasted. Maintenance items
  track mechanical wear, which is zero while paused, so the predicate
  now excludes PAUSE. Field-comment and docstring trail across main.py /
  models/printer.py / maintenance.py updated to match. Existing
  runtime_seconds values cannot be retroactively split — only future
  accumulation is fixed.

  Adds 3 regression tests pinning PAUSE non-accumulation, RUNNING
  accumulation, and the FINISH state's last_runtime_update clear
  (prevents idle-time back-bill when the printer next goes RUNNING).
2026-05-25 09:04:47 +02:00
maziggy 1e734fb7c6 fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up)
Reporter (@IndividualGhost1905) saw Total: 20 / Success: 18 / Failed: 1
  and asked where the 20th print went. The Quick Stats endpoint counted
  status == "completed" → Successful and status == "failed" → Failed, but
  used a raw count(*) for Total Prints, so the four other PrintLogEntry
  statuses (aborted, stopped, cancelled, skipped) silently inflated the
  total without showing up in any breakdown row. The earlier #1390 round
  had committed a test locking in this exact behaviour
  ("uses total_prints as denominator so cancelled/stopped events count"),
  which was wrong: it conflated user intent with print quality.

  Three-bucket classification, applied across the whole stats surface
  and matching how the rest of the codebase already groups statuses
  (main.py:430, 1729; failure_analysis status filter):

    successful = completed
    failed     = failed + aborted    (printer-detected quality failures)
    cancelled  = stopped + cancelled + skipped  (user/queue stopped)

  Quick Stats endpoint returns the new cancelled_prints field;
  ArchiveStats.cancelled_prints defaults to 0 so older fixtures still
  parse. SuccessRateWidget gauge now divides by successful + failed
  only — a cancelled roll no longer drags the gauge down — and a
  Cancelled row appears in the breakdown so the missing prints don't
  silently vanish from Total Prints.

  Failure Analysis service applies the same denominator fix to both
  the headline failure_rate and the per-week trend, so a week with
  several cancellations and zero failures reads as 0% rather than
  a misleading "failed / total".

  i18n: new stats.cancelled key in all 9 locales with real
  translations (no English fallback), parity script clean.

  Tests: the existing 'uses total_prints as denominator' assertion
  is inverted to assert the new behaviour (40 / 20 / 35 → 67% gauge,
  Cancelled: 35 visible). The unchanged-display path (140 / 10 / 0
  → 93%) still holds since 140 / (140 + 10) = 93.33% rounds the
  same. 33 StatsPage tests + 6 backend stats/failure tests green.
2026-05-24 15:21:10 +02:00
maziggy ca08f1f340 fix(test): stop sys.modules-deleting backend.app.main in test_code_quality
+ ci: shard backend tests 4-way + drop -v for ~3.5x wall-clock speedup

  Root cause of the 4 CI failures on PR #1514 (all in
  test_print_start_assigns_printer_id_to_vp_archive.py +
  test_timelapse_baseline_restart_recovery.py): test_all_modules_importable
  in test_code_quality.py was deleting backend.app.main from sys.modules
  and re-importing it via importlib.import_module. That created NEW
  module-level dicts (_timelapse_baselines, _expected_prints,
  _active_prints, …) and re-ran root_logger.addHandler — hence the
  duplicate log lines at the same microsecond in captured stderr.

  Any sibling test that bound those names via "from backend.app.main
  import _timelapse_baselines" before the reimport now held a reference
  to the OLD dict; production code (reached via "from backend.app.main
  import on_print_start") resolved the symbol through the NEW module
  instance. Production mutated the new dict, the test read the old one,
  the assertion saw None / un-mutated mock_archive.

  Locally with -n 30, xdist load-balanced test_code_quality.py to a
  different worker process so the collision never happened (which is
  why the suite was green for me). CI's -n auto = -n 2 on ubuntu-latest
  made the collision deterministic.

  Fix: drop the "del sys.modules[name]" step. importlib.import_module
  already returns the cached module if cached, or runs the import
  machinery if not — either way, any import-time error surfaces. The
  "fresh import" framing was theatre; in practice every module in the
  list is already imported by other tests/fixtures before this test
  runs, so we were never actually getting a fresh import anyway — just
  destruction.

  CI workflow tightening (separate concern, same PR since both touch
  the test infrastructure):

  - Dropped -v from the pytest invocation. 5300+ "PASSED foo::bar"
    lines per worker were eating ~30-60s of stdout I/O on 2-vCPU
    runners. --tb=short is sufficient for failure context.
  - Sharded backend-tests into a 4-way matrix via pytest-split (new
    dev dep). Each shard runs ~1326 tests in ~95s on a 2-vCPU runner;
    all 4 run in parallel so wall-clock drops from 362s -> ~100s.
  - fail-fast: false on the matrix so a single failing shard doesn't
    hide failures in the other three — PRs see the complete failure
    picture in one push.
2026-05-24 12:51:24 +02:00
maziggy 22f222e4ac fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race
test_running_observed_captures_baseline_on_restart_recovery was reading
  _timelapse_baselines.get(1) after the patch() with-block exited.
  Locally and under low parallelism this works fine — the dict still
  holds what _capture_timelapse_baseline_at_start wrote. CI under
  xdist's default load-balancing scheduling intermittently saw the
  dict empty by the time the top-level assert ran, even though the
  production code logged "Baseline at print start: 3 video files for
  printer 1" right before returning. The duplicate log line at the
  same microsecond in the captured stderr is the tell — module state
  is being re-touched between the handler completing and the test
  asserting, almost certainly via the session-scoped event_loop
  fixture in conftest.py interacting badly with the per-file
  autouse _clear_baselines teardown of a sibling test on the same
  worker.

  The test is verifying the handler captured the baseline at the
  moment it returned, so capture the relevant value at exactly
  that point — inside the with-block, immediately after the await.
  That's immune to whatever happens to the module-level dict
  afterward.
2026-05-24 12:30:12 +02:00
maziggy eb98521e93 fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108
The test_returns_empty_when_3mf_missing test sets a deliberately
  non-existent file_path on a PrintArchive to verify
  compute_deficit_for_queue_item handles the missing-3MF branch
  gracefully. The path just needs to fail an existence check — the
  /tmp/ prefix was incidental.

  Bandit B108 ("insecure temp file usage") regex-matches /tmp/,
  /var/tmp/, and /dev/shm/. Dropping /tmp/ in favour of /nonexistent/
  keeps the test behaviour identical (still a guaranteed-missing
  path, still triggers the missing-file branch) while clearing the
  GitHub Advanced Security finding on PR #1514 without adding a
  # nosec annotation.
2026-05-24 12:03:11 +02:00
maziggy 3b9633a178 ● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up)
The three diagnostic surfaces shipped earlier this month
  (6bc6a1d6 VP setup diagnostic, e222a0ef log-health scanner,
  ed31b8f4 connection diagnostic in the bug-report bubble) were
  only ever shown to the *user*. A bug report arriving in the
  maintainer's inbox carried raw logs but no diagnostic results —
  the user-visible "your X1C can't reach MQTT" finding never made
  it into the issue body, so the maintainer had to ask the user
  to re-run and paste.

  New `services/diagnostic_snapshot.collect_diagnostic_snapshot`
  runs all three concurrently with a per-probe 15 s wall-clock cap
  (so total ≈ max(per-cap), not sum — fleet size doesn't matter)
  and is fail-soft per probe: a crash inside one printer's check
  emits `{"printer_id": N, "error": "..."}` for that entry rather
  than nuking the whole snapshot. The snapshot is then added as a
  `diagnostics` top-level key by `_collect_support_info()`, so both
  flows (POST /support/bundle and POST /bug-report/submit via
  `support_info=...`) pick it up without their own changes.

  Private-data sanitization
  -------------------------
  The diagnostic schemas embed raw IPv4 in five field shapes that
  must not land in a submitted GitHub issue or a shared support ZIP:

    - PrinterDiagnosticResult.ip_address (top-level)
    - DiagnosticCheck.params.printer_ip (network-mode check)
    - DiagnosticCheck.params.host_ip (network-mode check)
    - VPDiagnosticResult per-check params.bind_ip (VP setup)
    - IPs embedded in log-health sample lines

  The first two carry the printer's own IP (already in the
  existing `collect_sensitive_strings` table via the Printer rows);
  host_ip and bind_ip are NOT in the DB so a sensitive_strings-only
  pass missed them.

  Fix: `_sanitize_recursive` walks the full snapshot tree, masks
  DB-known values with the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]`
  labels the log sanitizer applies (via the shared
  `collect_sensitive_strings`), then an IPv4-regex pass catches any
  IP the DB didn't cover — most importantly the Bambuddy host IP
  returned by `_get_host_ip()` and the VP `bind_ip` the user picked
  at setup. Recursive walk so arbitrary nested dicts/lists don't
  slip through future schema additions.

  Live-DB smoke test against the dev fleet: zero raw IPv4 instances
  in the serialized snapshot output; all five field shapes plus the
  embedded log samples render as `[IP]`.

  Progress indicators
  -------------------
  The bubble's "submitting" view and the System page's Download
  button now render a static four-line checklist showing what's
  running (printer connectivity → VP setup → log scan →
  submit / build ZIP). Static, not faked phase progress — we can't
  actually track server-side phases without SSE and the honest
  "here's what's happening" list communicates the longer wait
  without lying about percentage complete.

  9 new i18n keys, real translations in all 9 locales (no English
  fallback). parity script clean at 4993 leaves per locale.

  Tests: 6 new in test_diagnostic_snapshot.py
    - empty-input shape stable (the three top-level keys always present)
    - per-printer / per-VP result coverage (lists match input lengths)
    - fail-soft on a single-probe crash (other entries + log-health
      still complete)
    - timed_out marker when a probe exceeds the per-probe cap
      (test patches the cap to 0.05 s)
    - end-to-end IP sanitization across all five field shapes plus
      log-sample IPs, with a final JSON-serialize-and-regex sweep
      asserting zero raw IPv4 escapes anywhere in the result
    - concurrent execution proof (4 × 0.2 s probes complete in
      < 0.5 s; sequential would be 0.8 s)
2026-05-24 10:16:30 +02:00
maziggy 03896d1af0 fix(scheduler): use inventory weight for "Prefer Lowest Filament" sort (#1508)
Reporter has a P1S with an inventory spool cloned to slot 1 and the
  original (much further used) in slot 4, the preference enabled, and
  the dispatch picked slot 1 every time. The sort's been blind to
  Bambuddy inventory weights — it reads MQTT `tray.remain`, the
  printer firmware's RFID-decremented value, which has two limitations:

  - Bambu RFID only. Non-RFID spools report -1 and get clamped to a
    sentinel; multiple non-RFID trays then tie in the sort and Python's
    stable sort collapses to AMS-slot insertion order, so slot 1 wins.
  - Even when set, it's the printer's counter, not Bambuddy's
    `label_weight - weight_used` (internal) or Spoolman's
    `remaining_weight`. The two diverge whenever the user re-spools,
    swaps cardboard, or runs a print outside Bambuddy.

  The reporter is on internal-inventory mode with non-RFID spools — both
  failure modes apply, hence slot 1 every time.

  Fix: when a slot is bound to an inventory spool the inventory record's
  remaining weight becomes the sort signal. New async helper
  `_build_inventory_remain_overrides(db, printer_id, loaded)` returns
  `{global_tray_id: remaining_grams}` for bound slots — internal mode
  joins SpoolAssignment → Spool once per dispatch; Spoolman mode joins
  SpoolmanSlotAssignment then reuses `_spoolman_remaining_grams` from
  filament_deficit.py for parity.

  New `_prefer_lowest_sort_key` does a two-tier comparison: inventory-
  tracked spools sort BEFORE MQTT-only spools, then ascending by
  remaining within each tier, then ascending by ams_id*4+tray_id as the
  deterministic slot tie-breaker. The tier flag dominates so grams
  (inventory) and percent (MQTT) never get cross-compared — no unit
  conversion needed.

  MQTT-only behaviour is preserved exactly: remain=-1 still maps to the
  101 sentinel and slot order still decides on ties. Users who haven't
  bound any inventory spool see no change. The DB lookup runs only when
  prefer_lowest_filament is enabled.

  External / VT slots are skipped (tracked separately from AMS bindings).
2026-05-24 09:18:38 +02:00
maziggy eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504)
A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
  fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.

  ffmpeg has shipped THREE arrangements of this option over time and
  Bambuddy supports the full range:

  - Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
  - Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
    repurposed to RTSP listen-mode timeout; any non-zero value implies
    `-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
    EADDRINUSE. `-stimeout` is the replacement socket I/O option.
  - Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
    socket I/O — the original meaning.

  So no single literal is correct on all installs.

  Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
  `ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
  advertises it (covers transitional + older builds that kept it as an
  alias), else `-timeout` (modern + pre-deprecation). Cached at module
  level for the process lifetime — ffmpeg doesn't swap mid-run.

  The function returns the option name without a leading dash; callers
  prepend it themselves so a formatting bug can't pass an empty flag.

  Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
  (printer camera) and services/external_camera.py (external RTSP),
  which use the same TLS-proxy + ffmpeg pattern and would hit the same
  regression on either ffmpeg cohort.

  Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
  (prefers stimeout when advertised, falls back to timeout on modern,
  defaults to timeout when ffmpeg missing or probe raises, caches across
  calls, trailing-space substring guard against `-listen_timeout`
  false-positives), 2 parametrised guards against either RTSP ffmpeg
  argv re-hard-coding a literal instead of consuming the probe. 37
  probe + existing external-camera tests green.
2026-05-24 08:49:21 +02:00
maziggy ed08ed3787 fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485)
When Bambuddy is restarted mid-print, the first MQTT push from the
  printer carries `_previous_gcode_state = None`. The #1304 guard
  deliberately suppresses on_print_start on that first push to prevent
  duplicate archive creation — but on_print_start is also where
  _capture_timelapse_baseline_at_start runs, so the in-memory
  _timelapse_baselines dict stays empty for the resumed session.

  At PRINT COMPLETE, _scan_for_timelapse_with_retries finds no baseline
  and falls into its "take baseline now" fallback. By that point the
  printer has already uploaded the in-flight MP4, so the snapshot
  includes the new file. Every "Found N files / no new files since
  baseline" retry then fails to detect a diff, and the archive ends up
  with no timelapse attached — pwostran's report (#1485 follow-up): card
  shows the finish snapshot but no video.

  Add a sibling callback on_print_running_observed that bambu_mqtt fires
  in the "Now tracking RUNNING state" branch when on_print_start was
  suppressed. main.py wires it to a thin handler that looks up the
  printer row and calls the existing _capture_timelapse_baseline_at_start.
  Idempotent — skips if a baseline already exists (handles the rare
  same-session race where on_print_start also fires for some reason).

  The printer doesn't upload the timelapse until after PRINT COMPLETE,
  so a baseline captured any time during the print is still pre-upload —
  no narrow window to hit.

  Verified against the in-the-field logs in #1485 (pwostran's 2026-05-23
  support bundle):
    pre-reboot: baseline = 7 files
    reboot
    post-reboot completion: fallback baseline = 8 files (includes new MP4)
    -> all 4 retry attempts report "no new files since baseline"

  10 new tests cover both the MQTT-side fire decision (fires when
  suppressed, doesn't fire when on_print_start handles it, once per
  session, payload shape mirrors on_print_start) and the main.py
  handler (snapshot capture, double-capture guard, missing-printer-row
  guard).
2026-05-23 14:52:04 +02:00
maziggy c0c07bc509 fix(archives): cross-model re-slice no longer carries source printer_id
When the user re-sliced an H2D archive for X1C, the new archive card and
  reprint modal both showed the source printer's name (e.g. "Workshop H2C")
  even though sliced_for_model on the same row correctly read "X1C".

  slice_and_persist_as_archive copied source_archive.printer_id verbatim
  onto the new PrintArchive row. Both the archive card
  (ArchivesPage.tsx:3571) and the reprint modal read printer_id first and
  only fall back to sliced_for_model when it's None. With printer_id set
  to the source's H2D printer, neither could see that the new file is for
  a different model.

  Same shape as the sliced_for_model fix already in the same function — a
  cross-model re-slice means the source's physical printer isn't where
  this output prints anymore. When the slicer-baked target model differs
  from source_archive.sliced_for_model, drop printer_id so both surfaces
  fall back to the sliced_for_model badge ("X1C"). Same-model re-slices
  keep printer_id so the reprint modal still pre-selects the source
  printer.

  Edge case: when source_archive.sliced_for_model is None (older archives
  that predate that column being populated), we can't tell whether this
  is a cross-model re-slice. Fail open and preserve printer_id rather
  than spuriously nulling it.

  slice_and_persist (the library-file path) doesn't have this bug —
  LibraryFile has no printer_id column.

  Tests in TestSliceArchiveResliceModel cover all three branches: cross-
  model nulls printer_id; same-model keeps it; unknown source model
  preserves it.

  Surfaced via the bambuddy demo doing H2D -> X1C re-slices after the
  .bbscfg System-tier slicing fix landed.
2026-05-23 14:25:12 +02:00
maziggy 3058c5789b fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up)
The first cut of #1325 swapped a stale hardcoded model table for
  bundle-based compatibility matching: a cloud / standard process or
  filament preset was classified by consulting the user's uploaded
  Slicer Bundles (.bbscfg). That works perfectly when bundles cover
  every printer in the user's cloud catalogue, and silently no-ops
  otherwise - every cloud preset resolves to 'unknown', nothing moves
  into "Other printers", and the dropdowns look identical to the
  pre-fix state. The reporter saw exactly this on a clean install
  with no bundles uploaded.

  Restored BambuStudio's `@BBL <token>` name convention as a third
  tier below the bundle path, but driven by the canonical backend
  PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models
  route - rather than a manually-maintained frontend table. The
  matcher inverts the registry into short-code -> printer-fragment
  ("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini"
  and "A1 Mini" compare equal, and falls back to raw-token compare
  for models not yet in the registry (so a future "Q1" matches
  without a code change). Adding a new Bambu model still touches
  exactly the one backend file already listed in the Bambu Model
  Codes registry.

  Tests: 2 new in test_slicer_presets.py (route returns the full
  PRINTER_MODEL_MAP, route returns a copy not the live dict); 11
  new in slicerPrinterMatch.test.ts covering registry-driven X1C
  vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S /
  X2D (which the original hardcoded list was missing), raw-token
  fallback, registry-not-loaded-yet degradation, and the
  precedence rules between compatible_printers / bundle / @BBL
  name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal
  tests green; backend ruff clean; frontend build clean.
2026-05-23 12:52:04 +02:00
maziggy 4096d8d6bd fix(csp): nonce-based script-src so Cloudflare-injected scripts pass (#1460 follow-up)
Behind Cloudflare, the bot-detection script CF injects into every HTML
  response carries a hash that rotates per request, so it can never be
  allowlisted by hash. Reporters with CF in front had to relax their NPM
  CSP to 'unsafe-inline' as a workaround.

  Per Cloudflare's documented behaviour, when a nonce is present in the
  page's script-src, CF clones it onto its injected <script>. The SPA CSP
  now stamps a fresh per-request nonce via secrets.token_urlsafe(16),
  keeping 'self' for our own scripts (index.html has had no inline scripts
  since the SW registration moved to /sw-register.js in the original
  #1460 PR), so no HTML body rewriting is needed.

  Also folded in: /manifest.json, /sw.js and /sw-register.js now accept
  HEAD as well as GET, so `curl -I` and uptime scanners stop returning
  405 on those routes - a separate red herring during this issue's
  debugging.

  Tests: 3 new in test_security_headers.py - 'nonce-' token stamped into
  SPA script-src while 'self' remains and 'unsafe-inline' does not; nonce
  is fresh per request across 5 sequential calls; HEAD on the three PWA
  routes never returns 405. 22/22 security-header tests green; backend
  ruff clean.
2026-05-23 12:28:58 +02:00
maziggy 4686d108ef feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
2026-05-23 12:16:34 +02:00
maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
2026-05-23 08:47:46 +02:00
maziggy e222a0ef0e feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection
  Diagnostic. Scans Bambuddy's recent app log against a curated allowlist
  catalog of known failure signatures (rejected access code, FTPS :990
  timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite
  "database is locked" contention), dedupes and classifies each finding
  as layer8/environment/bug, and deep-links to the troubleshooting wiki.
  Sample log lines are sanitized before they leave the process. Exposed
  via GET /system/health and surfaced on two surfaces sharing one
  SystemHealthPanel component: a System Health section on the System
  page, and inline in the bug reporter when the form opens.

  The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight:
  saving runs the connection diagnostic and, on a failed check, warns with
  a "save anyway" escape hatch instead of silently saving a printer that
  will immediately show offline.

  Log read/parse/sanitize primitives extracted from routes/support.py into
  a shared services/log_reader.py (behaviour-preserving); affected support
  tests repointed accordingly.

  Tests: test_log_health.py (11), test_system_api.py (2 new),
  SystemHealthPanel + BugReportBubble + AddPrinterPreflight +
  EditPrinterPreflight (8 frontend). All strings translated across the 9
  locales. Backend ruff clean, full unit suite green, frontend build +
  eslint clean, i18n parity green.
2026-05-22 16:31:16 +02:00
maziggy ab8e07618f fix(inventory): archive filament colour follows the assigned spool, not the 3MF (#1494)
An archive's filament_color was parsed verbatim from the print job's
  3MF (filament_colour in project_settings.config) — the slicer's
  filament-slot colour, which a user picks independently of the exact
  hex they curate on the Bambuddy inventory spool. So a print from a
  #000000 inventory spool showed #161616 (the slicer's near-black) in
  the archive card and the Color Distribution graph, even though usage
  tracking correctly decremented the right spool.

  Once usage tracking has resolved the print's filament slots to
  inventory spools, the spool colours are authoritative. _track_from_3mf
  (built-in inventory) and report_usage (Spoolman mode) now overwrite
  the archive's filament_color with the slot-ordered, de-duplicated
  colours of the matched spools.

  The rewrite is all-or-nothing: it only applies when every used slot
  resolved to a spool carrying a colour, so a partially-mapped
  multi-colour print keeps the 3MF colour rather than silently dropping
  the unmatched slots.

  Shipped for both inventory modes: built-in spools read Spool.rgba,
  Spoolman spools read the spool's filament.color_hex (fetched via
  get_spool for tag-less slot-assignment matches). New helpers
  _spool_color_to_hex / _archive_colors_from_spools in usage_tracker.py,
  reused by spoolman_tracking.py via _apply_spool_colors_to_archive.

  Tests: 12 new in test_usage_tracker.py (hex normalisation, the
  all-or-nothing rule across single/multi/partial/no-colour/AMS-fallback
  cases, end-to-end rewrite), 4 in test_spoolman_tracking.py (Spoolman
  rewrite + empty/partial/missing-archive no-ops). 70 tracking tests
  green; backend ruff clean.
2026-05-22 14:22:37 +02:00
maziggy 6d7a92c024 fix(camera): P2S RTSP stream dropped every frame after the first (#1395)
A fresh P2S support bundle showed ffmpeg's reason for the stall:
  `frame=1 time=00:00:00.06 dup=0 drop=526 speed=0.0037x`. ffmpeg
  connects and frames arrive (drop counter climbs ~15/s), but it emits
  one output frame and the output clock freezes.

  The streaming command ends with `-r 15`, putting ffmpeg in CFR mode:
  it drops/dupes input frames to hit 15 fps based on the source's
  timestamps. P2S firmware 01.02.00.00 sends an RTSP stream whose RTP
  timestamps don't advance, so CFR treats every frame after the first
  as a same-timestamp duplicate and drops it. Snapshot capture works on
  the same printer because that path has no `-r` (no CFR conversion);
  X1/H2 are unaffected because their firmware timestamps are correct.
  The earlier probesize fix was masking this second bug.

  Add `-use_wallclock_as_timestamps 1` to the P2S camera profile via
  the existing extra_ffmpeg_input_args hook. ffmpeg rebuilds each
  packet's PTS from arrival wall-clock time, the output clock advances,
  and CFR conversion works. No dataclass change, no other model touched.

  Tests: 2 new in test_camera_profiles.py (P2S splices the flag+value
  pair; default profile keeps extra_ffmpeg_input_args empty so the
  override never leaks to X1/H2).
2026-05-22 13:55:16 +02:00
maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
Two recurring virtual-printer support pains, both on the Virtual Printers
  settings page.

  Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
  checklist — VP enabled, services running, bind interface still exists, access
  code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
  / discovery ports on the bind IP. start_server swallows per-service bind
  errors, so a service object can exist while nothing is listening; probing the
  bind IP from outside is the only reliable signal and it catches the common
  "VP not visible in the slicer" bind-IP-conflict and stale-interface cases.

  Slicer certificate: virtual printers present a TLS cert signed by a shared CA
  the slicer must trust. Until now users had to docker exec in and cat
  bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
  and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
  GET /virtual-printers/ca-certificate returns only the public certificate; the
  CA private key never leaves the backend. The CA is generated on demand so the
  button works before the first VP is enabled.

  Backend:
  - services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
  - schemas/virtual_printer.py — VPDiagnosticResult
  - CertificateService.get_ca_certificate_info() + manager helper
  - routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic

  Frontend:
  - VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
  - caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
    non-secure-context fallback + downloadTextFile), de-duplicating the
    existing FQDN-copy logic
  - vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales

  9 backend unit tests + 4 route integration tests + 6 frontend tests.
  Backend ruff clean, frontend build clean, i18n parity green.
2026-05-22 13:24:35 +02:00
maziggy 4925b4c830 fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard
Five follow-up fixes to cross-printer re-slicing, all surfaced while
  testing archive re-slices.

  1. Re-sliced archive now records the printer it was sliced FOR.
     slice_and_persist_as_archive copied sliced_for_model from the source
     archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
     from the freshly-sliced 3MF's parsed metadata instead, falling back
     to the source only when absent.

  2. Real slicer rejections are surfaced instead of silently masked.
     _run_slicer_with_fallback retried with the 3MF's embedded settings on
     any sidecar 5xx — including genuine content rejections (object off
     the bed, incompatible filament temps), which "succeeded" only by
     re-slicing for the source's original printer. A new
     _slicer_rejection_message detects the slicer's own error string and
     surfaces it as a 400; the embedded-settings fallback is kept only for
     true CLI crashes.

  3. A failed slice opens an error modal, not a 3s toast. The slicer's
     reason is actionable and a toast hides it before it can be read. New
     AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
     failed job. New slice.failedTitle key in all 9 locales.

  4. Sliced files no longer report "0 g" filament usage. The sidecar
     doesn't always populate the X-Filament-Used-* headers;
     ThreeMFParser._parse_gcode_header now also reads the slicer's own
     "total filament weight/length" from the G-code header, and both
     slice-persist paths fall back to it when the sidecar reports 0.

  5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
     dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
     multi-extruder validation; both slice routes now reject it up front
     with a clear 400. The dual-nozzle model classification — previously
     an inline tuple duplicated across start_print and the K-profile
     routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
     in printer_models.py, consumed by all three sites and the guard.

  Full cross-nozzle-class re-slicing (dual-nozzle project_settings
  reconciliation) remains separately tracked.

  Tests: _slicer_rejection_message, _canonical_printer_model,
  guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
  filament parse, AlertModal, and end-to-end slice-API coverage including
  an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
  build clean.
2026-05-22 12:34:08 +02:00
maziggy 16da533c9a fix(static): serve /fonts/*.woff2 — self-hosted Inter font (#1460 follow-up)
The browser console logged "downloadable font: rejected by sanitizer"
  for inter-latin.woff2 on every load. The #1460 PWA fix added @font-face
  rules pointing at /fonts/inter-latin.woff2 and bundled the woff2 files
  into static/fonts/, but main.py only mounts /assets, /img and /icons as
  static directories. With no /fonts mount, /fonts/*.woff2 fell through to
  the SPA catch-all and returned index.html with 200 OK; the browser's
  OpenType sanitizer rejected the HTML-as-a-font.

  Add a /fonts StaticFiles mount alongside /img and /icons. The woff2
  files themselves are valid (verified — Inter variable, latin and
  latin-ext subsets).

  Also bump the service worker STATIC_CACHE version (v26 -> v27). sw.js
  lists the two font URLs in STATIC_ASSETS, and cache.addAll() treats the
  200 OK HTML as a successful fetch — so it had cached index.html under
  the font URLs and served it cache-first. The version bump makes the
  activate handler purge the poisoned cache and re-fetch the real fonts.
2026-05-22 10:43:27 +02:00
maziggy 71e58e6cf1 fix(library): show the filename, not the embedded 3MF Title (#1489)
File Manager cards, search and sort keyed off file_metadata.print_name,
  which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That
  title is the in-app project title — generic "Exported 3D Model" for any
  Bambu Studio "Save As", a marketing title for a MakerWorld download —
  and almost never the filename the user saved as. A card for
  Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename
  round-trip, since the Rename dialog disables Save while the name is
  unchanged.

  The slicer-output write path already dropped print_name for this exact
  reason; the four other paths that store parsed 3MF metadata onto a
  LibraryFile did not — external-folder scan, managed multipart upload,
  the multi-file ZIP-upload branch, and MakerWorld import.

  Add a shared _without_print_name() helper and apply it at all four
  import paths; switch the slicer path to it so there is one rule. A
  LibraryFile's display name is its filename — only PrintArchive carries
  a real print_name, which is untouched. Remove the now-redundant
  filename->print_name mirroring in the rename route.

  Add a one-time idempotent data migration (_migrate_drop_library_print_name,
  SQLite json_remove / PostgreSQL jsonb key-removal branched on
  is_sqlite()) so libraries imported before the fix correct themselves
  without the rename workaround. No frontend change: print_name || filename
  yields the filename once print_name is gone.

  Tests: 6 new in test_library_print_name.py cover _without_print_name and
  the migration (incl. idempotency, siblings preserved, null metadata).
  SQLite migration branch verified by test; PostgreSQL branch verified
  against a real Postgres instance.
2026-05-22 10:22:14 +02:00
maziggy 056f06a396 fix(camera): capture ffmpeg stderr when an RTSP stream stalls (#1395)
A P2S support bundle on 0.2.5b1 — per-model probesize fix already
  applied — showed the camera still failing: ffmpeg connects, stays alive
  30+ seconds, emits zero JPEG bytes, the 30s stdout.read times out,
  reconnect loop repeats. No ffmpeg stderr appeared anywhere in the log to
  explain why.

  The cause was a diagnostic bug, not the camera path. _read_ffmpeg_stderr
  called process.stderr.read() — read-to-EOF. A stalled-but-still-alive
  ffmpeg (the P2S RTSP failure mode) never closes stderr, so the read
  blocked until the 2s wait_for timeout and returned None, discarding the
  banner + stream-analysis lines ffmpeg had already printed. ffmpeg stderr
  was captured only when it fully exited; once the probesize bump turned
  the earlier crash into a hang, the diagnostic went dark.

  Drain stderr incrementally in bounded 8KB chunks (64KB cap), returning
  whatever ffmpeg printed so far whether or not it has exited. Also log
  the resolved per-model probesize/analyzeduration on the info-level
  "Starting RTSP camera stream" line, and log the full ffmpeg argv at
  debug level with only the credential-bearing camera URL redacted
  instead of hiding the entire command.

  No behaviour change to streaming — this makes the unresolved P2S RTSP
  stall diagnosable in the next support bundle.
2026-05-22 10:02:41 +02:00
maziggy 774eba73c8 feat(diagnostics): event-loop stall watchdog to catch silent backend freezes
Several "container hangs after adding a printer" reports (#1486) share a
  signature with nothing to act on: HTTP goes silent, /health hangs, the
  process may ignore SIGTERM, and the log just stops mid-stream - a frozen
  asyncio loop cannot log a thing.

  loop_watchdog re-arms faulthandler.dump_traceback_later() from an async
  heartbeat. While the loop ticks the timer is cancelled and re-armed before
  it can fire; if the loop stalls for 30s the heartbeat can't re-arm and
  faulthandler's C-level timer thread dumps every thread's stack to stderr,
  so the blocked frame shows up in `docker compose logs`.
2026-05-22 09:28:43 +02:00
maziggy 745ed847e6 fix(archive): stop duplicating the job on a backend restart mid-print (#1485)
A restart during an active print duplicated the running job in the
  archive, and every further restart spawned another. on_print_start
  re-attaches by subtask_id, falling back to a name match plus a 4-hour
  staleness cutoff that cancelled + recreated any name-matched 'printing'
  archive older than 4h - destroying the live archive of every long print.

  Two fixes:
  - start_print records the minted subtask_id (last_dispatch_subtask_id);
    on_print_start falls back to it when the printer hasn't echoed one
    yet, so queue/scheduled archives persist a restart-stable id.
  - Replace the 4h cutoff with a progress-aware check: a name-matched
    'printing' archive resumes whenever the printer reports real (or
    unknown) progress; it is stale only when the printer shows a
    freshly-started print (<1%) on an archive over 2h old.
2026-05-22 09:05:25 +02:00
maziggy 3286ccd7d2 fix(inventory): send honest Bambuddy User-Agent on FilamentColors.xyz sync
The Color Catalog sync built its httpx.AsyncClient with no User-Agent, so
  it leaked httpx's default python-httpx/x.y string - the only outbound
  client that did; bambu_cloud, makerworld and firmware_check all send
  Bambuddy/1.0 (+https://github.com/maziggy/bambuddy). It now sends the
  same honest UA.

  Found while investigating an issue - a Cloudflare 403 on the sync that
  turned out to be the reporter's network/IP reputation, not Bambuddy. The
  UA leak was a separate inconsistency found in passing; this change does
  not by itself resolve a Cloudflare IP block.
2026-05-22 08:34:54 +02:00
maziggy 50d1984820 fix(printer): stop the File Manager polling the printer over FTPS every 30s (#1480)
FileManagerModal ran its file-listing query with refetchInterval 30000,
  opening a fresh FTPS connection (full TLS handshake) to the printer every
  30s while the modal was open. On fragile controllers like the P1S that
  load tipped MQTT, FTP and the camera into simultaneous timeouts. Drop the
  interval; the listing still refreshes on open, directory change, after
  upload/delete, and via the manual Refresh button.

  Also log STL thumbnail failures with a traceback (exc_info) so a
  data-specific "str / str" TypeError can be located from a support bundle.
2026-05-22 08:26:50 +02:00
maziggy e0247fc6a6 fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325)
The process dropdown still mixed @BBL P2S presets into an X1C list:
  slicerPrinterMatch matched cloud/standard presets by parsing the
  @BBL <model> name suffix against a hardcoded model-code allow-list
  that was missing P2S, H2C and X2D — so those presets resolved to
  "unknown" and stayed in the main list instead of "Other printers".

  Drop both hardcoded model tables. Compatibility now comes from the
  user's uploaded Slicer Bundles: a bundle is scoped to one printer and
  lists the presets it ships, so a preset matches a printer exactly when
  some bundle for that printer contains it. New models are covered the
  moment their bundle is uploaded.
2026-05-22 08:01:42 +02:00
maziggy 17e39921bb fix(pwa): add in-app install button and self-host the Inter font (#1460)
Bambuddy installed as a PWA on desktop but not on Android. Two causes:

  - Chrome for Android removed the automatic install banner in Chrome 108.
    With no beforeinstallprompt handler, Android had no install path. New
    InstallAppButton captures the event and re-fires it from the sidebar.
  - index.css pulled Inter from fonts.googleapis.com: breaks offline, trips
    CSP, and the service worker answered the failed cross-origin request
    with cached index.html. Inter is now self-hosted; the SW skips all
    cross-origin requests and caches the font; CSP drops the Google hosts.
2026-05-22 07:40:44 +02:00
maziggy 379b1c14bc fix(printer): Flow Calibration was silently skipped — wrong project_file fields (#1478)
The H2S never ran flow-dynamics calibration even with the print option
  enabled, because start_print built the project_file command wrong:

  - extrude_cali_flag was hardcoded to 0. A BambuStudio request-topic
    capture from a real H2D (plus X1C/P2S captures) shows it is always 1
    (run calibration) or 2 (skip, reuse stored PA), paired with flow_cali,
    never 0 — so the printer skipped calibration regardless of the toggle.
  - flow_cali and the other calibration/leveling fields were integer-
    encoded for the H2 family on a mistaken belief that H2 firmware
    requires 0/1. The same capture sends plain JSON booleans for every
    model; the belief conflated these fields with use_ams (which does
    need to stay boolean — the actual #1386 cause).

  Fix: extrude_cali_flag = 1 if flow_cali else 2; send timelapse,
  bed_leveling, flow_cali, vibration_cali and layer_inspect as booleans
  for all models; drop the is_h_family integer-conversion branch.
  use_ams is unchanged.

  Corrected the two tests that asserted the integer format and renamed
  them; all three model tests now also assert extrude_cali_flag.
2026-05-21 14:50:29 +02:00
maziggy e738645b0d feat(slicer): filter slice profiles by printer + default from the 3MF (issue #1325)
The Slice dialog listed every process / filament preset regardless of
  the chosen printer, and always defaulted to the first listed preset
  rather than what the 3MF was prepared with (#1325).
  Matching uses the slicer's own compatible_printers list for imported
  (local) presets and falls back to the "@BBL <model>" name suffix for
  cloud / standard presets. Compatibility-unknown presets are never
  hidden.

  Defaults: the printer and process dropdowns default to the preset
  names embedded in the source 3MF's project_settings.config when those
  presets are available; the per-slot filament and process pre-picks
  prefer a printer-compatible preset, and switching the printer re-picks
  any selection left incompatible.

  - UnifiedPreset gains compatible_printers, exposed for the local tier
  - plates endpoints return embedded_printer / embedded_process
  - new frontend util slicerPrinterMatch.ts; extract_embedded_presets_from_3mf
  - slice.otherPrinters added across all 9 locales
2026-05-21 13:49:20 +02:00
maziggy 7eba29624b feat(slicer): filter process & filament profiles by selected printer (issue #1325)
The Slice dialog listed every process / filament preset regardless of
  the chosen printer (#1325). Picking a printer profile now filters both
  dropdowns to compatible presets; presets resolving to a different Bambu
  model move into a trailing "Other printers" group.

  Matching uses the slicer's own compatible_printers list for imported
  (local) presets, and falls back to the "@BBL <model>" name suffix for
  cloud / standard presets where no compatibility metadata is available,
  so all three tiers are covered. Compatibility-unknown presets (custom
  or untagged) are never hidden. The pre-pick and printer-switch paths
  follow the same rule.

  - UnifiedPreset gains compatible_printers, exposed for the local tier
  - new frontend util slicerPrinterMatch.ts with the matching logic
  - slice.otherPrinters added across all 9 locales
2026-05-21 13:33:13 +02:00
maziggy 76e327f4a1 feat: connection diagnostic for "printer won't connect" triage
A triage review of the last 200 closed issues found ~1/3 were
  user-side setup errors — printer not in LAN developer mode, blocked
  ports, Docker bridge networking, wrong access code, cross-subnet —
  each costing a multi-round-trip support exchange.

  Add a Connection Diagnostic that runs those checks automatically:
  - backend/app/services/printer_diagnostic.py: TCP probes of MQTT
    8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
    mode, printer/host subnet match, MQTT credential class; each
    check returns pass/fail/warn/skip with a localized fix.
  - Routes: GET /printers/{id}/diagnostic (saved printer) and
    POST /printers/diagnostic (pre-save Add-Printer flow).
  - ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
    the printer card actions menu, an offline-printer quick button,
    the Add-Printer dialog, and a new System-page section.
  - The in-app bug reporter scans configured printers when the form
    opens and always shows the result inline — a healthy confirmation,
    or the detected problem and its fix.
  - config.yml troubleshooting link repointed to the rendered wiki
    page; bug_report.yml gains a diagnostic checkbox.

  Diagnostic strings translated across all 8 locales. Backend service
  unit tests (15) + frontend modal tests (3). Ruff clean, frontend
  build clean, i18n parity green.
2026-05-21 11:00:59 +02:00
maziggy e1a236e408 fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122)
GET /spoolman/spools/unlinked hid any spool with a non-empty
  extra.tag from the AMS-slot assignment picker. extra.tag is only an
  RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value
  into that same Spoolman field -- so every OpenSpoolman-tagged spool
  became un-assignable in Bambuddy even when it occupied no slot.

  get_unlinked_spools now determines assignability from the
  spoolman_slot_assignments table (the documented source of truth for
  slot assignments) and ignores extra.tag entirely. Both link_spool and
  the AMS auto-sync upsert a row there for every occupied slot, so the
  ledger is complete. get_linked_spools and find_spool_by_tag still use
  extra.tag -- they are genuine tag-match maps and are unaffected.

  Internal-inventory mode needs no parallel change: it stores tags in
  its own DB with no Spoolman extra collision.

  Updates test_get_unlinked_spools_success and adds
  test_get_unlinked_spools_excludes_slot_assigned.
2026-05-21 09:33:47 +02:00
maziggy b06f8f6951 fix(spool-assignments): union both assignment tables in the missing-spool check + symmetric mode-switch clear (#1473)
notify_missing_spool_assignments_on_print_start queried only the legacy
  SpoolAssignment table. In Spoolman mode that table is empty -- bindings
  live in spoolman_slot_assignments -- so every used tray was flagged
  missing, firing a false-positive notification on every print.

  - spool_assignment_notifications.py: the assigned-tray set is now the
    union of SpoolAssignment + SpoolmanSlotAssignment rows. Union-only,
    so legacy-mode behavior cannot regress.
  - settings.py: the Spoolman toggle cleared SpoolAssignment on switch-on
    but never cleared SpoolmanSlotAssignment on switch-off. Added the
    symmetric clear so stale Spoolman rows can't leak into a later
    internal-mode session and mask a real missing-assignment warning.

  Adds 3 notification tests + 1 mode-switch integration test. An audit
  of the remaining SpoolAssignment consumers confirmed usage_tracker,
  spool_tag_matcher and routes/inventory are correctly internal-mode-only.
2026-05-21 09:15:10 +02:00
maziggy 305529f483 fix(notifications): missing-spool-assignment check now unions both assignment tables (#1473)
notify_missing_spool_assignments_on_print_start queried only the legacy
  SpoolAssignment table. In Spoolman mode that table is empty -- bindings
  live in spoolman_slot_assignments -- so assigned_global_trays came back
  empty and every used tray was flagged missing, firing a false-positive
  notification on every print.

  Union SpoolAssignment + SpoolmanSlotAssignment rows for the printer
  before computing the missing set. Both tables expose printer_id /
  ams_id / tray_id identically, so _global_tray_from_assignment is
  unchanged. Union-only, so legacy-mode behavior cannot regress.
2026-05-21 09:02:18 +02:00
maziggy 5b3962e3e6 fix(obico): Failure Detection status panel shows thresholds for the selected sensitivity (#1469)
The Status panel's Low / High thresholds readout was stuck at 0.38 /
  0.78 regardless of the Sensitivity dropdown, so the setting looked
  dead. Detection itself was correct -- classify() always used the real
  sensitivity -- but get_status() computed the displayed thresholds with
  a hardcoded thresholds("medium").

  get_status() now takes an optional sensitivity argument and the
  /obico/status route passes settings["sensitivity"] (it already loads
  settings fresh). The readout updates as soon as the change is saved.
2026-05-21 08:45:59 +02:00
maziggy 01787eb1e6 fix(printers): normalize serial numbers + diagnose connect-but-no-reports (#1465)
Reporter's H2C connected over MQTT+TLS but every status field stayed
  unknown. Root cause was layer-8: the MQTT broker is the printer, it
  authenticates on the access code and SUBACKs any topic string, so a
  wrong or mis-cased serial connects fine and silently receives nothing
  (the report topic device/<serial>/report is case-sensitive; Bambu
  serials are uppercase). Bambuddy stored and used the serial verbatim.

  - schemas/printer.py: field_validator strip()+upper()s serial_number on
    create, rejects blank-after-strip. The subscribed topic now always
    matches the printer's correctly-cased one.
  - bambu_mqtt.py: count report-topic messages per connection; when a
    stale reconnect fires with zero reports received, log a one-shot
    hint pointing at the serial number instead of looping silently.
2026-05-21 08:30:17 +02:00
maziggy 76582298b5 fix(drying): stop false "drying complete" from killing the printer via smart-plug auto-off (#1462)
Reporter on X2D set a 1h AMS dry; the printer powered off seconds in.
  Support log: every "Sent drying command duration=1" was followed 3-9s
  later by "AMS 0 drying complete (dry_time 60 -> 0)" -- the completion
  callback fired right after drying started, arming smart-plug auto-off.

  Root cause: the tray-bearing branch of the AMS partial-update merge
  rebuilt the unit as {**ams_unit, "tray": merged_trays}, never spreading
  existing_unit. Tray-bearing partials carry no drying fields, so dry_time
  (and info) was dropped; the falling-edge detector read the absent field
  as 0 and saw a false 60->0 edge.

  - Merge: tray branch now spreads existing_unit first, preserving
    dry_time / info / humidity / temp across tray-only partials. Matches
    the no-tray branch. Also fixes dry_status/dry_sub_status UI flapping.
  - Detector: only evaluate the falling edge when dry_time is explicitly
    present and parseable; skip otherwise without updating state.
2026-05-21 08:13:44 +02:00
maziggy ed27b27adb feat(slice): cross-printer re-slicing — drop the gate, the banner, and the dead plumbing
Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
  3MF for a different printer" assumption: feeding an 18-color H2D-bound
  Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
  1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
  and bed_exclude_area all coming from the target bundle.

  - SliceModal: drop !printerMismatch from isReady; remove the banner and
    the sourcePrinterModel / printerProfileName / printerMismatch state
    entirely. Cross-printer slicing is now indistinguishable from a normal
    slice; the picker already shows the target printer.
  - Remove slice.printerMismatch from all 8 locales.
  - API cleanup: drop source_printer_model from /library/files/{id}/plates
    and /archives/{id}/plates responses, drop the field from
    frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
    delete extract_source_printer_model_from_3mf from threemf_tools.py and
    its 6 unit tests. Zero remaining consumers.
  - i18n discipline cleanup in SliceModal.tsx (same drop): strip every
    inline English defaultValue / positional fallback from t() calls (22
    sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
    to all 8 locales — they had no entry in any locale file and were being
    served from the inline English fallback for every non-English user.
  - Tests: rewrite the mismatch-warning test to assert "no banner, Slice
    enabled" when models differ (regression guard); delete 2 obsolete
    tests covering gate states that no longer exist.
2026-05-20 12:21:33 +02:00
Seb 14919a80a5 Merge pull request #1440 from Person2099/fix/filament-override-ams-mapping-dispatch
fix: compute AMS mapping from force-colour overrides when 3MF reqs unavailable
2026-05-20 11:25:45 +02:00
maziggy d3f0e9ac73 fix(spoolman): per-print weight tracker falls back to local slot-assignment table for tag-less spools (#1459)
Reporter on Postgres + Spoolman saw weight never decremented after
  prints. Traced to _report_spool_usage_for_slots calling only
  client.find_spool_by_tag() — which returns None when extra.tag is empty.
  Non-RFID spools assigned via the Bambuddy UI intentionally leave
  extra.tag empty (per #1457 — we don't want fallback tags polluting
  Spoolman), so tag-less spools never got matched and weight tracking
  silently no-op'd. The tracker never consulted the local
  spoolman_slot_assignments table that has the binding.

  Adds _resolve_spool_id_via_slot_assignment() as stage 2 of the
  resolution chain. Stage 1 (existing tag-lookup) wins when present so
  RFID auto-sync remains unchanged. (ams_id, tray_id) derived from
  global_tray_id via the existing _global_tray_id_to_ams_slot helper,
  so external slots and AMS-HT slots resolve correctly. Threaded
  printer_id through the three callers (partial G-code, partial linear,
  final-usage report). Resolution path is logged ("via tag" vs "via
  slot-assignment") so support bundles confirm the fix is live.

  extra.tag is deliberately NOT auto-populated — that would re-introduce
  the exact pollution #1457 cleaned up. Slot-assignment table is the
  source of truth for non-RFID; extra.tag is reserved for hardware RFID.
2026-05-20 11:16:56 +02:00
maziggy 12b0c138f7 fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457)
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
  the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
  correctly showed the freshly assigned full spool. Two layers compounded:

    (1) Non-RFID slots fall back to a deterministic per-slot tag
        (hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
        that tag to Spoolman extra.tag but never cleared it from the
        previous holder on re-binding.

    (2) The frontend's hover-card resolver preferred the (stale) tag-link
        over the user's explicit slot-assignment. Same precedence bug in
        SpoolBuddy's fill-bar resolver and slot-action picker.

  Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
  everywhere. FilamentHoverCard's existing match-dedupe then collapses the
  two "Open in Inventory" buttons back into one.

  Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
  from POST /spoolman/inventory/slot-assignments (with the slot's
  deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
  the literal tag being bound — works for RFID and fallback). Best-effort:
  Spoolman 5xx and per-spool patch failures log + continue, never wedge
  the bind. get_fallback_spool_tag_for_slot promoted to a public helper
  mirroring the frontend's signature exactly.
2026-05-20 11:00:54 +02:00
maziggy fbaf219094 Fix: AMS drying popover positioning + diagnostic logging (#1447)
Two bugs in one report, both shipped here.

  (1) Popover positioning. The flame-icon onClick on PrintersPage
  computed popover position as a fixed { top: rect.bottom + 4,
  left: Math.max(8, rect.right - 240) } with no viewport-overflow
  check. The flame icon sits at the bottom of the AMS info section
  on the printer card, so on most realistic viewports
  rect.bottom + 4 + popover_height (~320px) overruns viewport.height
  and the popover renders partially or entirely off-screen with the
  Start button unreachable. Reporter worked around it via DevTools to
  confirm the popover was actually there, just clipped.

  Extract a computePopoverPosition() helper in utils/popoverPosition.ts:
  - defaults to below + right-aligned to the trigger (preserves the
    original visual layout when there's room),
  - flips ABOVE the trigger when below would overflow AND above fits,
  - stays below in the degraded case (popover taller than viewport) —
    at least the top is visible and the user can scroll inside; flipping
    to a top-clipped position would lose the action buttons too,
  - clamps the left coordinate so a trigger near either viewport edge
    can't push the popover off-screen horizontally either.

  Both PrintersPage callsites (compact AMS row at :3498 and dual-nozzle
  layout at :4011) route through the helper.

  (2) Diagnostic logging for the silent-drying-ignore. Reporter's
  support bundle shows the printer receives every ams_filament_drying
  command (P1S 01.10.00.00 firmware, AMS-HT at ams_id=128) and ACKs
  each one, but the AMS info field never changes — drying neither
  starts nor stops on Bambuddy's request, while pressing Start on the
  printer's touchscreen works immediately. The command JSON matches
  the format documented as working on H2D, all required fields present.
  Diagnosing the silent rejection needs the printer's actual response
  payload — result/reason — but bambu_mqtt.py:918 was only logging the
  response command name, not the body. The existing extrusion_cali_* /
  ams_filament_setting debug path at :919-920 was the template; this
  PR extends it to ams_filament_drying at INFO level (not DEBUG like
  its siblings) because drying responses are rare (user-initiated only)
  and INFO ensures the body lands in support bundles by default without
  the user having to bump log level first. Paired with an outgoing-side
  INFO log inside send_drying_command that captures the full wire JSON,
  so the next bundle has both halves of the conversation.

  No guessing on the command-side. Mutating a field that matches the
  documented-working H2D shape (e.g. flipping close_power_conflict)
  could break currently-working installs. When the reporter retries on
  this build and re-attaches a bundle, the rejection reason is visible
  and the command-side fix follows from real data.
2026-05-20 10:04:41 +02:00
maziggy 0406487eb3 Fix: Add Printer no longer hangs the container on P1S (#1445)
The pre-insert MQTT probe added in 0.2.4.2 (b51598ea) had two bugs that
  compounded on P1S firmware specifically:

  1. Fixed 2-second sleep was too short. P1S broker + TLS handshake
  routinely needs 3-5s to surface CONNACK on a cold MQTT session (same
  firmware family with the documented "broker stops publishing but TCP
  stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected
  a printer that would have connected fine. H2C's broker is snappier and
  cleared the 2s window without trouble — which is why the reporter's
  H2C added without issue and only the P1S misbehaved.

  2. client.disconnect() ran synchronously on the asyncio thread.
  BambuMQTTClient.disconnect() ends in paho's loop_stop() which joins
  the network thread; if that thread was still mid-TLS-handshake to the
  slow P1S socket when teardown ran, the join blocked the asyncio thread
  for as long as the handshake took to complete or fail. POST /printers
  wedged, every other HTTP request queued behind it, Docker healthcheck
  timed out — user-visible symptom: "the container hangs."

  Fix:
  - Replace the fixed sleep with a polling loop (8s budget, 200ms tick,
    early-returns the moment state.connected flips True). Slow brokers
    get the headroom they need; happy-path connects still finish in
    ~1-2s. Constants exposed as PROBE_TIMEOUT_SECONDS / PROBE_POLL_
    INTERVAL_SECONDS class attributes so tests can dial them down.
  - Move client.disconnect() to await asyncio.to_thread(...) so paho's
    thread-join can never block the event loop.

  The empty-card-report-prevention goal of the original probe stays
  intact: a genuinely wrong access code still results in connected=False
  after the 8s budget, the 400 with code=printer_connection_failed
  still fires, the row is still never persisted.
2026-05-20 09:39:10 +02:00
maziggy badf0bed04 Fix: Failure Analysis widget honours edited failure_reason / status (#1444)
PrintLogEntry.failure_reason is captured once at print-completion time
  (main.py:3641) by copying archive.failure_reason — which is NULL while
  the user hasn't classified the failure yet. The PATCH /archives/{id}
  route then writes only to print_archives via a generic setattr loop,
  so the log entry stays NULL and failure_analysis.py keeps grouping the
  print as "Unknown". Same desync hits status — flipping it in the modal
  never reached the entry either.

  Mirror failure_reason and status from the PATCH payload to the latest
  PrintLogEntry for that archive (highest id). Latest-only because
  archive.failure_reason / status already reflect the latest run's outcome
  (each reprint clears the archive value at main.py:2195 and rewrites it
  at completion), so the Edit Archive modal is implicitly editing the
  latest run — reprints of an archive that succeeded on the second attempt
  keep the earlier failed run's original classification intact.

  Scoped to those two fields only. cost / print_name / printer_id stay
  unmirrored because per-run values legitimately diverge from archive
  ones (partial-print cost on a failed run vs source archive's full-print
  cost — see _compute_run_filament_grams at main.py:596).
2026-05-20 09:26:56 +02:00
maziggy 6f050708da Fix: cap TLS to v1.2 for P2S FTPS to dodge vsFTPd session-reuse bug (#1401)
Python 3.13 negotiates TLS 1.3 by default. The P2S firmware 01.02.00.00
  vsFTPd build doesn't tolerate TLS 1.3's async session-ticket model on
  the FTPS data channel — session resumption races, the data channel gets
  torn down mid-stream, uploads land truncated at a chunk boundary, and
  the printer replies 426 instead of 226. Visible to the user as "unable
  to parse 3mf file" 30 s into the print.

  Capping the SSL context's maximum_version to TLS 1.2 makes session
  resumption synchronous and uploads complete normally.

  Follow the per-model pattern established by camera_profiles.py in the
  #1395 follow-up: add backend/app/services/ftp_profiles.py with a frozen
  FTPProfile dataclass and a per-model registry. Only P2S (display name
  + N7 SSDP code) gets the cap today. X1C, H2D, P1S, A1 stay on negotiated
  TLS 1.3 — the maintainer's dogfooded printers see zero behaviour change.
2026-05-20 08:52:10 +02:00