Commit Graph
2392 Commits
Author SHA1 Message Date
maziggy 9a69e2ea97 Housekeeping 2026-05-05 10:54:14 +02:00
maziggy 907fbec138 Housekeeping 2026-05-05 09:54:15 +02:00
Martha Augsburger 7baf1caf75 UI/multi color handling (#1205) 2026-05-05 09:34:16 +02:00
maziggy 3bb99759d2 fix(archive): never delete persistent files in 3MF cache cleanup (#1212)
Daily builds since 889c8bd8 (Apr 29) silently destroyed archive copies
  and library file bytes on every print completion. Reprint / View G-code
  later returned 404 with no log line explaining why; the DB row was
  intact and the archive grid kept showing the entry, but the file
  behind archive.file_path no longer existed on disk.

  Root cause: #1166 added three dispatch sites that cache the live
  archive copy (and library file bytes for Direct-Print) in the shared
  3MF download cache, so /cover could skip a redundant FTP transfer
  mid-print. The cache was originally designed for transient downloads
  under archive_dir/temp/, and clear_3mf_cache(printer_id) — called
  from on_print_complete to keep that temp dir from accumulating —
  happily unlink()'d every cached path. Path.exists() guarded the
  unlink, so no exception, no warning, just silent destruction. Listing
  didn't change; only acting on the archive surfaced the 404.

  Fix: clear_3mf_cache._maybe_unlink refuses to unlink any path outside
  archive_dir/temp. Cache dict is still cleared (so re-cache continues
  to work and /cover hits a fresh path next print), only the on-disk
  delete is gated. Persistent locations — archive/<printer_id>/...,
  archive/unassigned/... (VP-archived prints with printer_id=None),
  library_files/..., is_external library mounts — all survive.

  Regression test test_clear_does_not_delete_persistent_files pins the
  contract end-to-end: archive 3mf, library 3mf, and temp 3mf all
  cached for the same printer; after clear, all three cache entries
  are dropped from the dict, but only the temp file is unlinked from
  disk. Two existing tests updated to put fixtures under
  archive_dir/temp.
2026-05-05 09:24:34 +02:00
maziggy b42aaca521 fix(spool-assign): defer MQTT for empty AMS slot, replay on physical insert
The SpoolBuddy "weigh-then-assign" workflow tried to configure an empty AMS
  slot at assign time, but Bambu firmware silently drops ams_filament_setting
  and extrusion_cali_sel for unloaded slots — the MQTT calls completed and the
  modal closed, yet BambuStudio kept showing the slot as default-PLA forever.

  assign_spool now detects an empty target slot (fingerprint_type empty) and
  persists the SpoolAssignment without publishing MQTT, returning a new
  pending_config flag so the frontend can swap "Assigned!" for "Slot will
  configure when you insert the spool." on_ams_change watches for the slot
  to load (state == 11, which fires for 3rd-party tags too even when
  tray_type stays empty) and replays the deferred ams_filament_setting +
  extrusion_cali_sel — including the printer-kp realignment that converts
  PFUS-prefix cloud user presets to the P-prefix local-preset filament_id
  the slicer actually accepts.

  The full assign-time MQTT block was extracted into
  apply_spool_to_slot_via_mqtt so both the assign endpoint and the
  on_ams_change replay path use the same resolution logic; the helper takes
  ~270 lines of duplication out of assign_spool.
2026-05-04 16:02:18 +02:00
maziggy 8986343999 Revert "."
This reverts commit e05c8a1dca.
2026-05-04 10:59:38 +02:00
maziggy e05c8a1dca . 2026-05-04 10:57:36 +02:00
maziggy aecf8283b5 fix(slicer): strip "-1" inherit sentinels from project_settings.config (#1201)
MakerWorld 3MFs sliced for P2S (and likely other Bambu printers) ship
project_settings.config entries with "-1" on fields BambuStudio writes
to mean "inherit from the parent process preset". The headless slicer
CLI's StaticPrintConfig validator runs against the embedded settings
*before* --load-settings overrides apply, so the sentinel trips the
field's lower-bound check and the CLI exits non-zero before the supplied
profile triplet is consulted. Both slice paths (with-profiles and the
embedded-settings fallback) read the same config and fail the same way,
so the user surfaces the error.

Surgical fix: open Metadata/project_settings.config, remove allowlisted
keys when their value is exactly "-1", re-zip. Allowlist starts with the
two from this report (raft_first_layer_expansion, tree_support_wall_count)
plus prime_tower_brim_width (a known sentinel cited in earlier reports).
Non-allowlisted "-1" values are left alone so a blanket strip can't
corrupt legitimate negative values. Other zip entries pass through
byte-identical — no risk of the failure mode the previous full-strip
experiment hit (silent CLI exit on initialisation).

Sanitiser runs before both slice_with_profiles and slice_without_profiles
paths since both fail on the same sentinel.

13 new unit tests in test_project_settings_sentinel_sanitiser.py cover
the allowlist removal contract (parametrised across all sentinel keys),
preservation of unaffected values, byte-identical round-trip of unrelated
zip entries, and defensive fallbacks for non-zip / malformed / no-config
inputs.

Adding new sentinel keys is one-line: append to
_PROJECT_SETTINGS_SENTINEL_KEYS in library.py.
2026-05-04 10:34:36 +02:00
maziggy 713b85387a fix(archives): validate downloaded 3MF plate against gcode_file (#1204)
Two consecutive plates of the same model would create the second print's
archive with the first plate's metadata: subtask_name lags across the
boundary while gcode_file is fresh, so the FTP candidate list (built
from subtask_name first) lands on the previous plate's still-resident
upload. The 3MF parser then locks the wrong _plate_index, name, time
estimate, and per-slot filament data into the archive at creation.

Fix peeks the downloaded 3MF's slice_info plate index, compares against
parse_plate_id(filename) (the plate parsed from /Metadata/plate_N.gcode,
which always reflects what's running), and on mismatch retries FTP with
swap_plate_suffix(subtask_name, expected_plate) — handling both the
spaced "Plate N" and underscored "_plate_N" suffix forms seen in real
subtask_names. If the retry finds a matching 3MF, the wrong file is
dropped and the corrected one feeds the archive; if no match is found
(or no swap is possible) the wrong file is dropped and the existing
no-3MF fallback creates an archive whose name reflects the right plate.

The validation only runs when parse_plate_id() returns a value, so
single-plate / cloud-named / non-Bambu jobs are unaffected.

17 new unit tests in test_archive_plate_validation.py cover both helpers:
plate-index peek across malformed / missing / non-integer / non-zip
inputs, and the suffix swap across both casings, the underscored form,
case-insensitive matching, and rejection of names without a recognised
suffix.
2026-05-04 10:09:25 +02:00
maziggy 64899a8ca4 refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:

  - Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
    dialog (IP-only).
  - Their printer-MQTT trust path validates only against the bundled BBL CA
    store (`printer.cer`), NOT the system trust store. Confirmed against
    ClusterM/open-bambu-networking's clean-room reimplementation:
    `mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
    chain validation against BBL CA only, hostname check intentionally
    skipped (because Bambu's printer cert CN is the device serial).
  - LE certs don't chain to BBL CA, so the slicer rejects with the
    well-known "-1" before any hostname/IP logic runs.

The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:

  - Toggle stays as an informational marker — when ON, the VP card surfaces
    the host's Tailscale IP + MagicDNS hostname so users know what to paste
    into the slicer.
  - Cert is always self-signed (signed by `bbl_ca`).
  - Tailscale exposure is via the existing bind_ip dropdown, which already
    includes `tailscale0` IPs.
  - Tailscale's role is strictly network reach — same trust burden as LAN.

Backend cuts:

  - `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
    `_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
    `cryptography` import. Keep `get_status` and `TailscaleStatus`.
  - `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
  - `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
    `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
    `_cancel_renewal_task`, `_cancel_restart_task`. Simplify
    `_resolve_cert_and_advertise` to a sync method that just generates the
    self-signed cert. Drop `tailscale_disabled` from the change-detection
    diff (toggle is informational — no service restart needed).
  - `routes/virtual_printers.py` + `routes/settings.py`: drop the
    `tailscale_not_available` 409 guard on toggle-enable.

Frontend cuts:

  - `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
    `multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
    is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
    no longer populated). Drop the `tailscale_not_available` toast handler.
  - `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
  - i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
    the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.

Docs:

  - Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
    — remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
    steps, document the toggle as informational, keep the Docker socket
    mount + LXC TUN troubleshooting (those still apply for daemon
    reachability).
  - README: drop "the Tailscale benefit here is the tunnel, not cert-import
    elimination" framing in favour of "surfaces the IP for paste into
    slicer; CA import unchanged because BBL CA store, not system trust
    store, is what gets validated".

Tests:

  - `test_tailscale.py`: reduced to surviving `get_status` cases (binary
    missing, command fails, success, empty DNSName, malformed JSON).
  - `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
    → `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
    informational; `remove_instance` must NOT be called).
  - `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
    `TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
    directions succeed and daemon is never consulted).
  - `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
    FQDN-copy block drives data through that query.

DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.

Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
2026-05-03 14:58:29 +02:00
MartinNYHC 556d666889 Merge pull request #1202 from maziggy/feature/vp_non_proxy_refactor
feat(vp): mirror live target printer state to slicer in non-proxy modes

In non-proxy VP modes (Immediate / Review / Print Queue), the slicer now
sees real AMS / FTS / nozzle / k-profile state from the target printer
and streams the live camera — full slicer-as-remote functionality without
giving up Bambuddy's queue / archive / dispatch features.

Architecture (cached-as-base, single source of truth). The bridge caches
the latest real push_status and info.get_version response from Bambuddy's
existing per-printer MQTT subscription — no second session on the printer,
firmware in-flight budget unaffected (#1164). _send_status_report serves
a near-byte-identical copy of the cached push with only the upload-state-
machine fields overridden. Command responses (extrusion_cali_get, AMS
write acks, xcam) fan out raw — they carry sequence_ids the slicer is
waiting on. Slicer-issued commands forward to the printer except
project_file / gcode_file, which still terminate locally because the file
lives on Bambuddy. Camera is a raw TCPProxy on bind_ip:322 → printer:322,
same approach proxy mode uses.

Field-shape gotchas pinned in the bridge module's docstring and the
new test file:
  - Real Bambu pushes use json.dumps(indent=4) wire format. Compact JSON
    fails BambuStudio's Send pre-flight silently.
  - net.info[*].ip is the FTP destination IP (little-endian uint32).
    Without rewriting to the VP bind IP, the slicer FTPs straight to
    the real printer.
  - upgrade_state.sn rewritten to VP serial; AMS-hardware sn fields
    (n3f/0.sn etc.) left alone.
  - ipcam.rtsp_url passes through unchanged; BambuStudio overrides the
    URL host with the device IP it bound on, so :322 lands on the VP's
    TCPProxy.
  - extrusion_cali_get must forward; answering it locally hides the
    user's stored per-filament k-profiles.

Setup nuance for camera: the VP's access code must match the target
printer's because the slicer authenticates RTSPS with whatever access
code is in its profile. MQTT and FTP work either way.

Tested e2e with BambuStudio and OrcaSlicer against H2D (dual-nozzle,
AMS 2 Pro + AMS HT) and X1C across all three non-proxy modes — sync,
send, k-profile lookup, AMS configuration from slicer, and live camera
all work. Proxy mode is untouched: SlicerProxyManager owns its own
proxies and never instantiates SimpleMQTTServer or MQTTBridge.

25 new tests in backend/tests/unit/test_vp_mqtt_bridge.py cover lifecycle,
caching, identity / IP rewriting, wire format, slicer→printer routing,
and the LE-uint32 IP encoder against the real H2D capture value.
2026-05-03 13:47:18 +02:00
maziggy 7dea33d0d8 feat(vp): mirror live target printer state to slicer in non-proxy modes
In non-proxy VP modes (Immediate / Review / Print Queue), the slicer now
sees real AMS / FTS / nozzle / k-profile state from the target printer
and streams the live camera — full slicer-as-remote functionality without
giving up Bambuddy's queue / archive / dispatch features.

Architecture (cached-as-base, single source of truth). The bridge caches
the latest real push_status and info.get_version response from Bambuddy's
existing per-printer MQTT subscription — no second session on the printer,
firmware in-flight budget unaffected (#1164). _send_status_report serves
a near-byte-identical copy of the cached push with only the upload-state-
machine fields overridden. Command responses (extrusion_cali_get, AMS
write acks, xcam) fan out raw — they carry sequence_ids the slicer is
waiting on. Slicer-issued commands forward to the printer except
project_file / gcode_file, which still terminate locally because the file
lives on Bambuddy. Camera is a raw TCPProxy on bind_ip:322 → printer:322,
same approach proxy mode uses.

Field-shape gotchas pinned in the bridge module's docstring and the
new test file:
  - Real Bambu pushes use json.dumps(indent=4) wire format. Compact JSON
    fails BambuStudio's Send pre-flight silently.
  - net.info[*].ip is the FTP destination IP (little-endian uint32).
    Without rewriting to the VP bind IP, the slicer FTPs straight to
    the real printer.
  - upgrade_state.sn rewritten to VP serial; AMS-hardware sn fields
    (n3f/0.sn etc.) left alone.
  - ipcam.rtsp_url passes through unchanged; BambuStudio overrides the
    URL host with the device IP it bound on, so :322 lands on the VP's
    TCPProxy.
  - extrusion_cali_get must forward; answering it locally hides the
    user's stored per-filament k-profiles.

Setup nuance for camera: the VP's access code must match the target
printer's because the slicer authenticates RTSPS with whatever access
code is in its profile. MQTT and FTP work either way.

Tested e2e with BambuStudio and OrcaSlicer against H2D (dual-nozzle,
AMS 2 Pro + AMS HT) and X1C across all three non-proxy modes — sync,
send, k-profile lookup, AMS configuration from slicer, and live camera
all work. Proxy mode is untouched: SlicerProxyManager owns its own
proxies and never instantiates SimpleMQTTServer or MQTTBridge.

25 new tests in backend/tests/unit/test_vp_mqtt_bridge.py cover lifecycle,
caching, identity / IP rewriting, wire format, slicer→printer routing,
and the LE-uint32 IP encoder against the real H2D capture value.
2026-05-03 13:43:54 +02:00
maziggy 66a3604d9b Draft commit message:
fix(spoolbuddy): gate display wake on stable scale reading

  A noisy load cell that bounced ≥50 g around its midpoint kept the kiosk
  screen permanently lit. The wake threshold check ran against last_wake_grams
  which itself advanced to noisy values, so every bounce back across 50 g
  re-fired display.wake(), keeping the FIFO reader pumping wlopm --on
  faster than swayidle could trigger wlopm --off.

  The fix gates wake on the scale's `stable` flag — only readings that
  have settled within 2 g over a 1 s window count as a real spool
  placement / removal. Unstable noise can't fire wake and can't advance
  last_wake_grams, so the next genuine settled change is still measured
  against the right baseline.

  Three regression tests pin the contract: noisy ±60 g unstable readings
  never wake, a settled >50 g jump wakes, a noise burst between two
  settled readings doesn't poison last_wake_grams.
2026-05-03 09:22:35 +02:00
maziggy a6c53798d4 fix(notifications): print-complete duration uses actual elapsed, not slicer estimate (#1198)
Pre-fix, _background_notifications in main.py:3434 built archive_data
  with print_time_seconds (the slicer's pre-print estimate parsed from
  the 3MF at archive creation), and notification_service.py:909 formatted
  that field straight into the {{duration}} template variable. A print
  cancelled 2 minutes into a 3-hour estimate notified "duration: 3h".

  Compute actual_time_seconds from started_at/completed_at in main.py and
  add it to archive_data. notification_service.py prefers it, falls back
  to print_time_seconds when the actual can't be derived.

  Also add "cancelled" to the list of statuses that get completed_at set
  in update_archive_status — pre-fix only completed/failed/aborted got a
  timestamp, so queue-UI cancellations had no actual elapsed to compute
  from. Audited every completed_at consumer; none depend on NULL to mean
  "cancelled" (status field already carries that signal), and the
  statistics-totals aggregation gets more accurate too as a side effect.

  3 new regression tests in TestNotificationVariableFallbacks pin the
  {{duration}} variable contract (actual wins over estimate; estimate
  falls in when actual is missing; "Unknown" when both absent).
2026-05-03 08:33:00 +02:00
BurntOutHylian e45f967616 feat(backup): Extend Backup to other Git providers (#1160) 2026-05-03 08:12:36 +02:00
maziggy abc8e97050 feat(camera): optional snapshot URL override for external cameras (#1177)
go2rtc and several IP cameras still emit a warm-up / black frame on every
  fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
  slipped through intermittently for @nkm8's setup. His own bisect named
  the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
  single-frame endpoint that never returns the encoder's stale keyframe.

  Adds an optional external_camera_snapshot_url column on printers. When
  set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
  notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
  Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
  on the override URL via plain HTTP GET, bypassing the warm-up dance.

  Live view stays on the configured stream URL — only single-frame
  captures use the override. Override is camera-type-agnostic. SSRF guard
  applies (existing _sanitize_camera_url allowlist). Empty string treated
  as unset.

  Settings UI: new "Snapshot URL (optional)" input + Test button under
  External Cameras, hidden for camera_type=snapshot since the live URL is
  already a single-frame source. en + de fully translated; 6 other locales
  seeded with English copy.

  5 backend tests pin the routing contract; 3 frontend tests pin the
  input + debounced PATCH. Documented in
  bambuddy-wiki/docs/features/camera.md with the go2rtc example.
2026-05-03 07:59:02 +02:00
maziggy d6a3139397 fix(frontend): emit relative asset paths so SPA loads under any subpath (#1195)
Vite's default base of '/' baked absolute asset URLs into the built
  index.html (/assets/..., /manifest.json, /img/..., /sw-register.js),
  so any path-prefixed reverse proxy (Traefik, nginx subpath, Cloudflare
  Tunnel with path routing) served the SPA as a blank white page — the
  browser requested assets from the host root and got HTML or text/plain
  back, triggering MIME-mismatch errors on every stylesheet/script.

  Set base: '' in vite.config.ts so the HTML transform emits relative
  URLs everywhere. Update public/sw-register.js to register('sw.js')
  (relative) so SW scope auto-pins to whatever subpath the document
  loaded from.

  Out of scope: API_BASE in client.ts is still absolute. The supported
  HA embedding path remains Webpage panel + TRUSTED_FRAME_ORIGINS, not
  HA Ingress (subpath-aware SPA bootstrapping has too many failure modes
  around PWA scope, push subscriptions, and deep-link reloads to take on
  in core). Documented explicitly in docker.md.

  Reported by @Spegeli, follow-up to #1167.
2026-05-03 07:33:40 +02:00
maziggy 875d80ad3c fix(mqtt): lift paho inflight ceiling to prevent QoS=1 session wedge (#1164)
paho's default max_inflight_messages=20 silently fills on Bambu's broker
  after ~16-20 cumulative commands per session, leaving publish() returning
  success while packets sit in paho's internal queue. force_reconnect heals
  it because the inflight queue is per-session, but it costs one wasted
  user action to trigger.

  Lifting the ceiling to 1000 keeps QoS=1 untouched (deliberately chosen
  for cross-model reliability — A1, P1S, X1C, H2D, P2S, X2D all need it)
  and removes the inflight queue as the bottleneck without changing
  wire-protocol behaviour. The 0.2.4b2 watchdog reconnect stays as
  defence-in-depth.

  Diagnosis credit: RosdasHH's QoS=1/0/2 bisect on #1164.
2026-05-02 16:12:48 +02:00
maziggy b02350d423 fix(security): allow iframe embedding from trusted origins via env var (#1191)
Bambuddy ships strict anti-clickjacking headers (X-Frame-Options:
  SAMEORIGIN + CSP frame-ancestors 'none') by default. Internet-exposed
  deployments need this; same-LAN HA Webpage-panel users do not, and
  SAMEORIGIN is port-strict so HA on :8123 + Bambuddy on :8000 always
  fails. azurusnova hit exactly that case.

  Add TRUSTED_FRAME_ORIGINS env var (comma-separated scheme://host[:port]).
  When set, drop X-Frame-Options entirely (modern browsers honor
  frame-ancestors and the legacy ALLOW-FROM syntax is deprecated /
  inconsistent across vendors) and emit "frame-ancestors 'self' <list>"
  on every CSP-bearing route. Origin validation is strict: only http(s),
  no paths, no query/fragment, no wildcards. Bad entries get a warning
  and are dropped — startup never fails.

  Default behaviour (no env var) is unchanged: X-Frame-Options:
  SAMEORIGIN + frame-ancestors 'none', so existing Docker / bare-metal
  deployments are not affected.
2026-05-02 12:32:02 +02:00
maziggy 31577b8d2b Post work PR #1176 2026-05-02 12:18:00 +02:00
Sn0rrii d0d0be89ea fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)
fix(oidc): use preferred_username/name claim for auto-created username

When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
2026-05-02 12:14:32 +02:00
maziggy 3320c7fd45 feat(printers): AMS slot Load / Unload from the printer card (#891)
The ams_load_filament / ams_unload_filament MQTT primitives existed
  in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
  both as POST /printers/{id}/ams/load?tray_id={int} and
  POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.

  Wire them into the existing AMS slot popover (next to "Re-read RFID")
  and add a popover wrapper on the external spool slot which had none.
  Hidden while the printer is RUNNING, mirroring the RFID re-read
  gating. Both buttons enabled when permission is granted; the printer
  no-ops gracefully if there's nothing to do (matches BambuStudio).

  Dual-extruder H2D Ext-R support is the trickier piece. The existing
  ams_load_filament(254) capture came from a single-extruder printer
  and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
  BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
  extruder index, NOT a slot index), target=255, and curr/tar = the
  actual right-nozzle temp (read from state.temperatures["nozzle_2"],
  falling back to 215 °C if cold so the printer doesn't reject the
  command on a nonsensical temp). Added that as a new branch in
  ams_load_filament; the existing tray_id=254 branch is preserved
  verbatim — no risk of regression on single-external setups.
2026-05-02 12:10:09 +02:00
maziggy 459cfdc51f fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
  filament requirements from the 3MF and writes
  required_filament_types + filament_overrides + ams_mapping onto the
  queue item, but the VP queue-mode write path skipped all of that.
  Net effect: scheduler reached its model-only-matching fallback and
  auto-dispatched onto whatever printer was free regardless of loaded
  colour.

  Extract the scheduler's existing _get_filament_requirements 3MF
  parser into a shared helper so the VP path can reuse it. VP's
  _add_to_print_queue now populates required_filament_types
  unconditionally (cheap; helps the scheduler reject obvious type
  mismatches) and writes filament_overrides with force_color_match:
  true per consumed slot when a new per-VP queue_force_color_match
  toggle is on. Default off to preserve current behaviour for
  upgraders.

  UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
  mirroring the existing auto-dispatch toggle. i18n: en + de
  translated, other 6 locales seeded with English copy.

  Schema: one nullable column on virtual_printers
  (queue_force_color_match BOOLEAN, default 0/FALSE).

  11 new backend tests (8 for the extracted parser, 3 for the VP
  write path) + 6 new frontend tests (toggle render gating, default
  state, click posts queue_force_color_match in update body).
  Existing scheduler tests pass against the refactored helper.
  README, CHANGELOG, website features page, and wiki virtual-printer
  page all updated.
2026-05-02 08:38:46 +02:00
maziggy d81040607e fix(api-keys): slice + slicer-presets routes resolve cloud token via key owner (#1182 follow-up)
turulix's headless slicing pipeline got cloud preset IDs from
  /api/v1/cloud/settings (the /cloud/* gate from #1182 worked), but
  slicing those IDs via POST /library/files/{id}/slice failed with
  "no Bambu Cloud session is stored" — the slice route lives on a
  different router, never saw the api_key_owner stash, and
  _resolve_cloud fell through to the empty auth-disabled global
  Settings token.

  Add a permissive route-level dep that returns the API key's owner
  when the key has the cloud scope and None otherwise (never raises),
  so non-/cloud/* routes can opt in without breaking the local-preset
  path. Wire it into POST /library/files/{id}/slice and
  GET /slicer/presets (same root cause, would hit any UI proxied
  through an API key). The route picks current_user or
  api_key_cloud_owner before deriving user_id.

  Auth gate's None-return for API keys is unchanged — keeping the
  owner-resolution scoped to the routes that actually need a cloud
  token prevents scope creep into routes that fence on
  ``current_user is None``.
2026-05-02 08:10:47 +02:00
maziggy 592ec44705 chore(mqtt): log slicer-launched project_file payload for FTS routing diagnostics (#1162) 2026-05-02 07:47:10 +02:00
maziggy 82a593de95 fix(projects): portal-mounted hover preview for cover thumbnails (#1155)
@smandon flagged the 40×40 cover thumbnail as too small to recognise
  the print and asked for a click-to-enlarge full preview. Enlarging
  the thumbnail itself would shift the card grid layout, so keep the
  small thumbnail and show a 384×384 hover popover with the full image
  in ``object-contain`` rendering (so tall MakerWorld photos aren't
  cropped to a square).

  Why a portal: ProjectCard carries ``overflow-hidden`` (rounded
  corners + color accent bar), so any in-tree popover gets clipped the
  moment it extends past the card. Rendering via
  ``createPortal(..., document.body)`` escapes every ancestor clipping
  context, and ``position: fixed`` with measurements from
  ``getBoundingClientRect()`` keeps the popover pinned next to the
  thumbnail regardless of grid position. ``pointer-events-none`` on the
  popover so it can't intercept hover and create a flicker loop;
  ``z-[100]`` so it stacks above sibling cards.

  Edge handling: if the thumbnail is near the viewport's right edge the
  popover flips to the LEFT side of the thumbnail; vertical position is
  clamped so the popover never overflows the window top or bottom. The
  thumbnail's own ``onClick`` is ``stopPropagation``'d so hovering the
  popover area never accidentally triggers the parent card's
  "open project" navigation.

  Tests: 2 new ``ProjectsPage.test.tsx`` cases — mouseenter mounts the
  popover at document.body level (not nested in the card subtree, which
  would re-introduce the clipping bug, and the assertion catches that);
  mouseleave unmounts it; the popover img points at the same
  cover-image URL as the small thumbnail with ``object-contain``; cards
  without a cover_image_filename never mount the portal-rendering
  component.
2026-05-01 13:23:59 +02:00
maziggy 86b54026f6 fix(inventory): hydrate Extra Colours; render Dual Color as bars; punch up Sparkle + checkerboard (#1154)
@maugsburger surfaced four bugs against the original #1154 multi-colour
  swatch work:

  1. Editing an existing spool always opened with the Extra Colours field
     blank, even when the COLOR preview banner above it was rendering
     correctly from the saved data. ColorSection seeded its local
     ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
     mount time, but SpoolFormModal opens *before* its own useEffect
     populates ``formData`` from the spool record — so by the time the
     saved value landed, the input had already locked onto ''. The user
     then had to retype the value before saving anything else.

  2. Dual Color and Gradient produced the same diagonal blend
     (``linear-gradient(135deg, A, B)``), so the two variants were
     visually indistinguishable. The whole point of the Dual Color variant
     is that the spool has two distinct bars on the reel — a smooth blend
     defeats it.

  3. Sparkle was almost invisible on card-sized swatches. The original
     4-dot pattern (each ~1px) read fine on the inline 20×20 swatch but
     disappeared on the 60-pixel inventory card banners — exactly where
     the user actually identifies a spool.

  4. Checkerboard cell density scaled with the swatch — the same 4-cell
     pattern was either tiny squares on a small swatch or four huge
     squares on a card-sized banner. The user couldn't tell a translucent
     filament from a multi-colour one because the indicator changed shape.

  Fix:

  - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
    whenever the parent's ``formData.extra_colors`` changes via an
    external update. ``commitExtraColors`` updates the ref before
    calling ``updateField`` so live user typing is round-tripped without
    the resync useEffect clobbering it.

  - ``filamentSwatchHelpers.ts: buildColorLayer``: branch on
    ``effect_type``. ``dual-color`` and ``tri-color`` produce
    ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
    double-position stops (hard line, not blend) and equal-width
    segments. ``gradient`` keeps the original 135° smooth blend. The
    ``multicolor`` conic-gradient path is untouched.

  - ``filamentSwatchHelpers.ts: EFFECT_OVERLAYS.sparkle``: bumped from 4
    dots to 13 flecks in mixed sizes (1 / 1.5 / 2 px) and varying
    opacity (0.65 → 1.0) for a depth-of-field "metal flake" feel.

  - ``filamentSwatchHelpers.ts: buildFilamentBackground``: now returns
    ``{ backgroundImage, backgroundSize }`` so per-layer sizes can be
    applied — painted layers stay ``cover``, the checkerboard gets a
    fixed 12px tile so cell density is constant regardless of element
    size. Updated the three existing call sites (``InventoryPage`` group
    banner + spool card, ``ColorSection`` preview) to spread the style
    object directly. ``FilamentSwatch.tsx`` composes the same per-layer
    sizing inline so its output stays in lockstep.

  Tests: 8 new frontend cases pinning the four fixes — Dual/Tri Color
  hard-split (3 tests + 1 regression guard that Dual ≠ Gradient for the
  same stops), Sparkle prominence (≥ 10 distinct radial-gradient layers
  in the rendered background), checkerboard density (last backgroundSize
  layer is a fixed pixel value, not ``cover``), 4 hydration cases (fills
  when formData arrives via parent update, resyncs when the spool
  changes mid-form, doesn't clobber live user typing, clears when the
  new spool has no extra_colors). Existing buildFilamentBackground tests
  updated for the new return-object shape. Full frontend suite: 1610
  passed; full backend suite: 3598 passed; no regressions.
2026-05-01 13:04:10 +02:00
maziggy 6a1e3454db fix(inventory): hydrate Extra Colours on edit + render Dual Color as hard-split bars (#1154)
@maugsburger surfaced two bugs against the original #1154 multi-colour
  swatch work:

  1. Editing an existing spool always opened with the Extra Colours field
     blank, even when the COLOR preview banner above it was rendering
     correctly from the saved data. ColorSection seeded its local
     ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
     mount time, but SpoolFormModal opens *before* its own useEffect
     populates ``formData`` from the spool record — so by the time the
     saved value landed, the input had already locked onto ''. The user
     then had to retype the value before saving anything else.

  2. Dual Color and Gradient produced the same diagonal blend
     (``linear-gradient(135deg, A, B)``), so the two variants were
     visually indistinguishable. The whole point of the Dual Color variant
     is that the spool has two distinct bars on the reel — a smooth blend
     defeats it.

  Fix:

  - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
    whenever the parent's ``formData.extra_colors`` changes via an
    external update (modal opening with a spool, or switching to a
    different spool mid-form). ``commitExtraColors`` updates the ref
    before calling ``updateField`` so the user's own typing is round-
    tripped without the resync useEffect clobbering it.

  - ``filamentSwatchHelpers.ts``: ``buildColorLayer`` now branches on
    ``effect_type``. ``dual-color`` and ``tri-color`` produce
    ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
    double-position stops — the colour change is a hard vertical line
    rather than a blend region — and equal-width segments across N stops.
    ``gradient`` keeps the original 135° smooth blend. The
    ``multicolor`` conic-gradient path is untouched.

  Tests: 4 new ``FilamentSwatch.test.tsx`` cases pinning the hard-split
  contract (Dual Color uses ``to right`` not ``135deg``; Tri Color
  renders 3 equal hard-split bars; ``gradient`` keeps the smooth
  diagonal; explicit regression guard that Dual Color and Gradient never
  produce the same CSS string for the same stops). 4 new
  ``ColorSectionExtraColorsHydration.test.tsx`` cases pinning the input
  hydration (fills when formData arrives via parent update, resyncs when
  the spool changes mid-form, doesn't clobber live user typing, clears
  when the new spool has no extra_colors). Full frontend suite: 1608
  passed; full backend suite: 3598 passed; no regressions.

  The minor "Sparkle could be more prominent / checkerboard denser"
  feedback in the same comment is deferred to a separate cosmetic pass —
  the reporter flagged it as finetuning.
2026-05-01 12:53:03 +02:00
maziggy 01a7e6ee93 fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced
  two distinct holes:

  1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
     default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
     ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.

  2. The pending-uploads review card always showed the raw FTP filename,
     while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
     embedded title (or, with the toggle on ``filename``, the stripped stem).
     Net effect: same upload showed two different names depending on which
     view you were looking at, with no way for the toggle to flip both
     views in lockstep.

  Three changes:

  - ``resolve_display_stem`` helper in ``services/archive.py`` strips
    ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
    the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
    every flow that produces a ``PrintArchive`` row.

  - ``PendingUpload.metadata_print_name`` (new nullable column) is
    populated at FTP-receive time by peeking at the 3MF's embedded title
    via the existing ``ThreeMFParser``. Read happens once per upload —
    the list endpoint then doesn't have to reopen each 3MF on every
    render. Parser failures are swallowed and the column stays NULL;
    the response model gracefully falls back to the stripped filename.

  - ``PendingUploadResponse.display_name`` is a computed field that
    mirrors ``archive_print``'s exact precedence — ``filename`` toggle
    → stripped stem; ``metadata`` toggle (default) → cached title or
    stripped stem. The frontend's review card reads it (with
    ``upload.filename`` as a defensive fallback) and surfaces the raw
    FTP filename via tooltip so users can still inspect what arrived.

  Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
  metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
  rows have NULL and degrade to filename-stem behaviour without any
  operator action.

  Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
  canonical normalisation rules (Bambu Studio default name, mixed case,
  dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
  inputs); 6 integration tests in ``test_pending_upload_display_name.py``
  pinning the response contract (default toggle uses metadata title when
  present, falls back to stripped stem when absent, ``filename`` toggle
  overrides metadata, ``filename`` toggle still strips the double suffix,
  ``GET /{id}`` exposes the same field, whitespace-only metadata behaves
  like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
  pinning the review card's render path (resolved name shown, fallback
  to filename when display_name is empty, raw filename available via
  tooltip). Full backend suite: 3598 passed; frontend build clean; no
  regressions in any flow that previously processed ``.3mf`` /
  ``.gcode`` / non-3D filenames.
2026-05-01 12:34:56 +02:00
maziggy 46468c9602 Updated .github/workflows/cleanup-ghcr.yml 2026-05-01 11:49:46 +02:00
maziggy 133ec72527 feat(api-keys): per-user ownership + opt-in cloud access scope (#1182)
Tim (@turulix) is building a fully automated headless slicing pipeline
  against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
  resolve cloud_token per-user from User.cloud_token, but the auth gate
  returned None for API-keyed requests, so the route fell back to the
  global Settings-table token, which only carries a value in auth-disabled
  deployments. Net effect on auth-enabled deployments: API keys reached
  the gate just fine, then /cloud/filaments always saw user=None and
  returned 401 / empty results — no path to read slicer presets or the
  filament catalogue that a CLI workflow needs.

  Make API keys carry an owner and route /cloud/* lookups through that
  owner; gate the new capability behind an explicit opt-in scope so
  existing automation doesn't gain cloud-read access on upgrade.

  - APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
    can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
    explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
    FK enforcement off — same pattern as the existing created_by_id
    cleanup blocks.

  - New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
    the API-key owner stashed by a router-level gate. The auth gate itself
    continues to return None for API keys so #1182's surface stays bounded
    to /cloud/* — without that bound, any route that fences API keys via
    `if current_user is None: raise 403` (e.g. long-lived-token
    management) would silently start accepting them.

  - The /cloud/* router-level dep enforces three independent fences for
    API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
    recreate copy), can_access_cloud=True (otherwise 403), and owner has
    cloud_token (existing fence, unchanged). Two extra one-shot fence
    errors at create/update time refuse can_access_cloud=True when auth
    is disabled or the key is ownerless.

  - Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
    "Legacy" badge on ownerless rows; create form gains an "Allow cloud
    access" toggle, default off. New i18n keys in all 8 locales (en + de
    fully translated, others seeded with English fallbacks pending native
    translation — matches the project's flow for newly-added features).

  Migration: two idempotent ALTER TABLE statements + an index on user_id
  for the auth gate's owner→keys lookup. Postgres-safe.

  Tests: 9 backend integration tests in test_api_key_cloud_access.py
  covering creation flags, the three /cloud/* fences, JWT no-op, and
  deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
  matrix and the create-form contract; 5 daemon unit tests for the
  related SpoolBuddy ssh-key sync work that landed in the same branch.
  Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
  regressions.

  Permission semantics for existing keys: keys created before this
  release become "legacy" and are rejected at /cloud/* with the recreate
  message. Every other endpoint they were used against — queue, status,
  control — is untouched.
2026-05-01 11:43:55 +02:00
maziggy 2aabbe5d37 fix(spoolbuddy): sync SSH key over heartbeat to survive Bambuddy keypair rotation
Bambuddy's SSH keypair under <DATA_DIR>/spoolbuddy/ssh/ regenerates whenever
  the data dir is recreated (volume remount, container recreate, fresh deploy).
  The daemon previously only fetched the pubkey at registration, so any
  rotation after a successful boot left ~/.ssh/authorized_keys pointing at
  a stale public half — every Update click then failed with "Connection
  closed by authenticating user spoolbuddy [preauth]" until the daemon was
  restarted by hand. Each prior registration also appended a fresh entry
  without pruning, accumulating stale Bambuddy-tagged keys indefinitely.

  - HeartbeatResponse now carries ssh_public_key; the heartbeat route reads
    it via the same try/except shape as the register route so a missing or
    unreadable backend key doesn't break telemetry.
  - _deploy_ssh_key() strips lines tagged bambuddy-spoolbuddy and writes
    the current key once. No-op when already in sync (no mtime churn on
    every heartbeat). User-managed entries are preserved.
  - Daemon heartbeat handler calls _deploy_ssh_key when the response
    carries a key, so rotations propagate within one heartbeat instead
    of requiring a service restart.

  Tests: 5 unit (creates-when-missing, replace-stale-pileup, preserve-user-keys,
  idempotent, swallows-write-errors) + 2 backend integration (heartbeat carries
  the key; backend key-read failure leaves ssh_public_key None but the
  heartbeat still 200s).
2026-05-01 10:44:01 +02:00
maziggy 44e4b6b5d7 Workflow: .github/workflows/cleanup-ghcr.yml runs Sundays at 03:00 UTC across both packages, with a manual workflow_dispatch and a dry_run toggle. It builds the live-digest
set the same way we just did, so it won't ever delete a digest still referenced by a tagged manifest.
2026-05-01 09:41:01 +02:00
maziggy ddf3dc0c84 fix(camera): skip MJPEG warm-up frame, return second representative frame (#1177)
_capture_mjpeg_frame returned the very first JPEG it found in the
  bytes stream, but many MJPEG sources — go2rtc most notably, and
  several IP cameras — emit a warm-up frame on the byte that follows
  connection accept: usually the last keyframe held in the encoder,
  typically black or stale until the encoder catches up to live
  content. Subsequent frames on the same connection are fine.

  Result: every code path that opened a fresh capture (snapshot UX,
  finish photos in notifications, timelapse, plate-detection CV,
  Obico ML inference, Settings → Test button) returned a black image
  on go2rtc-fronted cameras.

  Reporter's support log showed every black frame was 11095 bytes
  (pure-black 1280x720 JPEG ≈ 10-15 KB) while real-content frames
  from the same source were 30-45 KB.

  Fix:

  - Read past the first complete JPEG, return the second.
  - Fall back to the first frame if the connection closes / times out /
    hits the 5 MB buffer cap before a second arrives. Without that
    fallback, slow / single-frame streams that pre-fix returned the
    warm-up would post-fix return None — a regression. The fallback
    guarantees we never do worse than current behaviour.
  - Inner while-loop now drains every complete frame already in the
    buffer before pulling the next chunk so high-FPS sources that
    pack multiple frames per chunk are handled correctly.

  Untouched: snapshot / rtsp / usb capture paths, generate_mjpeg_stream
  (live-view fan-out).

  7 new regression tests in TestCaptureMjpegFrameWarmupSkip cover
  two-frames-in-two-chunks, two-frames-in-one-chunk, partial-frame-
  split-across-chunks, single-frame fallback, timeout fallback, zero-
  frame stream returns None, non-200 returns None.

  Latency penalty: at most one frame interval (typically 50 ms - 1 s
  on a steady stream), well within every caller's tolerance window.
2026-05-01 08:43:16 +02:00
maziggy 32b5c42dfb fix(permissions): hide MakerWorld nav entry from users without makerworld:view (#1175)
Backend routes were already gated on makerworld:view, the permission
  was granted to admin + standard-user role defaults, and the frontend
  Permission type union already included 'makerworld:view' — but the
  sidebar's hand-maintained navPermissions map in Layout.tsx had no
  entry for `makerworld`. So `isHidden('makerworld')` always returned
  false, the entry rendered for every authenticated user regardless
  of group permissions, and the only way the user found out they
  couldn't use it was by clicking and getting 403'd by every API call.

  Fix is two lines:

  - Layout.tsx: add `makerworld: 'makerworld:view'` to navPermissions,
    matching every other sidebar entry's gating shape.
  - App.tsx: wrap the /makerworld route in PermissionRoute for defence
    in depth, so a user who knows the URL can no longer reach the page
    directly. Same pattern already used by settings, groups/new, and
    groups/:id/edit two lines below.

  Two new Layout tests pin the contract: with auth enabled and a user
  lacking makerworld:view, the sidebar <a href="/makerworld"> link is
  absent while other links still render; with the permission granted,
  the link renders.
2026-05-01 08:31:19 +02:00
maziggy 59c58362ba fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)
PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
  which is gated by the secure-context requirement (HTTPS or localhost).
  On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
  navigator.clipboard is undefined; the existing try/catch swallowed the
  TypeError, the icon never flipped to the tick, and nothing landed on
  the user's clipboard.

  Fixed by adding the same off-screen-textarea + document.execCommand('copy')
  fallback that CameraTokensPage's plaintext-token modal already uses for
  plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
  fall back to the legacy path otherwise, and surface the success-tick only
  when the copy actually landed (return early without flipping `copied` if
  execCommand returns false). The try/finally around the textarea guarantees
  DOM cleanup even when the browser throws on a restricted context.

  3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
  happy path (navigator.clipboard.writeText is called with the correct value),
  the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
  left in the DOM), and the finally cleanup when execCommand throws
  synthetically.
2026-05-01 08:21:37 +02:00
maziggy 25eab96817 fix(scheduler): raise plate-clear gate for every terminal status (#1171)
The plate-clear gate added in #961 was raised only when a print ended
  with status completed or failed. Aborted prints (printer self-abort
  or a user stopping the print from the printer's own touchscreen) and
  cancelled prints (user stopping via the Bambuddy queue UI) did NOT
  raise the flag, so the queue scheduler dispatched the next pending
  item ~2 seconds later onto a fouled bed.

  The reporter saw two prints (P1P + P1S) auto-start onto fouled beds
  within seconds of touchscreen-aborts, and explicitly flagged the
  risk of damage to the printer. A third printer behaved correctly
  because its previous print had ended "completed" — the asymmetry he
  noticed was the gate working for one terminal status and not the
  other three.

  Touchscreen-aborts are particularly important to gate. Bambuddy's
  existing "user stopped via UI" override (which translates aborted
  to cancelled when _user_stopped_printers is populated) only fires
  for stops through the Bambuddy queue UI; a touchscreen stop reports
  aborted straight through.

  The original code comment claimed user-cancelled prints don't need a
  plate-clear ack because "nothing printed on the bed". That only
  holds if you cancel right at layer 1; a cancel at hour 11 of a
  12-hour print leaves a fully fouled bed.

  The gate is user-clearable on the Printers page, so worst case a
  user who cancels at layer 1 clicks "Clear Plate" once — that's a
  non-issue compared to auto-dispatching onto material.

  Regression coverage in test_print_lifecycle.py::TestPlateClearGate:
  parametrised across all 4 terminal statuses asserting
  set_awaiting_plate_clear(printer_id, True) is called for each, plus
  a defence-in-depth test that an unrecognised future status string
  never silently raises the gate.
2026-05-01 08:13:52 +02:00
maziggy 4aea4be2bd feat(updates): detect HA Supervisor addon and defer update UI to it (#1167)
Bambuddy already supports running as a Home Assistant addon
  (HA_URL/HA_TOKEN env-var integration since #283, community addon at
  hobbypunk90/homeassistant-addon-bambuddy), but the update UI was
  oblivious to it: HA addon users saw the in-app "Update available"
  banner and, on Settings, the docker-compose snippet — neither of
  which they can act on, since the HA Supervisor owns the addon
  lifecycle.

  Detection uses the SUPERVISOR_TOKEN env var that HA Supervisor
  injects into every addon container; no other environment sets it,
  so the check has zero false-positive surface.

  Backend:
    - new _is_ha_addon() helper in routes/updates.py
    - /updates/check now returns is_ha_addon: bool and extends
      update_method to 'git' | 'docker' | 'ha_addon'
    - /updates/apply checks HA before Docker (HA addons ARE Docker
      containers, so checking docker first would mis-classify) and
      returns an HA-specific message that points to Settings →
      Add-ons → Bambuddy in HA
    - response keeps is_docker: true alongside is_ha_addon: true so
      older frontend bundles still hit a managed-deployment branch
      instead of rendering an Install button that can't work

  Frontend:
    - SettingsPage update card branches on is_ha_addon BEFORE
      is_docker; HA users get a Supervisor-targeted message instead
      of the docker-compose snippet
    - Layout update banner is suppressed for HA addons — HA
      Supervisor surfaces its own update notification natively, so
      Bambuddy's banner would be duplicate noise linking to a page
      that just says "update via HA"
    - Plain Docker deployments are unaffected

  i18n: settings.updateViaHomeAssistant added to all 8 locales with
  full native translations.

  Tests: 3 backend unit tests for _is_ha_addon (present, absent,
  empty-string treated as unset), 3 backend integration tests
  (HA-precedes-Docker rejection on apply; HA branch on check; plain
  Docker branch on check), 2 SettingsPage tests pinning the
  mutually-exclusive UI rendering, 2 Layout tests pinning banner
  suppression for HA and retention for plain Docker.
2026-05-01 08:01:19 +02:00
maziggy 889c8bd87f fix(printers): show correct plate thumbnail on multi-plate 3MFs (#1166)
P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
  filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
  The /cover route's regex falls back to plate 1 — and the printer card
  shows the wrong plate's thumbnail on multi-plate prints.

  Resolution order in the new resolve_plate_id() helper (used by both
  the status route's current_plate_id and /cover):

  1. The plate Bambuddy dispatched. start_print() now records
     (dispatched_plate_id, dispatched_subtask) on PrinterState; the
     subtask check rejects stale records from a previous Bambuddy
     dispatch bleeding into a Studio-direct print on the same project.
  2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
     for firmware that does include the path).
  3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
     covers per-plate archives sliced separately in Studio without a
     Bambuddy dispatch record.
  4. Default to plate 1.

  Cover-byte cache key simplified to (subtask_name, view_key) now that
  plate resolution is late-bound. clear_cover_cache() already fires on
  every print start, so re-dispatches with a different plate always
  fetch a fresh thumbnail.

  Bambuddy-dispatched prints additionally register the local archive
  3MF in the cover cache at dispatch time, so /cover reads straight
  from the archive directory and doesn't refetch the file over FTP
  from a printer whose FTP server is busy serving the active print.

  Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
  dispatch record on start_print, 2 integration tests for the cover
  route (dispatch wins over plate-1 default; 3MF-scan fallback for
  per-plate archive without dispatch record).
2026-04-29 16:51:38 +02:00
maziggy b45ca2a662 feat(printer): support Filament Track Switch (FTS) accessory in print modal (#1162)
The FTS routes any AMS slot to either extruder, so AMS info reports
  bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
  The print modal's per-nozzle dropdown filter then hides every loaded
  slot, leaving the user with an empty filament dropdown.

  Detection: parse print.device.fila_switch from MQTT push_status into a
  new FilaSwitchState dataclass on PrinterState; surface it through the
  GET /printers/{id}/status response as a nullable FilaSwitchResponse.

  Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
  filter when fila_switch.installed is true. Slots currently fed into a
  track display an [L]/[R] routing badge in the dropdown so the user
  can see where the FTS is currently routing them.

  Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
  integration (status route), 2 hook regression, 2 component regression.
2026-04-29 16:18:45 +02:00
maziggy f45f6a131d Updated Github issue template 2026-04-29 13:08:16 +02:00
maziggy dac6cbfe4e fix(mqtt): reset unanswered counter on any ams_filament_setting response (#1164)
Configuring AMS slots ~6 times in a row would silently stop reaching
  the printer, with filament colours jumping around briefly ~1 min
  later. Root cause was the zombie-session watchdog from #887.

  When an ams_filament_setting response took >10 s (normal under load)
  the watchdog set `_ams_cmd_unanswered=1` and zeroed
  `_last_ams_cmd_time` so it wouldn't re-fire on every status push.
  The response handler that resets the counter required
  `_last_ams_cmd_time > 0` — so when the late response arrived, the
  reset path skipped it, leaving the counter armed at 1. The next
  slow response on a fresh command (possibly minutes or hours later)
  would take the counter to 2 and force-reconnect mid-publish — the
  in-flight command got dropped, surfacing as "Cannot set AMS
  filament setting: not connected" if the user retried during the
  ~1 min reconnect window.

  Fix: drop the `_last_ams_cmd_time > 0` guard. Any
  ams_filament_setting response proves the channel is alive, so the
  counter must reset unconditionally. Real zombie sessions (no
  responses at all for two consecutive >10 s windows) still trip the
  watchdog correctly.

  Regression test in test_bambu_mqtt.py drives the exact reporter
  sequence: watchdog fires (clears timer, increments counter) →
  late response arrives (must reset counter) → next slow response
  (must only count as 1, not 2). Other 10 zombie-detection tests
  still pass.
2026-04-29 12:50:30 +02:00
maziggy 68c4a5b839 fix(updates): install the discovered release tag, not hardcoded origin/main
The in-app updater ran `git fetch origin main && git reset --hard
  origin/main` regardless of which version the GitHub releases API
  reported as latest. So whenever the latest release lived on a branch
  other than main — e.g. during a beta cycle when 0.2.4b1 sits on its
  own branch and main still points at the previous stable — clicking
  Apply Update appeared to succeed but the user actually stayed pinned
  to old main HEAD.

  Fix: extract `_discover_target_release(db)` mirroring the same
  release-API + include_beta_updates selection the GUI's update-check
  already uses, pass the resolved tag (e.g. `v0.2.4b1`) into
  `_perform_update(target_ref)`, and run `git fetch --prune --tags
  origin && git reset --hard <target_ref>`. The fetch now pulls --tags
  so a tag ref is locally resolvable; the reset takes the caller's
  ref instead of a hardcoded branch. apply_update now returns a clear
  error if no release resolves, instead of silently kicking off an
  update that can't land.
2026-04-29 12:17:25 +02:00
maziggy cc5692a283 fix(updates): preserve SSH origin pointing at the right repo
The in-app Apply Update path unconditionally ran `git remote set-url
  origin https://github.com/maziggy/bambuddy.git` before fetching, on
  the theory that systemd service users wouldn't have SSH keys. True
  in production, but it also clobbered every developer's SSH origin
  the moment they tested the upgrade flow against their own checkout.
  Next `git push` then prompted for HTTPS credentials and bounced.

  New behaviour: read `origin` first via `git remote get-url`, parse
  out the (owner, repo) pair using a small helper that handles all
  four canonical forms (git@github.com:owner/repo[.git] and
  https://github.com/owner/repo[.git]), and only rewrite if it doesn't
  already resolve to maziggy/bambuddy. Native installs with no remote
  or pointing at a fork still get reset to the canonical HTTPS URL.

  Three new regression tests in test_updates_api.py:
    - parser accepts SSH/HTTPS, with/without .git, rejects non-GitHub
    - SSH origin pointing at maziggy/bambuddy is preserved (the
      developer-footgun case)
    - origin pointing at a fork still gets rewritten to HTTPS (the
      original behaviour we don't want to lose)
2026-04-29 11:45:30 +02:00
maziggy f6dc3a2661 Updated CHANGELOG 2026-04-29 11:39:42 +02:00
maziggy a85855f2dd fix(updates): run pip install in app_dir, not base_dir, on native installs
Native-install upgrade via the in-app Apply Update button got the new
  code in via `git reset --hard origin/main` but then logged

    ERROR: Could not open requirements file:
    [Errno 2] No such file or directory: 'requirements.txt'

  and continued. The new deps never installed, leaving the user with
  new code but stale dependencies — surfaces as cryptic import errors
  on the next restart.

  Root cause: `pip install -r requirements.txt` ran with
  `cwd=settings.base_dir`. On a native install, systemd sets
  DATA_DIR=$INSTALL_PATH/data so base_dir resolves to the data dir
  (e.g. /opt/bambuddy/data), not the source tree. Pip doesn't walk up
  looking for the requirements file the way git walks up looking for
  .git, so it fails. Same bug affected the optional npm step
  (`frontend_dir = base_dir / "frontend"` doesn't exist).

  Fix: introduce `settings.app_dir` pointing at the source-tree root
  (distinct from `base_dir` only on native installs) and run pip +
  npm with `cwd=settings.app_dir`. Git ops keep using `base_dir`
  because they already work (git walks up).

  Docker users were unaffected — Docker doesn't use the in-app updater
  (image pull replaces it).

  Regression test in test_updates_api.py mocks every subprocess in
  _perform_update, captures their cwd, and asserts the pip step runs
  in app_dir and that requirements.txt actually exists there. Any
  future refactor that re-introduces cwd=base_dir for the pip step
  fails CI before another user trips over it.
2026-04-29 11:39:17 +02:00
maziggy eec9ef4a41 Bumped version 2026-04-29 11:28:34 +02:00
maziggy be6342932f fix(restore): drop tables with CASCADE so orphan FKs can't abort restore
Settings -> Backup -> Restore on a Postgres-backed Bambuddy aborted
  with `cannot drop table printers because other objects depend on it`
  when the live DB held orphan tables from removed features. Legacy
  `spoolman_slot_assignments` / `spoolman_k_profile` from an earlier
  Spoolman integration still sat in the schema with `*_printer_id_fkey`
  constraints back to `printers`, so `metadata.drop_all` (which only
  knows about ORM tables, no CASCADE) couldn't drop `printers` and the
  whole restore aborted before any rows landed.

  Replace `metadata.drop_all` with a `pg_tables`-iterating PL/pgSQL DO
  block that DROPs every public-schema table with CASCADE, then call
  `metadata.create_all` to rebuild the schema. CASCADE removes external
  constraints alongside the table, and a "restore" is intentionally
  destructive — the user has explicitly chosen to wipe the DB and
  replace from backup.

  Two regression tests in test_postgres_restore_drop_cascade.py mock
  the Postgres engine, capture the SQL stream, and assert (a) the
  CASCADE+pg_tables iteration is emitted and metadata.drop_all is
  never called, (b) the drop is scoped to public schema so shared
  Postgres setups aren't taken out.

  SQLite restores go through a separate path and are unaffected.
2026-04-29 11:17:43 +02:00
maziggy 7bc4712480 chore(i18n): full parity across all 8 locales + drop two-tier check
Backfill 276 missing translations across fr / it / ja / pt-BR so all
  8 shipped locales now match en. Most gaps come from features that
  landed with en+de+zh translations only:

    - login.resetPassword.* (12 × 3): fr, it, ja
    - printers.firmwareModal.* (7 × 4): all locales
    - settings.spoolbuddy.* (~40 × 3, plus 18 in ja): admin device-
      control block (unregister / reboot / shutdown / update /
      restart confirms)
    - spoolbuddy.settings.* (13 × 4): kiosk backend & auth + diagnostics
    - virtualPrinter.archiveNameSource.* (4 × 4): from this release

  Also fixes 27 ja and 1 fr placeholder-name mismatches that silently
  broke interpolation at runtime — e.g. printers.activeNozzle used
  {{side}} while the runtime passes nozzle, and several keys had
  {{count}} dropped entirely so the value would never render.

  Drop the STRICT / info two-tier machinery from check-i18n-parity.mjs:
  en is the reference, every other locale is checked identically,
  any drift fails CI. The previous tier was just deferred policy, no
  real distinction.

  All 8 locales now sit at 4492 leaves. Parity script, i18n test suite
  (11 tests), and full frontend build all green.
2026-04-29 10:45:43 +02:00
maziggy a34beaa599 feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex
  stops) and effect_type (14 visual variants: surface effects, sheen,
  structural). The shared FilamentSwatch component renders gradient,
  conic, effect overlay, and alpha-checkerboard consistently across the
  inventory grid, table, group banner, card, ColorSection preview, and
  catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
  can carry transparency too.

  The paste field accepts the exact format 3dfilamentprofiles.com puts on
  its filament details pages, so users can copy a multi-colour combo
  directly. The effect dropdown spans the full filament-variant
  vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
  variants (silk/galaxy/rainbow/metal/translucent), and structural
  variants (gradient/dual-color/tri-color/multicolor). None of these
  fields touch MQTT/firmware -- pure visual hint.

  Spool group-key extended to include extra_colors + effect_type so
  "Group similar" no longer collapses visually distinct spools.

  Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
  ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
  VARCHAR length).

  Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
  FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
  regression). 3522 backend + 1582 frontend tests pass; ruff clean.
  Localised across all 8 UI locales.
2026-04-29 09:13:33 +02:00