Commit Graph
141 Commits
Author SHA1 Message Date
maziggy 1b43488016 fix(printers): recover large-3mf metadata after FTP timeout (#972)
Two-part root cause for missing photos/filament/cost on large prints
  (#972). The configured ftp_timeout was only plumbed through as the FTP
  socket timeout; the asyncio.wait_for wrapping run_in_executor stayed on
  its 60s hardcoded default, so the user's 300s setting never applied.
  Worse, asyncio.wait_for cannot cancel run_in_executor threads — after
  the 60s outer timeout fired, the executor thread kept running
  ftplib.retrbinary and frequently completed the download ~30–60s later,
  but by then the async wrapper had returned False. with_ftp_retry kept
  re-attempting the same path, each retry truncating the file the zombie
  thread had just written, and the archive was ultimately persisted as a
  fallback with no 3MF.

  download_file_async now accepts timeout at each call site (plumbed from
  ftp_timeout) and salvages post-timeout success via an explicit
  completion flag the executor thread sets only after download_to_file
  returns True. Per-attempt completion dict so a prot_p zombie can't
  flip the flag for a later prot_c attempt. A cosmetic // prefix in the
  directory-search download path is also fixed by replacing string
  concatenation with posixpath.join.
2026-04-15 07:52:28 +02:00
maziggy 899c2c6480 revert(printers): remove SD card badge entirely
Four attempts at making the printer-card SD badge stable on H2D all failed:
  the final straw was powering on an A1 causing every connected H2D to flip to
  red simultaneously. Bambu firmware SD signaling is not reliably derivable
  from MQTT — the legacy `sdcard` field is sporadic and inconsistently typed,
  and home_flag bits 8-9 are cleared on heartbeat pushes regardless of card
  state with no clean way to distinguish heartbeats from full status reports.

  Remove the badge from the Printers page card and the Printer Info modal,
  drop `sdcard` from the frontend PrinterStatus type, and strip all home_flag
  derivation and heartbeat-handling code from the MQTT parser.

  `state.sdcard` is retained on the backend and populated only from a plain
  truthy read of the `sdcard` field, because firmware_update.py uses it as a
  precondition before starting firmware installs.
2026-04-14 18:41:46 +02:00
maziggy 0d7c0d4054 fix(printers): stop H2D SD badge from flipping red on heartbeat bursts
Third follow-up on the H2D SD card badge. The prior 3-strike downgrade still
  lost the race: on idle printers, a nearby printer coming online (e.g. an A1
  reconnecting) triggered an MQTT activity burst that let idle H2Ds accumulate
  ≥3 heartbeat home_flag pushes before the next full push_status, flipping every
  H2D badge to red at once.

  Reworked the derivation:
    - the top-level `sdcard` field is authoritative when present (truthy check
      handles bool / int / "HAS_SDCARD_NORMAL" string variants)
    - home_flag bits 8-9 are only consulted on full push_status payloads
      (detected via ≥2 of gcode_state, mc_percent, nozzle_temper, print_type,
      stg_cur, ams)
    - bare heartbeat pushes carrying home_flag alone no longer affect SD state

  Removed the now-dead `_home_flag_seen` latch and 3-strike counter. Tests in
  TestSdCardParsing rewritten to cover the new semantics.
2026-04-14 18:25:36 +02:00
maziggy dd349954a6 fix(printers): stop H2D SD-card badge flipping red on heartbeat pushes
H2D sends heartbeat-style home_flag pushes where bits 8-9 are clear
  even when a card is inserted, so a single heartbeat flipped the badge
  to red until the next full push. Downgrades true->false now require
  three consecutive clear reads; upgrades apply immediately.
2026-04-14 12:32:37 +02:00
maziggy 9cc7efdf5f fix(printers): stop SD card badge flapping on H2D
Parse `sdcard` from `home_flag` bits 8-9 (HAS_SDCARD_NORMAL /
  HAS_SDCARD_ABNORMAL) when available and fall back to a type-tolerant
  truthy check on the top-level `sdcard` field. Firmware ships that
  field inconsistently (bool, int `1`, or string `"HAS_SDCARD_NORMAL"`),
  so the previous `is True` identity check flipped the badge to red on
  every report that carried a non-bool value.
2026-04-14 08:17:34 +02:00
maziggy 8af0966e68 feat(printers): airduct mode + status badges + force refresh on printer card
Surface four Home Assistant-style controls on the Printers page card:

  - SD Card badge in the top status row (green / red, icon-only).
  - Enclosure Door badge in the top status row (green / yellow, icon-only).
    Detection per printer family — X1/X1C/X1E read home_flag bit 23, all
    others read top-level `stat` (hex string) bit 23 — so X1 firmware that
    does not flip stat bit 23 stops false-triggering "open". WebSocket
    status-change dedup key now includes door_open so toggling the door
    alone publishes a push, no 30s REST-poll wait.
  - Airduct Mode badge beside the speed control (cooling / heating)
    for P2S/H2D/H2C/H2S; one-click dropdown calls the existing
    set_airduct MQTT command via a new POST /printers/{id}/airduct-mode
    route.
  - Force Refresh entry in the kebab menu — calls the existing
    /printers/{id}/refresh-status endpoint to request a pushall snapshot
    without forcing a reconnect.

  Tests: door-open parsing (X1 home_flag, non-X1 stat, ignore mismatched
  source, invalid hex) and airduct route (validation, not-connected,
  success, failure).
2026-04-13 12:50:17 +02:00
maziggy 99c193b535 refactor(colors): color_catalog is the single source of truth (#857)
The Printer tab AMS popup and spool auto-provisioner resolved color
  names from hardcoded tray_id_name tables with a suffix-code fallback —
  and suffix codes like "R1" are not globally unique across material
  families. A17-R1 (PLA Translucent Cherry Pink) fell through the
  fallback and resolved to "Scarlet Red" (A01-R1, PLA Matte), baking
  the wrong name into auto-created inventory spools.

  The fix removes the hardcoded tables entirely. Backend resolves color
  names via the existing color_catalog table by hex; frontend fetches a
  compact {hex: name} map once per session via a new
  GET /inventory/colors/map endpoint (auth-gated but not on
  inventory:read — read-only views need it too) and stores it in a
  ColorCatalogProvider context. A useSyncExternalStore hook cascades a
  re-render into pages mounted before the fetch completes so they
  refresh from HSL-fallback names once the catalog loads.

  Existing auto-provisioned spools keep their stored names; only new
  provisioning and live display benefit. Co-Authored-By is intentionally
  omitted here per project convention — set it via git config if needed.
2026-04-11 12:10:45 +02:00
maziggy 8266d225d2 fix(energy): date-range energy in total mode + restart-resilient per-print tracking (#941)
The Statistics page reported "Gesamt" (All Time) kWh correctly but showed
  zero for Today/Week/Month in total-consumption mode. Two bugs drove it:

  1. The starting plug counter was kept in an in-memory dict
     `_print_energy_start` that was lost on any backend restart mid-print, so
     the per-print `energy_kwh` delta silently never got computed. The stats
     endpoint's fallback path `SUM(PrintArchive.energy_kwh)` therefore summed
     to zero for users running in total mode.
  2. Total-consumption mode has no per-print delta by design — it includes
     idle/preheat/standby — so the fallback to archive rows was the wrong
     strategy even when the data existed.

  Fix, in two parts:

  - Persist `energy_start_kwh` on the archive row and read it back from a
    fresh session at print end. Deletes `_print_energy_start` and its 5
    call sites, replacing them with a single `_record_energy_start()` helper.
    Per-print tracking is now restart-resilient regardless of tracking mode.
  - Add hourly `smart_plug_energy_snapshots` table + `_snapshot_loop()` in
    SmartPlugManager. Rewrote the `/archives/stats` energy branch as
    `_sum_snapshot_deltas()` which computes per-plug
    `max(0, last-in-range - baseline)` where baseline is the latest snapshot
    at or before the range start, falling back to the earliest-ever snapshot
    and signalling `energy_data_warming_up` when no pre-range baseline
    exists (fresh upgrade). MQTT plugs are skipped from snapshots since they
    only report "today" and have no lifetime counter.

  Frontend: QuickStatsWidget renders an AlertTriangle next to Energy Used /
  Energy Cost with a tooltip when `energy_data_warming_up` is true, so the
  "low values right after upgrading" situation is explained in-product.
  Fully localised across 7 UI languages.

  Tests: new backend unit tests cover the snapshot delta arithmetic
  (baseline/endpoint, counter reset clamp, multi-plug, warming-up fallback,
  endpoint windowing), per-print restart resilience via expunge_all, and the
  snapshot task lifecycle (start idempotent, stop cancels). Frontend tests
  assert the warning icon appears only when the flag is set and only on the
  energy tiles.

  Docs: updated `CHANGELOG.md`, `README.md`, wiki `features/energy.md`,
  wiki `features/statistics.md`, and website `features.html` with the new
  behaviour and warming-up explanation.
2026-04-11 11:14:54 +02:00
maziggy 7e7372c469 Fix SpoolBuddy update always pulling main instead of current branch
detect_current_branch() was reading .git/HEAD from settings.base_dir,
  which points at the data volume (DATA_DIR=/app/data in Docker) and
  never contains .git. The repo is at /app, so the lookup always failed
  and the code fell through to the GIT_BRANCH env-var → "main" fallback.
  The SpoolBuddy device was therefore checking out `main` regardless of
  which branch Bambuddy itself was running.

  The old subprocess-based implementation had the same bug but it was
  masked: the stock Docker image has no `git` binary, so `git rev-parse`
  raised FileNotFoundError, the except clause swallowed it, and the
  fallback kicked in. Swapping to filesystem reads exposed the wrong
  lookup path.

  Add a module-level _APP_DIR constant (parents[3] of the module file,
  same depth as config.py uses for its own _app_dir) and read `.git/HEAD`
  from there. A regression test plants a decoy .git in the data dir and
  asserts we still pick up the real one from the app root.

  Per your NO GIT WRITES rule, nothing is staged or committed.
2026-04-10 10:56:48 +02:00
maziggy 44cb26c7c3 Fix SpoolBuddy update Docker failure — set LOGNAME/USER/HOME in image
Follow-up to the asyncssh migration. asyncssh.connect() internally
  calls getpass.getuser() for ~/.ssh/config host matching, regardless
  of the explicit `username=` passed for the remote login. Under an
  arbitrary Docker PUID with no /etc/passwd entry, getpass.getuser()
  raises "No username set in the environment" (OSError in Python 3.13+,
  previously a bare KeyError).

  Fix: set LOGNAME=bambuddy, USER=bambuddy, HOME=/app in the Dockerfile.
  getpass.getuser() tries env vars before pwd.getpwuid(), so the lookup
  never touches the passwd database and works for any PUID the operator
  picks — no helper code, no image rebuild for different UIDs.

  Also pass config=[] to asyncssh.connect() so it does not try to load
  ~/.ssh/config (whose default path needs a resolvable home directory).

  An earlier draft of this fix added a Python helper that caught the
  KeyError and injected LOGNAME at module import. That was both more
  code than needed and broken on Python 3.13, which wraps the KeyError
  in an OSError the helper didn't catch — so the module import itself
  crashed, producing a 500 on /spoolbuddy/devices/{id}/update. Reverted
  in favour of the one-line ENV fix.
2026-04-10 10:46:06 +02:00
maziggy 60d034d40c Fix SpoolBuddy update Docker failure — asyncssh local-username lookup
Follow-up to the previous commit that swapped the `ssh`/`ssh-keygen`
  subprocesses for asyncssh. asyncssh.connect() internally calls
  getpass.getuser() to resolve the *local* username for ~/.ssh/config
  host matching, regardless of the explicit `username=` we pass for the
  remote login. Under an arbitrary Docker PUID with no /etc/passwd
  entry, getpass.getuser() tries LOGNAME/USER/LNAME/USERNAME (all unset
  in python:3.13-slim) and falls back to pwd.getpwuid(), which raises
  KeyError. asyncssh rewraps that as "Unknown local username: set one
  of LOGNAME, USER, LNAME, or USERNAME in the environment" — which
  surfaced in the UI as "ssh connection failed: no username set in the
  environment".

  Fix is two-part:

  - _ensure_local_username_env() runs at module import. If getpass
    .getuser() already works, or any of LOGNAME/USER/LNAME/USERNAME is
    set, it is a no-op. Otherwise it sets LOGNAME=bambuddy so asyncssh
    can proceed. Native installs are untouched.

  - asyncssh.connect() is now called with config=[] to skip the
    default ~/.ssh/config load, which relies on a resolvable home
    directory that may not exist under arbitrary Docker PUIDs.

  Three new unit tests cover the env-var fallback, including the case
  where the operator has set USER but the passwd lookup still fails.
2026-04-10 10:35:57 +02:00
maziggy a78a4bff2e Fix SpoolBuddy update still failing in Docker after keypair fix
Commit 67749565 eliminated ssh-keygen from the SpoolBuddy remote-update
  flow, but the update path still shelled out to the OpenSSH `ssh` client
  for every command. Like ssh-keygen, the `ssh` binary calls
  getpwuid(getuid()) during startup and aborts with "No user exists for
  uid <N>" when the container runs under an arbitrary PUID that isn't in
  /etc/passwd (python:3.13-slim only ships a root entry, so any
  `user: "1000:1000"` compose setup trips the same error).

  detect_current_branch() had a related problem: when the git repo is
  bind-mounted into the container, .git exists inside Docker, so the code
  tried to run `git rev-parse`. Git isn't in the image, so the subprocess
  silently fell back to the GIT_BRANCH env var — and if git ever were
  added, it could hit the same getpwuid trap.

  The entire update path is now subprocess-free:

  - _run_ssh_command uses asyncssh (pure-Python, built on the already
    installed cryptography library). Connection errors map to rc=255 to
    match `ssh`'s convention; asyncio.timeout handles the timeout path.
  - detect_current_branch reads .git/HEAD directly (handling git-worktree
    `gitdir:` pointer files too), keeping the same GIT_BRANCH → "main"
    fallback chain.
  - shutil and the inline `import subprocess` are gone from the module.

  Regression tests assert that neither keypair creation, branch
  detection, nor command execution spawns any subprocess. Native installs
  are unaffected.
2026-04-10 10:25:08 +02:00
maziggy 648f7d6ba8 P2S - enable AMS drying for firmware 01.02.00.00 and later 2026-04-09 15:42:34 +02:00
maziggy 6774956565 Fix SpoolBuddy update failing in Docker with "no user exists for uid"
The SpoolBuddy remote-update flow shelled out to `ssh-keygen` to create
  its update keypair on first use. Inside the Docker container the process
  runs under an arbitrary PUID that is not listed in /etc/passwd, so
  ssh-keygen aborted at the getpwuid() home-directory lookup with
  "no user exists for uid 1001" and the update button failed.

  Generate the ed25519 keypair in-process via the `cryptography` library
  (already a dependency) and serialize it in OpenSSH format. No subprocess,
  no /etc/passwd lookup. Native installs are unaffected.

  Added a regression test that asserts no subprocess is spawned during
  keypair creation so this can't come back.
2026-04-09 12:53:20 +02:00
maziggy 848f558105 LDAP: POSIX primary group support and default fallback group
Two related LDAP authentication changes.

  Fix: POSIX primary group membership was ignored. authenticate_ldap_user
  only searched for posixGroup entries via memberUid (supplementary
  groups). A user's primary group — referenced by the gidNumber attribute
  on the user object matching gidNumber on a posixGroup — was never
  resolved, so users whose role came from their primary group landed
  without the expected permissions. The authenticator now runs a second
  search for posixGroup entries whose gidNumber matches the user's
  primary gidNumber, then dedupes DNs case-insensitively before passing
  the list to resolve_group_mapping (LDAP DNs are case-insensitive by
  spec).

  New feature: ldap_default_group setting. Settings → Authentication →
  LDAP → Advanced has a new "Default group" selector. When an LDAP user
  authenticates but is not listed in any mapped LDAP group, they are
  assigned to this fallback group instead of being left with no groups
  (and therefore no permissions). A warning is logged each time the
  fallback is applied so admins can spot missing group assignments.
  Empty setting preserves the old behavior.

  Tests: added 4 mocked authenticate_ldap_user tests covering primary
  gidNumber lookup, dedupe of overlapping memberUid+primary gid matches,
  case-insensitive DN dedupe, and the guard when a user entry has no
  gidNumber attribute. Also extended the existing parse_ldap_config tests
  to cover the new default_group field.

  Backend: ldap_service.py (primary group + dedupe + default_group
  field), schemas/settings.py (schema field), api/routes/auth.py
  (fallback wiring in _provision_ldap_user / _sync_ldap_user).

  Frontend: LDAPSettings.tsx default-group dropdown in the Advanced
  collapsible, api/client.ts type field, new i18n keys in all 7 locales
  (defaultGroup, defaultGroupNone, defaultGroupHint).
2026-04-09 10:48:42 +02:00
maziggy b6599dd419 Add LDAP/Active Directory authentication (#794)
Users can authenticate against an LDAP/AD server with configurable
  server URL, bind DN, search base, and user filter. Supports StartTLS
  and LDAPS — plaintext is not allowed. Both Active Directory (memberOf)
  and POSIX groups (memberUid) are mapped to BamBuddy groups on each
  login. Auto-provisioning creates local accounts on first LDAP login.
  Local admin accounts remain as fallback when LDAP is unreachable.
  Password management is disabled for LDAP users.
2026-04-08 10:41:27 +02:00
maziggy 8c00b1b75f Fix multi-plug automation only working for first plug (#903)
When multiple smart plugs were assigned to the same printer, only the
  first plug's automation triggered. All automation paths (print start
  auto-on, print complete auto-off, queue auto-off, scheduler power-on)
  now iterate every plug linked to the printer. Also fix queue auto-off
  hardcoded to Tasmota instead of using the correct service for the plug
  type.
2026-04-07 09:44:48 +02:00
maziggy 113bae4c63 Fix developer mode probe destabilizing MQTT on auto-reconnect (#887)
The dev mode probe (ams_filament_setting to ext slot) fired on every
  auto-reconnect, which destabilized some firmware MQTT brokers (A1/P1)
  causing a reconnect-probe-disconnect feedback loop. Now caches the
  probe result across reconnects and only probes once on first connect,
  with a 5s delay to let the session stabilize.
2026-04-07 09:00:37 +02:00
maziggy 4df0349310 Fix spool weight tracking when auto-archive is disabled (#839)
When auto-archive was off, archive_id was None at print completion so
  the entire 3MF tracking path was skipped. AMS remain% fallback also
  failed on printers reporting remain=-1. Now searches library files and
  previous archives by filename to locate the 3MF without an archive,
  and captures the AMS slot-to-tray mapping at print start so it's
  available at completion regardless of archive state.
2026-04-07 08:31:14 +02:00
maziggy 2d5dd1b31f Fix AMS slot changes failing until manual reconnect (#887)
Add timeout and retry to the developer mode probe. After a keep-alive
  timeout, paho auto-reconnects but the session can be half-broken: the
  printer sends status but ignores commands. The probe had no recovery —
  one unanswered probe permanently blocked retries. Now times out after
  10s with one retry; two consecutive failures force-close the socket for
  a clean reconnect.
2026-04-04 14:08:29 +02:00
maziggy 3381f73e0f Enable AMS drying support for H2S with firmware 01.02.00.00+ (#886)
Move H2S from the drying-unsupported blocklist to the firmware-gated
  list, requiring minimum firmware 01.02.00.00 (released end of March
  2026). Both remote AMS drying and queue auto-drying now work on H2S.
2026-04-04 09:26:00 +02:00
maziggy 76adf70fd4 Add separate power/energy URLs and multipliers for REST smart plugs (#472)
REST/Webhook smart plugs can now fetch power and energy data from
  individual URLs instead of requiring all values in a single status
  response. Each value falls back to the shared Status URL when no
  separate URL is set, preserving backward compatibility. Added power
  and energy multipliers for unit conversion (e.g. 0.001 for Wh→kWh).
2026-04-03 08:31:08 +02:00
maziggy d327c70006 Fix WebSocket crash on printers without fun field (#873)
Race condition in _update_state: dev mode probe released GIL via MQTT
  publish between raw_data overwrite and vt_tray list restoration, letting
  the event loop iterate over raw dict keys (strings) instead of spool
  dicts. Affects A1, P1, and X1Plus firmware that don't send the fun field.

  Fix: normalize vt_tray dict→list before raw_data assignment, restore
  preserved fields before any GIL-releasing work, add defensive guard in
  printer_state_to_dict.
2026-04-02 13:58:42 +02:00
maziggy ad96337587 Fix external spool ams_mapping2 using wrong ams_id on single-nozzle printers (#859)
Single-nozzle printers (X1C, P1S, A1) report tray_now=254 for external
  spool, but BambuStudio sends ams_id=255 (VIRTUAL_TRAY_MAIN_ID) in the
  print command's ams_mapping2 field. Bambuddy was passing 254 as-is,
  causing firmware to target AMS tray 0 instead of external spool —
  resulting in 07FF_8012 "Failed to get AMS mapping table" or prints stuck
  at heatbed heating when an AMS is connected but empty.

  Map external spool to ams_id=255 for all non-H2D printers. H2D
  dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.
2026-04-02 09:27:35 +02:00
maziggy 75fa935851 Fix filament color name and subtype inconsistencies (#857) 2026-04-01 12:20:12 +02:00
maziggy 914adde5aa Add REST/Webhook smart plug type (#472) 2026-04-01 10:06:01 +02:00
maziggy 8b332f638f Fix external spool print failing on printers without AMS (#854)
Printers with no AMS hardware (P1S/P1P with only external spool)
  rejected print commands with "Failed to get AMS mapping table"
  because use_ams was always sent as true. Now auto-sets use_ams=false
  when all filament slots map to external spools or are unmapped.
  H2D-series excluded since they use use_ams for nozzle routing.
2026-03-31 10:02:25 +02:00
maziggy c6f62f9cd9 Add persistent auto-off option for smart plugs (#826)
Auto-off now has a "Keep Enabled" toggle that keeps it active between
  prints instead of disabling after each use (one-shot). Useful for HA
  accessories like BentoBox filters that should always power off after
  prints. Default behavior (one-shot) is unchanged.
2026-03-27 07:50:47 +01:00
maziggy 6c2df27860 Fix MQTT connected/disconnected UI bouncing caused by stale reconnect
The stale connection fix (6ac375a) force-closed the socket to trigger
  paho reconnect, but paho's disconnect callback redundantly broadcast
  state changes, causing rapid connected/disconnected cycling in the UI.
  Now sets a flag before the force-close so _on_disconnect skips the
  redundant broadcast. Also relaxed keepalive from 15s to 30s — the
  aggressive interval caused spurious disconnects on transient hiccups.

  Moved _disconnection_event.set() to the top of _on_disconnect so no
  early-return filter can prevent it from unblocking disconnect() callers.

  Removed leftover debug payload dump.
2026-03-26 13:32:38 +01:00
maziggy 1df5130df2 Broadcast spool assignment changes via WebSocket for cross-tab updates
Assigning or unassigning a spool now broadcasts a spool_assignment_changed
  event to all connected WebSocket clients. The frontend handles this event
  by invalidating the spool-assignments and slotPresets caches, so other
  open tabs update automatically without a page reload.
2026-03-26 09:34:46 +01:00
maziggy 5926e722f8 Fix H2D external spool print failing with 0700_8012 (#797)
BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
  ams_mapping and relies on ams_mapping2 for external spool routing.
  Bambuddy was passing raw 254/255 in the flat array, which H2D firmware
  rejects with "Failed to get AMS mapping table".

  - Convert external tray IDs to -1 in flat ams_mapping (match BambuStudio)
  - Fix ams_mapping2 for external trays: each virtual tray is its own AMS
    unit with slot_id 0, not a shared unit differentiated by slot
  - Fix main/deputy nozzle ID comment (255=main, 254=deputy per BambuStudio)
  - Add 8 unit tests for start_print() mapping construction
2026-03-25 11:38:27 +01:00
maziggy 95c703282d Fix missing image thumbnails in external folder scan (#124)
External folder scan generated thumbnails for 3mf/stl/gcode files but
  skipped image files. Added IMAGE_EXTENSIONS check with
  create_image_thumbnail() to the scan loop.
2026-03-24 16:32:45 +01:00
maziggy c9efa4b8bb Fix stale AMS slot data and empty slot button visibility (#784)
Some printers (e.g. H2D) only send {id, state} in incremental MQTT
  tray updates — no tray_type, tray_color, or other fields. When
  filament is unloaded (state changes from 11 to 10), the old tray
  data persisted indefinitely because the merge logic only updates
  fields present in the incoming message.

  Backend:
  - Detect tray state != 11 without tray_type in incremental updates
    and clear stale tray data (bambu_mqtt.py)
  - Expose tray `state` field via REST API and WebSocket broadcasts
    (printer.py schema, printers.py route, printer_manager.py)
  - Include AMS tray content in WebSocket dedup key so load/unload
    transitions trigger broadcasts (main.py)

  Frontend:
  - Add `state` to AMSTray interface (client.ts)
  - Show configure/assign buttons for state=10 (spool present, not
    loaded) but hide for state=9 (truly empty) on AMS/HT slots
  - Hide fill level bar on empty slots

  Tests:
  - 5 new tests covering state-based clearing, preservation, reload,
    and idempotency
2026-03-24 16:03:34 +01:00
Keybored 6e648804fc [Feature] Spoolbuddy Fixes and Improvements (#787)
[Feature] Spoolbuddy Fixes and Improvements (#787)
2026-03-24 11:56:33 +01:00
Keybored 3f7d0e2d55 Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
2026-03-24 11:31:32 +01:00
maziggy dd75c8f0c9 feat: auto-link untagged inventory spools on AMS insert (#538)
When a Bambu Lab spool is detected in the AMS but no tag match exists,
  check for an untagged inventory spool with matching material/subtype/color
  before creating a new entry. Links the RFID tag to the existing spool
  (data_origin="rfid_linked") to prevent duplicate inventory entries.
2026-03-24 09:23:25 +01:00
maziggy b8a3aa842b Add tests for SpoolBuddy SSH update service and settings UI
15 backend unit tests covering keypair management, branch detection,
  SSH command execution (success/failure/timeout), and full update flow
  with error handling. 4 new frontend tests for the Updates tab (version
  display, buttons, SSH setup, update available state).
2026-03-23 15:55:44 +01:00
maziggy 8e5625d1d9 Fix A1 virtual printer proxy by adding ports 2024-2026 (#757)
BambuStudio connects to undocumented proprietary ports 2024-2026
  on A1/P1S models during the print flow. The proxy wasn't forwarding
  these ports, causing connection refused (RST) and triggering the
  access code dialog instead of printing. MQTT and FTP worked fine —
  port 2024 was the sole blocker.

  Added transparent TCP pass-through proxies for ports 2024-2026,
  following the same pattern as the existing FileTransfer (6000) and
  RTSP (322) proxies. Silently ignored on models that don't use them.
2026-03-23 10:47:41 +01:00
maziggy 750accb17d Fix queue print command not reaching printer on reconnect (#778)
When targeting a specific printer, the scheduler's power-on-wait loop
  created new MQTT clients on each attempt. Each new client re-tried the
  request topic subscription, which some brokers (e.g. A1) reject by
  disconnecting. This caused a 170s thrash loop leaving the connection
  fragile, so the eventual print command silently failed to reach the
  printer.

  Cache request topic support per serial number at the class level so
  new client instances inherit the knowledge and skip the subscription.
2026-03-22 08:17:53 +01:00
maziggy 488f66310f [Fix] Preserve AMS spool assignments across printer restart (#765)
When a printer shuts down it sends a final MQTT message with
  tray_exist_bits=0 and power_on_flag=false. The tray_exist_bits
  clearing code processed this all-zero value, wiping every AMS
  slot's filament data. On reconnect, the auto-unlink check saw
  empty tray data (no color, no type) and deleted all spool
  assignments as "fingerprint mismatch".

  Fix: skip tray_exist_bits slot clearing when power_on_flag is
  false. Defaults to true when absent for backwards compatibility.

  Adds 3 regression tests covering shutdown preservation, genuine
  removal still working, and missing power_on_flag fallback.
2026-03-20 16:28:45 +01:00
maziggy e82362ac65 [Feature] Add spool rotation option for AMS drying
Add a "Rotate spool during drying" checkbox to the manual drying popover
  for AMS 2 Pro and AMS-HT units. The firmware-level rotate_tray field was
  already sent (hardcoded to false) — this makes it user-configurable.
  The checkbox defaults to unchecked and resets each time the popover opens.
  Firmware silently disables rotation if filament is currently loaded.
2026-03-20 12:14:44 +01:00
maziggy 332a7c6ac8 [Fix] Virtual Printer proxy: transparent TCP for X1C/X1 compatibility (#757)
The closed-source bambu_networking DLL validates TLS connection parameters
  and rejects connections where the certificate doesn't match the printer's
  real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's
  own certificate, causing X1C/X1 prints to silently fail after verify_job.

  Switch to transparent TCP proxying for FTP, FileTransfer, Camera, and FTP
  data — only MQTT remains TLS-terminated (required for IP rewriting). The
  slicer now gets end-to-end TLS directly with the printer's real certificate.

  Changes:
  - SlicerProxyManager uses TCPProxy for FTP (990), FileTransfer (6000),
    Camera (322), and pre-listens on FTP data ports (50000-50100)
  - Only MQTT (8883) uses TLSProxy for IP rewriting
  - Remove debug logging from MQTT and FTP proxy code
  - Fix install.sh missing AmbientCapabilities=CAP_NET_BIND_SERVICE
  - Update module docstring, migration docs, README proxy description
  - Add tests verifying transparent proxy architecture
2026-03-19 15:43:11 +01:00
maziggy 1b1f58fc88 [Fix] Virtual Printer proxy mode fails on isolated networks (#757)
When the slicer and printer are on different VLANs, Bambu Studio could
  not send prints through the proxy because the printer's real IP leaked
  through MQTT payloads, the bind protocol forwarded the real printer's
  identity, file transfer and camera ports were not proxied, and FTP
  data connections raced the TLS handshake on zero-byte uploads.

  - Rewrite IP addresses in MQTT PUBLISH payloads (string + integer)
    with proper packet framing and cross-chunk buffering
  - Respond to bind/detect with VP identity via BindServer
  - Add TLS proxies for port 6000 (file transfer) and 322 (RTSP camera)
  - Buffer slicer FTP data during printer connection setup
  - Advertise configured VP name in SSDP proxy
  - Add cross-subnet SSDP wildcard listener for VPN setups
  - Register UserEmailPreference model in models/__init__.py
  - Add 11 unit tests for MQTT rewrite, IP conversion, SSDP name
2026-03-19 11:10:22 +01:00
maziggy 0b2b81a34d [Fix] Virtual Printer proxy mode fails on isolated networks (#757)
When the slicer and printer are on different VLANs, Bambu Studio could
  not send prints through the proxy because the printer's real IP leaked
  through MQTT payloads, the bind protocol forwarded the real printer's
  identity, the port 6000 file transfer tunnel was not proxied, and FTP
  data connections raced the TLS handshake on zero-byte uploads.

  - Rewrite IP addresses in MQTT PUBLISH payloads (string + integer)
    with proper packet framing and cross-chunk buffering
  - Respond to bind/detect with VP identity via BindServer
  - Add TLS proxy for port 6000 (file transfer tunnel)
  - Buffer slicer FTP data during printer connection setup
  - Advertise configured VP name in SSDP proxy
  - Add cross-subnet SSDP wildcard listener for VPN setups
  - Register UserEmailPreference model in models/__init__.py
  - Add 11 unit tests for MQTT rewrite, IP conversion, SSDP name
2026-03-19 11:03:10 +01:00
maziggy 4f617c21e1 [Fix] X1C Virtual Printer not accepting sends (#735)
X1C and X1 virtual printers used legacy SSDP model codes
  (3DPrinter-X1-Carbon, 3DPrinter-X1) that BambuStudio doesn't
  recognize, causing "incompatible printer preset" errors when
  sending prints. Changed to the correct codes (BL-P001, BL-P002)
  that real printers report via SSDP.

  Also fixed proxy mode auto-inherit storing printer display names
  (e.g. "X1C") instead of SSDP codes, by adding a resolution layer
  that maps display names to model codes.

  DB migration auto-converts existing VPs on startup.
2026-03-17 16:25:31 +01:00
Dakota G ac7cfe2930 [Fix]: Send Bambu RFID Tags to Spoolman, Allow Unlink in Manual Mode, Stop Location Clearing for Generic Spools (#719)
[Fix]: Send Bambu RFID Tags to Spoolman, Allow Unlink in Manual Mode, Stop Location Clearing for Generic Spools (#719)
2026-03-16 15:45:06 +01:00
maziggy 446da087a0 Fix webhook notifications missing camera snapshot (#679)
Webhook providers did not include image data (e.g. camera snapshots
  from first layer complete notifications) even though other providers
  like Telegram, Pushover, and Discord already attached them. The webhook
  payload now includes a base64-encoded "image" field when a snapshot is
  available (generic format only, excluded from Slack format).
2026-03-16 15:10:39 +01:00
maziggy 0feed83ce4 Fix P2S camera TLS compatibility via OpenSSL proxy (#661)
The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
  TLS renegotiation and legacy ciphers that some Bambu printer firmwares
  (notably P2S) rely on — causing RTSP sessions to drop after a few
  seconds.

  Add a local TLS termination proxy (Python ssl/OpenSSL) that handles
  the TLS connection to the printer and exposes a plain RTSP port to
  ffmpeg. The proxy rewrites RTSP request-line URLs (rtsp://proxy →
  rtsps://printer) while preserving Authorization headers so Digest
  auth hashes remain valid.

  Also:
  - Reduce RTSP reconnect delay from 1.0s to 0.2s
  - Add ffmpeg fast-start flags (-probesize 32, -analyzeduration 0,
    -fflags nobuffer, -flags low_delay)
  - Fix external camera double rate-limiting causing choppy streams
  - Apply TLS proxy to external camera rtsps:// URLs and snapshot capture
  - Update orphan ffmpeg cleanup to match rtsp:// (proxied) URLs
  - Add unit tests for RTSP URL rewriting and proxy lifecycle
2026-03-15 11:52:32 +01:00
maziggy ecc9ec11f1 Fix HA switch badge toggle and multi-plug crash
Non-script HA entities (switch/light/input_boolean) on the printer card
  always sent turn_on when clicked, which is a no-op when the switch is
  already on. Now sends toggle for non-script entities so the badge click
  actually changes the switch state. Script entities still use turn_on.

  Also fix SmartPlugManager._get_plug_for_printer() using
  scalar_one_or_none() which crashes with MultipleResultsFound when
  multiple plugs are assigned to the same printer (e.g., Tasmota + HA
  switch). Now fetches all plugs and returns the main non-script plug.

  Add tests for both fixes: _get_plug_for_printer multi-plug handling
  (5 tests) and canonicalFilamentType/filamentTypesCompatible/autoMatch
  PA-CF equivalence (8 tests).
2026-03-13 13:11:12 +01:00
Dakota G fb29eb4cd7 [Fix]: Properly track filament usage data in Spoolman (#686)
Properly track filament usage data in Spoolman#686
2026-03-13 11:59:44 +01:00