Commit Graph
934 Commits
Author SHA1 Message Date
maziggy f03d0c4cf7 fix: attribute direct library prints to the authenticated user
The library POST /files/{file_id}/print endpoint discarded the
  authenticated user and passed requested_by_user_id=None to the
  dispatcher, so archives created from direct prints had no
  created_by_id and didn't show up in per-user statistics. Queue
  and reprint paths already forwarded the user correctly.

  Bind the auth dependency to current_user and pass its id/username
  through to dispatch_print_library_file, matching the reprint
  endpoint. The dispatcher already propagates this to
  printer_manager.set_current_print_user, which the archive
  creation reads.
2026-04-14 11:16:26 +02:00
maziggy d74ab06072 feat(firmware): list all announced versions with usable/unavailable status, support rollback
Firmware update modal now shows every version from Bambu's wiki release
  history, each badged Usable/Unavailable/Installed. Selecting a usable row
  — newer or older than current — swaps the release notes and enables
  install for that version, so rollback no longer requires hand-flashing.

  Wiki scraper tightened to only read heading-anchor ids (h-XXXXXXXX-YYYYMMDD)
  instead of any XX.XX.XX.XX substring, eliminating false positives like an
  AMS firmware version mentioned in an H2D changelog being listed as H2D
  firmware.

  Refs #568
2026-04-14 11:10:24 +02:00
maziggy 4a5b608a81 fix: surface AMS dry_sf_reason and backfill filament (#971)
Start Drying was publishing a valid MQTT command that the firmware
  silently refused. The per-AMS dry_sf_reason array was parsed but never
  consulted before publish, so users with the AMS 2 Pro external PSU
  unplugged (code 8) saw nothing happen. The empty filament field in our
  payload was also a refusal trigger on some firmwares.

  The /drying/start route now inspects dry_sf_reason and returns a 409
  with a specific message, and backfills filament from the first loaded
  tray (default PLA) so the printer can't reject the command for a
  missing field.
2026-04-14 09:27:00 +02:00
maziggy b71b721658 fix(security): stop leaking webhook tokens via httpx debug logging
Settings → Support → Debug Logging elevated httpx/httpcore to DEBUG,
  which makes httpx log every outbound request URL. For Discord and
  generic webhook notifications the bearer token is embedded in the URL
  path, so users who turned on debug logging to capture a support bundle
  were writing their webhook tokens straight into bambuddy.log.

  Pin httpx/httpcore to WARNING regardless of the debug toggle. paho.mqtt
  still honours debug. Users who enabled debug logging while notifications
  were sending must rotate any exposed Discord/webhook URLs — the token
  is the path, so the whole URL has to be regenerated in the provider UI.
2026-04-14 09:13:55 +02:00
maziggy 3762022444 fix(scheduler): revert stuck queue item when printer ignores start command
If the printer drops or ignores the MQTT project_file command (same
  half-broken-session shape as #887/#936), the queue item was permanently
  stuck in "printing" at 100% because the scheduler optimistically flipped
  the DB row right after the publish succeeded locally. A new watchdog
  polls the printer state for up to 45s after dispatch; if there's no
  transition, it reverts the item to "pending" and force-reconnects the
  MQTT session so the scheduler can retry.
2026-04-14 09:08:15 +02:00
maziggy 55cd050635 ● fix(mqtt): stop SD card badge flap from partial pushes
Follow-up to the earlier H2D SD card badge fix. The badge was still
  flapping because Bambu firmwares send partial MQTT pushes carrying only
  the legacy `sdcard` field (without home_flag), and the fallback path
  re-engaged on every such push. Latch home_flag as the canonical source
  once seen; reset the latch on reconnect so a firmware change still
  re-learns.
2026-04-14 08:45:28 +02:00
maziggy 4a2b9bc5d4 fix(mqtt): self-heal half-broken session on dispatch timeout (#936)
When a project_file command was unacknowledged for 15s, Bambuddy
  previously logged "printer may need restart" and left the broken MQTT
  session in place — requiring the user to power-cycle the printer. The
  existing half-broken-session recovery only ran via the developer-mode
  probe path, which skips printers with a known developer_mode value.

  Extract the existing force-reconnect logic into a reusable helper and
  call it from _verify_print_response on dispatch timeout. The next
  dispatch attempt then lands on a fresh MQTT session without a reboot.
2026-04-14 08:42:20 +02:00
maziggy 574b39aee5 change(workflow): default Plate-Clear Confirmation to off on fresh installs
New users repeatedly reported queued prints "not starting" because the
  confirmation prompt was waiting on an ack they didn't know existed.
  Flip the default in the settings schema and in the frontend fallbacks
  so a missing/unset value reads as disabled. Existing installs keep
  their saved preference.
2026-04-14 08:27:00 +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 53a70e37d9 fix: unbreak CSP for sidebar iframes, service worker, and Google Fonts
The strict CSP added in 0.2.3b4 blocked three things at once:
  external sidebar-link iframes (no frame-src declared, so they fell
  back to default-src 'self'), the inline service-worker registration
  script in index.html, and the Google Fonts @import used for Inter.

  - Add `frame-src 'self' https:` so user-configured HTTPS iframe
    targets load; frame-ancestors 'none' still prevents Bambuddy
    itself from being framed cross-origin.
  - Move the inline SW-registration script into public/sw-register.js
    so `script-src 'self'` covers it without 'unsafe-inline' or
    per-build hashes.
  - Allow fonts.googleapis.com in style-src and fonts.gstatic.com in
    font-src so the Inter webfont loads.
2026-04-13 16:38:17 +02:00
maziggy 2f0cca186b Post work PR #933 2026-04-13 13:53:30 +02:00
Sn0rrii ba1c97c808 feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
2026-04-13 13:24:28 +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 eec7793955 feat(obico): AI print-failure detection via self-hosted Obico ML API (#172)
Adds a Failure Detection tab under Settings that wires Bambuddy to a
  self-hosted Obico ml_api container — no cloud, no account, no WebSocket.
  While a print is running, the detection service periodically hands the
  printer's camera snapshot URL to the ML API and smooths scores over
  time (30-frame warmup + EWM, alpha=2/13, short/long rolling means) so
  one noisy frame can't trigger an action. When the smoothed score
  crosses HIGH, the configured action fires exactly once per print:
  notify, pause, or pause-and-cut-power (via linked smart plugs).

  - Backend: new obico_detection + obico_smoothing + obico_actions
    services, /obico/status and /obico/test-connection routes
    (SETTINGS_READ / SETTINGS_UPDATE), six obico_* AppSettings fields
    with validators for sensitivity/action/enabled_printers.
  - Frontend: FailureDetectionSettings component (enable, ML URL + test,
    sensitivity, action, poll interval, per-printer monitor list, live
    status + detection history), new sidebar tab with service-active
    bullet, toast on save.
  - Tests: 17 detection unit tests + 15 smoothing unit tests + 4
    frontend component tests.
  - Docs: README bullet, CHANGELOG entry, wiki page under Analytics,
    website features.html entry.
2026-04-13 09:54:26 +02:00
maziggy f1483f525d fix: relax X-Frame-Options to SAMEORIGIN so same-origin iframes load
The security-headers middleware added in 0.2.3b4 set X-Frame-Options: DENY
  on every response, which blocked the Spoolman page iframe when Spoolman
  was served from the same host as Bambuddy via a reverse proxy. Relaxed
  to SAMEORIGIN — same-origin embedding works again, cross-origin
  clickjacking protection is preserved.
2026-04-13 09:18:35 +02:00
maziggy de7fff0be4 fix: persist plate-clear gate so Auto Off power cycles can't bypass the queue confirmation (#961)
With Auto Off enabled and another job queued, the smart plug cut power when a
  print finished and immediately re-powered the printer because the scheduler
  saw pending items. The printer booted fresh into IDLE and the next job
  auto-dispatched, bypassing the "Clear Plate & Start Next" confirmation.

  Root cause: the plate-clear gate lived only in PrinterManager._plate_cleared
  (in-memory set) and _is_printer_idle treated IDLE as unconditionally idle. On
  power cycle the in-memory flag was lost and the IDLE-on-boot state skipped
  the gate entirely.

  Fix:
  - Replace the in-memory flag with an awaiting_plate_clear column on the
    printers table, rehydrated into the PrinterManager at startup.
  - Set the flag in on_print_complete for completed/failed prints (not user
    cancellations); clear it on ack and on scheduler dispatch.
  - _is_printer_idle now short-circuits to not-idle whenever require_plate_clear
    is on and the flag is set, regardless of the currently reported state —
    so the gate holds through power cycles, Bambuddy restarts, and the printer
    booting back into IDLE.
  - /printers/{id}/clear-plate no longer requires the printer to report
    FINISH/FAILED; it accepts the ack whenever the flag is raised.
  - Frontend widgets (PrinterQueueWidget, Layout, BulkPrinterToolbar) gate on
    the flag rather than reported state.

  Tests: added regression tests for IDLE+awaiting=True (the #961 case) and
  full DB round-trip tests for the persistence layer.
2026-04-13 09:12:12 +02:00
Dakota G f84e5ba173 feat: Shows vendor name in Spoolman Link Modal (#958)
* Add filament_vendor field to UnlinkedSpool model and populate from API response
* Add filament_vendor field to UnlinkedSpool interface
* Enhance LinkSpoolModal to include filament_vendor in search and display
2026-04-13 08:26:46 +02:00
maziggy 18dceb3c32 . 2026-04-12 15:10:40 +02:00
maziggy b37d8d6a9d Bumped version 2026-04-12 14:57:15 +02:00
maziggy 774a639e9a . 2026-04-12 14:16:17 +02:00
maziggy c4ebe5a70c ● fix(spoolbuddy): actually power off HDMI on idle instead of CSS overlay (#937)
The SpoolBuddy kiosk's "screen blank timeout" setting only painted a
  black CSS overlay over the browser window — the HDMI panel's backlight
  stayed on indefinitely, wasting power and risking burn-in on
  OLED/LED panels.

  Move blanking down to the OS layer:

  - install.sh now installs swayidle + wlopm + jq and rewrites labwc's
    autostart to launch a new spoolbuddy-idle.sh watchdog instead of the
    old `wlr-randr --on` keep-alive loop.
  - The watchdog sources /opt/bambuddy/spoolbuddy/.env, derives device_id
    from the first non-loopback MAC (same algorithm as daemon/config.py),
    fetches the configured blank_timeout from the backend once on boot,
    and execs `swayidle -w timeout $T 'wlopm --off HDMI-A-1' resume
    'wlopm --on HDMI-A-1'`. Touch/keypress wakes via labwc's input event
    path. timeout=0 skips swayidle entirely so existing installs that
    never picked a timeout keep their current always-on behavior.
  - New GET /api/v1/spoolbuddy/devices/{id}/display endpoint returns the
    current brightness + blank_timeout. Gated on INVENTORY_UPDATE (same
    level the daemon heartbeat key already uses) so existing SpoolBuddy
    API keys work without extra permissions.
  - SpoolBuddyLayout drops blanked state, the blank timer, activity
    listeners, resetActivity, and the CSS overlay. Runtime updates to
    the timeout take effect on next kiosk/browser restart; default for
    newly-enabled blanking is 300 seconds.
2026-04-11 12:42:49 +02:00
maziggy 1516d58118 fix(energy): recursively strip tz from nested params for insertmanyvalues
The prior fix (9f643724) added a list branch to _strip_container but only
  walked one level deep. SQLAlchemy's insertmanyvalues feature can pass
  parameters as nested containers (e.g. a list of tuples, or a tuple inside
  a list) depending on the dialect path, so the inner tz-aware datetimes
  still reached asyncpg and the hourly snapshot loop kept failing with:

    asyncpg.DataError: invalid input for query argument $2: ...
    (can't subtract offset-naive and offset-aware datetimes)

  Replaced the two-helper design with a single recursive _strip() that
  walks dict/list/tuple at any depth. One top-level call now handles every
  parameter shape SQLAlchemy may use, regardless of executemany or the
  insertmanyvalues batching path.
2026-04-11 12:25:16 +02:00
maziggy 9f6437244d fix(energy): strip tz from list params so snapshot INSERTs work on Postgres
The hourly smart plug energy snapshot loop introduced with #941 crashed
  every cycle on PostgreSQL installs with:

    asyncpg.DataError: invalid input for query argument $2:
    datetime.datetime(..., tzinfo=datetime.timezone.utc)
    (can't subtract offset-naive and offset-aware datetimes)

  The engine has a `before_cursor_execute` hook that strips tzinfo from
  aware datetimes before they reach asyncpg (all schema datetime columns
  are TIMESTAMP WITHOUT TIME ZONE). The hook's `_strip_container` handled
  dict and tuple parameter containers but fell through `list` unchanged.

  When SQLAlchemy's insertmanyvalues feature batches two or more rows into
  a single INSERT ... SELECT FROM (VALUES ...) statement, it passes the
  positional params as a flat `list`, so the tz-aware datetimes survived
  the hook and reached asyncpg.

  Added a list branch that mirrors the tuple one. No schema change needed
  — the column stays naive UTC like the rest of the codebase. SQLite was
  never affected.
2026-04-11 12:19:27 +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 3d893b22f3 fix(auth): make password_hash nullable on upgraded SQLite installs (#794)
LDAP auto-provisioning hit a NOT NULL constraint error on upgraded SQLite
  installs because the existing migration only ran on PostgreSQL. The SQLite
  branch now patches sqlite_master via writable_schema and bumps schema_version
  so the change takes effect without a restart. Fresh installs were unaffected.
2026-04-11 11:33:49 +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 b5c8c2cdf5 Add SpoolBuddy device management settings tab
Previously, if a SpoolBuddy daemon crashed during registration it could
  end up registered twice. The kiosk UI silently used only the first
  device and there was no UI path to remove the orphan — administrators
  had to delete the row directly in the database.

  Adds a new Settings → SpoolBuddy tab that lists every registered device
  with live connection status, system details (firmware, IP, CPU temp,
  memory, disk, OS, daemon + system uptime), hardware health flags, and
  an Unregister action gated by a confirm modal. A yellow banner appears
  whenever more than one device is registered to flag likely crash-
  duplicates. Backend adds DELETE /spoolbuddy/devices/{device_id} gated
  by inventory:delete and broadcasts spoolbuddy_unregistered over WS so
  other tabs refresh immediately.

  The tab header shows a device-count pill and a green/gray status bullet
  reflecting whether at least one registered device is online. An online
  device that is accidentally unregistered re-registers itself on its
  next heartbeat. Localized in English, German, and Japanese. The kiosk
  layout still uses devices[0] — once the orphan is unregistered, the
  remaining device naturally becomes [0].
2026-04-11 10:22:59 +02:00
maziggy b01b5e05fd Add tests, docs, and changelog for #920 + #932
Audit PRs #920 (printers search/filter) and #932 (print from project
  view) for regressions, i18n coverage, test gaps, and docs.

  No regressions found: existing callers of the changed signatures
  (archive_print, getLibraryFiles, filteredPrinters chain) are unaffected;
  i18n is complete in all 7 locales for both features.

  Backend tests (4 new, all passing):
  - test_list_files_by_project_id — bulk JOIN returns files across all
    linked folders, excludes unlinked ones
  - test_list_files_folder_id_takes_precedence_over_project_id — guards
    the documented precedence folder_id > project_id > include_root
  - test_add_to_queue_with_project_id — project_id is persisted on the
    queue row for later archive linkage
  - test_add_to_queue_invalid_project_id_returns_404 — regression guard
    for the validation pre-check on the queue path (mirrors the one on
    the direct-print path in library.py)
2026-04-10 13:09:21 +02:00
lietschaend f95b2acd7d Feature: print files directly from project view (closes #930) (#932)
* feat: print files directly from project view (closes #930)

Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
2026-04-10 12:54:27 +02:00
maziggy b069b5217c y Fix virtual printer "Synchronizing device information" timeout in Orca (#927)
OrcaSlicer's "Send job" flow sat on "Synchronizing device information…"
  until it gave up, even though FTP upload worked when the user clicked
  "Send job anyway". The virtual printer's MQTT server gated all incoming
  command handling on `f"device/{self.serial}/request" in topic` — if the
  slicer's cached serial for the VP didn't exactly equal the VP's computed
  self.serial (model prefix + per-VP serial_suffix), every get_version,
  pushall, and project_file publish was silently dropped. Nothing was
  logged past the initial "MQTT publish to …" line, so the slicer never
  received a push_status or get_version response on its subscribed
  device/{serial}/report topic and hit its sync timeout. Responses were
  also unconditionally published on device/{self.serial}/report, so even
  when the inbound check happened to pass, replies targeted a topic the
  slicer wasn't listening on if its serial had drifted.

  Both directions are now serial-adaptive:

  - `_handle_publish` accepts any authenticated publish on a
    `device/*/request` topic and extracts the serial from the topic itself
    rather than comparing against self.serial.
  - A per-connection `_client_serials` dict tracks the serial the slicer
    actually uses, populated from the first SUBSCRIBE or PUBLISH seen on
    each connection and cleared on disconnect/stop.
  - `_send_status_report`, `_send_version_response`, `_send_print_response`
    now take an optional `serial` parameter (defaulting to self.serial)
    so every outgoing publish — including the periodic 1-second status
    push — targets the topic the slicer subscribed to.
  - The version response's embedded `module[].sn` fields now also carry
    the client's serial so the payload is internally consistent with the
    topic.
  - When the client's serial differs from self.serial an INFO log records
    the adaptation so it's visible in future support bundles.

  The working case (slicer's cached serial equals self.serial, as in my
  own H2D-1 Proxy setup) is bit-for-bit identical to the old behavior —
  the new check is strictly more permissive and only affects cases the
  old code silently dropped.

  Regression tests cover:
  - `_extract_serial_from_topic` valid/invalid topic shapes
  - mismatched-serial publish → handler runs, response topic and sn field
    both use the client's serial
  - non-`/request` topics → still rejected
  - pushall → status_report routed to the client's subscribed topic
  - `_client_serials` cleared on stop()
2026-04-10 12:13: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 39a5840f67 Fix camera reconnect counter off-by-one and ffmpeg log flood (#925)
Two bugs surfaced while investigating camera reconnect behaviour in #925.

  The camera page briefly displayed "Reconnecting attempt 6 of 5" before
  giving up, because the attempt counter could be incremented to the
  maximum while the reconnect banner was still rendering. The displayed
  value is now clamped to the configured maximum.

  Every failed ffmpeg spawn logged the full ~20-line ffmpeg version,
  configuration, and lib* banner, producing hundreds of lines of noise
  per failed camera click (one reported click produced 555 log lines
  across 30 retries). A new _summarize_ffmpeg_stderr helper strips the
  banner and caps output at the last 10 meaningful lines, applied at
  all three stderr log sites (immediate-failure, stream-ended,
  read-timeout). Covered by unit tests for empty input, banner
  stripping, line cap, blank-line filtering, and banner-only input.

  The underlying "camera service stops accepting connections after
  prolonged uptime" behaviour in the X1C firmware is still under
  investigation — these two fixes are independent of that root cause.
2026-04-09 11:29:57 +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 d0b91ad4d5 Fix support bundle leaking virtual printer IP address
The debug support bundle included virtual_printer_remote_interface_ip
  unmasked in support-info.json. The setting key didn't match any
  sensitive-key filter substring. Added "_ip" to the filter set so IP
  address settings are excluded. Log file content was already redacted
  by the existing IPv4 regex.
2026-04-09 08:25:40 +02:00
maziggy 813d9dde38 Fix Spoolman location not cleared on auto-sync when spool removed from AMS (#921)
The on_ams_change auto-sync callback set locations for new spools but
  never called clear_location_for_removed_spools(), leaving stale locations
  that caused double-booked slots. Also pass synced_spool_ids in the
  single-printer sync route to match the sync-all endpoint behavior.
2026-04-09 08:16:54 +02:00
maziggy 100610a1ef Bumped version 2026-04-08 13:17:25 +02:00
maziggy b76d6210cf Add SpoolBuddy quick menu with power control and system commands (#893)
Swipe down from the top of the SpoolBuddy display to open a quick-access
  menu for toggling printer smart plugs and managing the device (restart
  daemon, restart browser, reboot, shutdown). All destructive actions
  require confirmation.

  Backend: new POST /spoolbuddy/devices/{id}/system/command endpoint
  queuing reboot/shutdown/restart_daemon/restart_browser commands.
  Daemon: handles commands via subprocess (sudo reboot, systemctl restart).
  Frontend: SpoolBuddyQuickMenu component, swipe-down gesture detection,
  i18n keys for all 7 locales.
2026-04-08 11:29:44 +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 42b1653f52 Fix spool weight tracking when auto-archive disabled for queue/reprint prints (#839)
When auto_archive was disabled but the print was dispatched by BamBuddy
  (queue/reprint), on_print_start discarded the expected print entry and
  returned early. The archive was never promoted to _active_prints, so at
  completion archive_id and ams_mapping were both None — all tracking paths
  failed silently. Now detects expected prints before the auto-archive
  early-return and falls through to the normal promotion path. Also injects
  the stored ams_mapping into the usage tracker session for printers where
  MQTT request topic subscription fails (P1S, A1).
2026-04-08 08:17:42 +02:00
maziggy 4c052e04e1 Fix SpoolBuddy inventory not updating on spool changes (#905)
Spool CRUD endpoints (create, bulk create, update, delete, archive,
  restore) did not emit websocket events, so SpoolBuddy Dashboard and
  other tabs relying on event-driven cache invalidation never refreshed.

  All inventory mutation endpoints now broadcast an `inventory_changed`
  websocket event. Frontend handles it by invalidating `inventory-spools`.
2026-04-07 12:32:44 +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 2d9a56b3d0 Fix ghost jobs from SQLite lock on print completion (#897)
Queue status update (printing → completed) failed silently when SQLite
  was locked by another writer, leaving ghost jobs permanently stuck in
  printing status. Add run_with_retry() for SQLite lock retries and split
  runtime tracker into per-printer commits to reduce lock hold time.
2026-04-07 09:20:58 +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 f006472f79 Add auto-print G-code injection for queue items (#422)
Per-model start/end G-code snippets configurable in Settings (Workflow
  tab). Queue items get "Inject G-code" toggle — scheduler injects
  snippets into a temp 3MF copy before FTP upload. Supports Farmloop,
  SwapMod, AutoClear, Printflow 3D and similar bed-clearing systems.
  Original files are never modified.
2026-04-05 11:14:26 +02:00