Commit Graph
971 Commits
Author SHA1 Message Date
maziggy 7026a6de77 fix(printer): bed-jog "Home Z" could crash bed into toolhead on H2C/H2D/H2S/X1 (#1052)
Critical safety fix. The bed-jog dialog's "Home Z" button sent a bare
  `G28 Z` over gcode_line. On Bambu printers where the Z endstop is at
  the top (bed moves UP into it — H2C, H2D, H2S, X1 family), `G28 Z`
  skips the toolhead-park step that a full `G28` runs first, so the bed
  rises at full speed with nothing getting out of the way. The reporter
  only escaped damage because the toolhead happened to be parked on the
  purge chute.

  The /printers/{id}/home-axes endpoint and BambuClient.home_axes() now
  always send bare `G28` regardless of the axes argument, triggering the
  firmware's safe multi-step routine (park toolhead → home XY → home Z).
  The axes argument is kept for API compat but ignored; invalid values
  still return 400.

  Frontend retitles the button "Auto Home" and updates the dialog copy
  in all 7 locales so users aren't surprised when X/Y motion happens
  before Z. Parameterized regression test asserts z/xy/all all produce
  bare G28.
2026-04-20 12:56:31 +02:00
maziggy d0f35e5d60 fix(mqtt): cap task_id at int32 max to prevent P1S dispatch stalls (#1042) 2026-04-20 08:46:57 +02:00
maziggy d3425c7f44 fix(ftp): wait for zombie thread to complete before giving up on download (#1014) 2026-04-20 08:39:09 +02:00
maziggy ea78fe720c fix(obico): clear Status banner on next successful detection cycle (#172) 2026-04-20 08:19:55 +02:00
maziggy 74527d4124 fix(smart-plug): restore MQTT subscriptions for per-type topic configs on startup (#1010)
Users integrating a Shelly plug through an external MQTT broker
  (ioBroker, Zigbee2MQTT, HA's MQTT broker, etc.) lost the plug's
  power/state/energy readings after every Bambuddy restart. The only
  fix was opening Settings → Smart Plugs, renaming the topic to a dummy
  value, saving, renaming back, and saving again.

  Root cause: three code paths configure an MQTT smart plug's
  subscriptions — the startup restore in main.py, the create route,
  and the update route — and they had drifted. The create/update
  routes used the newer per-type model (mqtt_power_topic /
  mqtt_energy_topic / mqtt_state_topic with per-type paths,
  multipliers and mqtt_state_on_value) while the startup restore was
  still on the legacy single-topic model. Worse, the restore loop
  short-circuited on `if plug.mqtt_topic:`, skipping any plug whose
  topics were only set in the new per-type fields — exactly the shape
  of a Shelly-via-ioBroker config, which publishes power and state on
  separate topics. The "rename, save, rename back" workaround routed
  through the update endpoint and re-established the subscription the
  correct way.

  Extracted the topic-resolution + service.subscribe() call into
  subscribe_plug_to_mqtt() in mqtt_smart_plug.py and routed all three
  paths through it so the schema can't drift again. The helper keeps
  the legacy `mqtt_topic` field working as a fallback for all three
  data types — matching the behaviour the startup restore used to
  have via subscribe()'s internal `effective_*_topic or topic`
  collapsing, and matching the change-detection dict already used
  during updates.

  Regression tests cover: per-type topics restored without a legacy
  topic, legacy single-topic backward compat, per-type multipliers
  overriding legacy, per-type winning when both are set, the
  empty-config skip case, and topic-list de-duplication.
2026-04-19 13:51:58 +02:00
maziggy 936b748127 fix(archive): truncation of large 3MF uploads on sendfile short-return (#1032)
On bare-metal Raspberry Pi OS bookworm / armv7l / Python 3.11, 3MF
  files larger than a few megabytes arrived complete via the
  virtual-printer FTP server but the copy into data/archives/ was
  silently truncated. The archive row was still written, the printer
  card looked fine, and the problem only surfaced later when opening
  the archive — the subsequent zipfile.ZipFile() in
  GET /archives/{id}/plates raised BadZipFile and the UI came up blank
  with no thumbnail, plate list, or filament data.

  Two things conspired:

  1. archive_print() used shutil.copy2, which takes Python's sendfile()
     fast path on Linux. On the reporter's kernel/fs combination
     sendfile returned a short count on the first call for the upload
     sizes hit in practice and the destination ended up truncated.
     Small files completed in one syscall and were fine.
  2. ThreeMFParser.parse() caught the resulting BadZipFile in a bare
     `except Exception: pass`, so the archive pipeline kept going with
     empty metadata and left the bad file on disk — nothing in the
     logs hinted anything had gone wrong until a support bundle came
     in and the "Failed to parse plates" warning fired much later.

  The archive copy is now an explicit chunked read/write with fsync —
  sendfile is not in the path. After the copy, if the source was a
  valid ZIP but the destination isn't, we refuse to create the archive
  row, remove only the truncated file (and the archive directory if
  empty — archive_dir is created with exist_ok=True so rmtree would be
  unsafe if a same-second same-filename collision happened), and log
  both sizes at ERROR so the condition is obvious in future support
  bundles. The parser's silent catch now logs at WARNING for the same
  reason.

  All nine archive_print() call sites already check `if archive:` or
  `if not archive:`, so returning None for corrupted ZIPs propagates
  cleanly without behaviour changes elsewhere.

  Regression tests cover single-chunk and multi-chunk copies, mtime
  preservation via copystat, overwrite of an existing destination, a
  ZIP roundtrip through a multi-megabyte 3MF, the new parser WARNING,
  and a truncation sentinel verifying that zipfile.is_zipfile() flips
  to False on a half-written ZIP — the exact post-condition
  archive_print now trusts.
2026-04-19 13:40:43 +02:00
maziggy c7ad449e4e fix(firmware): parse P2S/X2D wiki anchors without dash and full-width parens (#1030)
The wiki scraper silently returned no versions for P2S and X2D, causing
  Bambuddy to fall back to the Bambu Lab download page, which still listed
  01.01.01.00 as "latest" even though 01.02.00.00 shipped on 2026-04-09.

  Two regex mismatches in _fetch_all_versions_from_wiki():

  1. Heading anchor ids require an optional dash between version bytes and
     date. H2D/X1/H2C/H2S use "h-01020000-20260409"; P2S and X2D publish
     "h-0102000020260409" (no dash).
  2. The text fallback only matched ASCII parens around release dates, but
     P2S, X2D, A1 and A1-mini render dates in full-width parens (YYYYMMDD)
     (U+FF08/U+FF09).

  Anchor regex now accepts an optional dash; fallback accepts both paren
  styles. Added regression tests for both shapes.
2026-04-19 12:27:06 +02:00
maziggy 2bf397e33e fix(queue): update LibraryFile.print_count and last_printed_at on completion (#1008)
Both fields have existed on the model and been shown in the File
  Manager for some time, but nothing ever wrote to them — every file in
  every library appeared to have never been printed.

  Now on_print_complete's queue-status update path calls a small
  _bump_library_file_usage_if_completed() helper that increments
  print_count and stamps last_printed_at on the source library file
  whenever a queued print completes successfully. Failed, cancelled and
  user-aborted prints are intentionally skipped so the fields represent
  successful usage rather than attempt count.

  Unblocks sorting the File Manager by last-printed date and is a
  prerequisite for the scheduled-purge feature requested in #1008,
  which is held until we see whether manual sort+bulk-delete covers the
  use case.
2026-04-19 12:15:25 +02:00
maziggy d17c87c982 Bumped version 2026-04-19 10:07:44 +02:00
maziggy e7672e34ac chore(ci): silence false-positive security findings
- Bandit B108: mark 3 dummy /tmp paths in test fixtures as nosec
  - CodeQL py/ldap-injection: already RFC 4515 escaped via _ldap_escape()
  - CodeQL py/incomplete-url-substring-sanitization: test-only assertions
  - GitGuardian: replace sample passwords with <placeholder> strings in
    notification-template preview data
2026-04-19 10:02:31 +02:00
maziggy 10c261dcf2 chore(tests): suppress B108 on dummy /tmp test fixtures 2026-04-19 09:48:10 +02:00
maziggy 946ebb6307 Bumped version 2026-04-19 08:28:25 +02:00
maziggy bb999c6805 Post work PR #1024 2026-04-19 08:13:17 +02:00
Sn0rrii e958b10f75 fix(oidc): raise callback code/state max_length from 512 to 2048 (#1024)
Facebook and some other OAuth providers issue authorization codes that
exceed 512 characters. Pydantic rejected these with 422 string_too_long.
The OAuth spec defines no maximum code length; 2048 aligns with common
provider limits.

Also adds three integration tests to verify 512-char and 2048-char codes
are accepted while 2049-char codes are correctly rejected.
2026-04-19 08:10:56 +02:00
maziggy 68920f8c62 Fix virtual printer dropping null-terminated MQTT payloads from OrcaSlicer Linux (#927)
OrcaSlicer's Linux BBLNetworkPlugin publishes MQTT payloads with the
  C-string null terminator included in the length, so decoded messages
  arrived as `{…}\x00`. The strict json.loads() raised JSONDecodeError
  and the publish handler silently returned — pushall, get_version, and
  project_file were never answered, and the slicer hit its 60 s sync
  timeout. Print_queue mode only (proxy mode tunnels MQTT). The b069b521
  serial-adaptation fix was correct but ran past this earlier silent
  failure.

  _handle_publish now strips trailing \x00/whitespace before parsing and
  logs the raw payload on any remaining decode failure so future silent
  variants are visible in support bundles.
2026-04-19 08:04:05 +02:00
maziggy b92d4a7445 Post work PR #1013 2026-04-18 12:39:07 +02:00
Minidoracat baf0716a9a feat(cloud): support China region for token-based login (#1013)
feat(cloud): support China region for token-based login

The /cloud/token endpoint always used the global Bambu API endpoint,
so users with China-region access tokens could not validate their
token. The password login flow already exposes a region selector; this
brings the token flow to parity.
2026-04-18 12:30:01 +02:00
maziggy 115d6fe627 fix(mqtt): unique per-submission IDs for archive reprints (#1011)
Archive reprints and library-file prints built the MQTT project_file
  command with hardcoded project_id="0", subtask_id="0", task_id="0".
  Printers key per-job state (including gcode_start_time) on those IDs,
  so reprints looked like continuations of the same job and third-party
  MQTT observers (OctoEverywhere) reported compounding durations across
  repeat replays — a 40 min job reprinted from archive showed ~1h40m,
  and a second reprint of the same file showed ~4h. BambuStudio mints
  fresh IDs per submission; bambu_mqtt.start_print() now does the same
  using an epoch-millisecond timestamp for all three fields. md5 is
  deliberately left empty to avoid activating firmware md5-validation
  against a digest we can't compute without re-reading the upload.

  Added 6 regression tests in TestStartPrintUniqueIdentityFields
  covering non-zero IDs, md5 stays empty, uniqueness across successive
  submissions, numeric-string format, and blast-radius guard on
  unrelated payload fields. Updated CHANGELOG.
2026-04-18 09:09:21 +02:00
maziggy a2c7fd4542 fix(obico): revert POST-bytes approach — Obico /p/ is GET-only
The 0.2.3b4 #1003 "fix" POSTed JPEG bytes as multipart form data,
  but Obico's /p/ endpoint is declared methods=['GET'] upstream and
  reads ?img=URL from the query string. Every POST was 405'd by
  Flask's router before any handler ran, which is why the Obico
  container logs were silent while Bambuddy kept reporting
  "ML API call failed for printer N:" with a blank suffix —
  raise_for_status() on the 405 produced an exception whose str()
  rendered empty.

  Restored the pre-#1003 nonce-URL approach (commit 3e434458):
  capture locally with a 20s timeout we control, stash the JPEG
  under a single-use 32-byte nonce, hand Obico a
  GET /api/v1/obico/cached-frame/{nonce} URL that resolves in
  <50ms so its hardcoded 5s read timeout never races RTSP.

  Also guards against future silent exceptions: the error format
  now falls back to type(exc).__name__ when str(exc) is empty.
  Detection also early-returns with an explicit error if
  external_url is unset instead of handing Obico a URL it can't
  resolve.

  The #1003 reverse-proxy scenario (Authelia/Authentik/CF Access
  in front of Bambuddy) is addressed by documenting that the
  /api/v1/obico/cached-frame/ path must be whitelisted from
  external auth at the proxy layer — it is already public on
  Bambuddy's side.

  Backend: services/obico_detection.py, api/routes/obico.py,
  main.py (PUBLIC_API_PATTERNS).
  Frontend: FailureDetectionSettings banner + client.ts type +
  all 7 locales restored.
  Tests: 15 unit + 5 integration tests pass.
2026-04-18 08:50:46 +02:00
maziggy 464d56ea0d fix(install): make SpoolBuddy kiosk usable on first boot in full-mode install
Full-mode install booted into an unusable kiosk:
  - Chromium opened before uvicorn → "can't connect to localhost"
  - After reload, requires_setup=true hijacked /spoolbuddy → /setup
  - Touch-only Pi has no keyboard to complete the setup wizard
  - Declining auth left the user at / instead of the kiosk

  Fixes, bundled:

  1. backend/app/cli.py kiosk-bootstrap now, in one DB transaction:
     - creates a scoped API key (can_read_status=True, rest false)
     - upserts setup_completed=true
     so AuthContext never redirects and the kiosk URL loads directly. Users
     who want auth can still enable it from the admin UI; the provisioned
     key keeps working.

  2. install.sh full-mode runs the CLI as the bambuddy service user after
     create_bambuddy_service and sed-replaces the CHANGE_ME_AFTER_SETUP
     placeholder in spoolbuddy/.env.

  3. The generated spoolbuddy-kiosk-launch polls ${backend_url}/health for
     up to 60s before exec'ing chromium, so cold boots wait for uvicorn
     instead of flashing ERR_CONNECTION_REFUSED.

  Standalone mode was unaffected — users supply a real key from their
  existing Bambuddy before install.
2026-04-18 08:14:53 +02:00
maziggy 3502ab33c5 fix(install): auto-provision SpoolBuddy kiosk API key in full-mode install
Full-mode install wrote CHANGE_ME_AFTER_SETUP as SPOOLBUDDY_API_KEY because
  no admin exists yet to create a real one. On reboot the kiosk launched with
  that placeholder, AuthContext rejected it, and the user hit the Bambuddy
  login page instead of the kiosk. Standalone mode was unaffected — users
  paste a real key from their existing Bambuddy before install.

  Adds backend/app/cli.py with a kiosk-bootstrap subcommand that creates a
  scoped APIKey row directly in the DB (can_read_status=True, everything else
  false) and prints the full key to stdout. install.sh full-mode runs it as
  the bambuddy service user after create_bambuddy_service, captures the key,
  and sed-replaces the placeholder in spoolbuddy/.env. Idempotent with
  --force for re-installs.

  Drops the outdated "create an API key and edit .env" next-step block since
  the kiosk is now provisioned automatically.
2026-04-18 07:40:38 +02:00
maziggy a95a3c52ee fix(mqtt): detect zombie sessions via ams_filament_setting response tracking (#887)
After hours idle the MQTT connection can degrade so telemetry still
  flows but published commands never reach the printer.  The existing
  dev-mode probe only ran on first connect; this adds tracking for
  user-initiated ams_filament_setting commands — two consecutive
  unanswered commands (10 s timeout each) trigger force_reconnect.
2026-04-17 09:29:46 +02:00
maziggy 475e34ebda fix(obico): POST image bytes directly to ML API instead of callback URL (#1003)
The ML API previously called back into Bambuddy to fetch snapshots,
  which failed behind reverse proxies with external auth (Authelia, etc.).
  Now the detection loop captures the JPEG locally and POSTs it directly
  as multipart form data — no callback URL, no nonce cache, no
  external_url dependency.
2026-04-17 09:06:31 +02:00
maziggy ef37ffa7c7 fix(obico): exclude snapshot capture PIDs from stream cleanup (#172)
The periodic camera cleanup task scans /proc for ffmpeg processes and
  kills any not in the active-streams registry. The Obico detection
  service's capture_camera_frame_bytes() spawns short-lived ffmpeg for
  snapshots but never registered the PID — so cleanup killed it as
  "orphaned" mid-capture (SIGKILL, exit -9), producing false errors and
  missed detection frames.

  Track capture PIDs in _active_capture_pids and exclude them from the
  cleanup kill list.
2026-04-17 08:38:22 +02:00
maziggy 3e434458a4 fix(obico): capture snapshots locally and serve via nonce URL (#172)
Obico's ML API has a hardcoded 5s read timeout on the URL it fetches, which
  our /camera/snapshot regularly exceeds on cold calls (TLS proxy + ffmpeg +
  RTSP keyframe wait). The detection loop now captures the JPEG locally with
  a 20s timeout we control, stashes the bytes under a single-use 32-byte
  nonce, and hands Obico a new /api/v1/obico/cached-frame/{nonce} URL that
  returns the cached bytes instantly. The 5s ceiling is no longer a factor.

  The nonce is the credential (URL-safe, 256 bits of entropy, single-use,
  30s TTL) so the endpoint can be unauthenticated without widening the
  camera access surface. Replaces the previous camera-stream-token snapshot
  URL approach, which remained vulnerable to the upstream 5s timeout even
  when auth was disabled.

  Thanks to @fblix for the detailed reproducer with timeout numbers.
2026-04-16 11:09:54 +02:00
maziggy 6fb814c5ea feat(printer): add X2D support — camera, dual-nozzle, K-profile, maintenance (#988)
The Bambu Lab X2D (launched April 2026, dual-nozzle, enclosed, hardened
  steel rod gantry, AMS 2 Pro compatible) identifies itself as internal
  model code N6 via SSDP/MQTT, and real serials begin with 20P9. None of
  these identifiers existed in Bambuddy's registries, so the camera
  service fell back to the chamber-image protocol on port 6000 (X2D
  doesn't speak it), firmware-check logged "Unknown printer model: N6",
  and the dual-nozzle K-profile paths — gated on the H2D serial prefix
  "094" — would have treated X2D as single-nozzle.

  Backend:
  - Register N6 → X2D across every registry (PRINTER_MODEL_ID_MAP,
    PRINTER_MODEL_MAP, STEEL_ROD_MODELS, ETHERNET_MODELS,
    CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys + wiki path,
    virtual-printer SSDP/product/serial tables, DB vp_model_fixes).
  - supports_rtsp(): match the X2 display-name prefix and the N6 internal
    code; camera now routes to RTSP on port 322.
  - Dual-nozzle serial prefix check in bambu_mqtt.delete_kprofile and
    kprofiles.set_kprofile broadened to ("094", "20P9") — X2D now takes
    the H2D-style cali_idx in-place edit path.
  - is_h2d model gate in bambu_mqtt.start_print extended with "X2D" so
    timelapse / bed_leveling / flow_cali / vibration_cali / layer_inspect
    are sent as integers and external-spool ams_id 254/255 routing is
    preserved (H2D-style deputy-nozzle addressing).

  X2D uses hardened steel rods like P2S — it is intentionally placed in
  STEEL_ROD_MODELS, not CARBON_ROD_MODELS. A regression-guard test pins
  the classification.

  Frontend:
  - mapModelCode in PrintersPage and SpoolBuddyAmsPage handle N6 and X2D.
  - Enclosure-door badge and airduct-mode whitelists include X2D.
  - MaintenancePage.getMaintenanceWikiUrl routes X2D to P2S wiki URLs for
    steel-rod lubrication, belt tension, cold-pull, and PTFE tube
    (exported to enable direct unit testing).

  Tests:
  - test_printer_models.py: TestX2DModel (10 assertions).
  - test_bambu_mqtt.py: X2D in start_print ams_mapping and is_h2d gate;
    TestDeleteKProfileDualNozzleDetection across H2D, X2D, P2S, X1C.
  - MaintenancePageWikiUrls.test.tsx: 15 assertions covering X2D, P2S
    regression, X1C/H2D/A1Mini regression, and model-name normalisation.

  Docs:
  - README: added X2 series to the supported printers table.
  - CHANGELOG: new entry under 0.2.3b4 Fixed.

  Credit to @krautech for the report and debug bundle, and to @legend813
  for PR #989 which seeded most of the registry changes — rod-type
  classification was corrected (steel, not carbon) and the dual-nozzle /
  K-profile / is_h2d gaps were added on top.
2026-04-16 10:40:32 +02:00
Sn0rrii 071570f754 fix(oidc): normalise trailing slash on both sides of issuer comparison (#995)
PyJWT compares the iss claim against discovery_issuer with an exact string
match. Authentik (and similar providers) include a trailing slash in the JWT
iss claim while the discovery document issuer may omit it, or vice-versa.

Disable PyJWT built-in issuer validation and compare both sides after
rstrip('/') to make the check slash-agnostic.

Adds a regression test that verifies a login succeeds when the provider is
configured without a trailing slash but the JWT iss claim carries one.
2026-04-16 09:40:50 +02:00
maziggy 46c246c504 fix(archive): resume on subtask_id, short-circuit 550, cache 3mf (#972)
Second wave of #972 — reproducer on a 37.5 MB BambuStudio print to an A1
  showed three stacking root causes when Bambuddy restarts mid-print.

  1. Archive start_time lost on container restart. The name-based dedup
     cancelled any "printing" archive older than 4h and recreated it with
     started_at=now(), so a 13h print that saw a restart 10h in ended up
     showing ~1.5h duration. Persist MQTT subtask_id on every archive and
     match on that first, regardless of age — same id means same print,
     resume in place. Also revives Stale-cancelled rows for users
     upgrading mid-print.

  2. 3MF FTP search tried non-existent paths for ~48 min. Order was
     /cache → /model → /data → /data/Metadata → / with 11×30s retries
     each; BambuStudio actually pushes to / on A1, so the real path was
     tested last. Reorder to / first, and raise a new FileNotOnPrinterError
     sentinel from download_to_file on 550 so with_ftp_retry short-circuits
     via non_retry_exceptions. 425 / SSL EOF / connection resets still
     retry as before.

  3. Cover endpoint and archive flow downloaded the same 36 MB twice and
     competed for the printer's single FTP socket, producing 425 errors
     that fed cause-2's retry storm. Add an in-memory _threemf_path_cache
     keyed on (printer_id, normalized filename); whichever flow fetches
     first populates it, the other reuses the file read-only. Eviction
     runs on on_print_complete and deletes the temp file.

  Backend: 14 new tests across test_bambu_ftp.py and a new
  test_subtask_archive_resume.py. Existing suite: 2737 pass. ruff clean,
  frontend build clean.
2026-04-16 09:36:44 +02:00
maziggy c73c23b083 fix(printers): forward speed_level in websocket status payload (#993)
The MQTT parser already tracked spd_lvl and updated state.speed_level,
  but printer_state_to_dict omitted the field, so live WebSocket pushes
  never carried it. The frontend's merge-over-old-cache update kept the
  icon stuck on its previous value; only a full page reload (which hits
  the REST /status endpoint — that field was included) would refresh it.

  Changing the print speed from the printer's own panel now updates the
  Bambuddy icon live, without a reload.
2026-04-16 08:40:28 +02:00
Sn0rrii a5c3941ef1 fix(oidc): strip trailing slash from issuer URL before building discovery URL (#985) 2026-04-15 13:50:44 +02:00
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 b5ccc38e4a feat(support): include all settings (redacted) + SpoolBuddy devices in support bundle
Settings dump now retains every key from the Settings table and replaces
  sensitive values with [REDACTED] instead of dropping the row. New config
  flags automatically surface in future bundles without a code change.

  Adds integrations.spoolbuddy with per-device firmware, NFC/scale hardware,
  calibration, online state and uptime — anonymized (no hostnames, IPs or
  device IDs). Both /support/bundle and the bug-report bubble benefit, since
  they share _collect_support_info().
2026-04-14 12:22:07 +02:00
maziggy 44bb179364 feat: build-plate Z-jog control from printer card (#791)
Adds a compact "Bed" badge in the printer-card controls row
  between print speed and Stop/Pause. Opens a popover with up/down
  arrows and a 1 / 10 / 50 mm step selector.

  When the Z axis has not been homed since the last print, the
  first jog per session opens a Bambu Studio-style modal with
  Home Z / Move anyway / Cancel. "Move anyway" bypasses soft
  endstops (M211 S0 ... M211 S1) for a single move and is
  remembered for the browser session.

  Backend:
  - POST /printers/{id}/bed-jog?distance=N[&force=bool]
    Emits G91 / G1 ZN F600 / G90 (with optional M211 wrap).
    Distance validated server-side (non-zero, |N| <= 200 mm).
  - POST /printers/{id}/home-axes?axes=z|xy|all
    Emits G28 variants.
  Both gated behind Permission.PRINTERS_CONTROL.

  Frontend:
  - New indigo-themed badge + popover in PrintersPage.
  - Not-homed confirmation modal with sessionStorage "warned" flag.
  - i18n keys under printers.bedJog.* in all 7 locales.

  Tests:
  - backend/tests/unit/test_bed_jog.py — 13 tests covering
    404 / 400 / 500 / success paths for both endpoints, plus
    gcode-payload assertions for force on/off.

  Docs:
  - README feature list, CHANGELOG (0.2.3b4 Unreleased),
    printer-control wiki page, website features.html.
2026-04-14 12:05:06 +02:00
maziggy 0198226db1 Root cause: the Obico detection service handed the ML API a bare /camera/snapshot URL, and Bambuddy's auth returned 401 on that unauthenticated GET. The ML API then
surfaced it as "Failed to get image", which Bambuddy reported back as a 400.

  Fix: the snapshot endpoint already accepts a reusable camera-stream token (the same mechanism used by <img>-based camera consumers, since browsers can't send auth headers
  on image loads). The detection service now appends that token to the URL it gives the ML API. The token is cached on the service, refreshed 5 min before its 60-min expiry,
   and is simply ignored when Bambuddy auth is disabled — so no behavior change for users without auth.

  Refs #172
2026-04-14 11:36:46 +02:00
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