Commit Graph
2895 Commits
Author SHA1 Message Date
maziggy 96a5f953f0 ci(windows-installer): drop Inno Setup install step
windows-latest runners ship Inno Setup 6.7.1 pre-installed under the
  same path we already hardcode for ISCC.exe; the choco install was trying
  to downgrade to 6.2.2 and failing on the version mismatch.
2026-06-10 12:20:02 +02:00
maziggy 45337cd540 ci: add Windows installer workflow 2026-06-10 12:16:31 +02:00
maziggy ef25c5f2db Updated README 2026-06-10 11:02:47 +02:00
maziggy 802faf119c Updated README 2026-06-09 15:49:20 +02:00
maziggy e0ace602fb Updated README 2026-06-09 15:45:50 +02:00
maziggy 96168c2d03 fix(restore): pause timer-based DB writers before swap (Postgres deadlock)
close_all_connections() only disposes the engine's connection pool —
  asyncio tasks like print_scheduler.run() and the smart-plug snapshot
  loop wake on their 30 s cadence and lazily reopen pool connections
  holding RowExclusiveLock on print_queue / smart_plug_energy_snapshots.
  The restore's DROP TABLE ... CASCADE pass needs AccessExclusiveLock on
  every public table, producing an AB/BA deadlock that rolls back the
  entire restore transaction.

  Reproduced 2026-06-09 restoring a native install's backup into a fresh
  Docker+Postgres deploy:
    asyncpg.exceptions.DeadlockDetectedError: deadlock detected
    Process X waits for AccessExclusiveLock on relation 109940
    Process Y waits for RowExclusiveLock on relation 110182

  Fix:
  - Layer 1: pause print_scheduler / smart_plug_manager /
    notification_service / background_dispatch via their existing stop
    affordances before close_all_connections(), with a 1.0 s sleep for
    in-flight loop iterations to release sessions. Restore handler
    already requires a container restart on success, so the paused
    services come back via the next lifespan startup.

  - Layer 2: prepend SET LOCAL lock_timeout = '10s' to the begin-block
    in _import_sqlite_to_postgres so any reactive writer (per-printer
    MQTT, hourly AMS history recorder) that slips through the pause
    window fails fast and visibly instead of producing a new deadlock.
v0.2.4.6
2026-06-09 13:54:23 +02:00
maziggy e789b2374a chore(deps): pin aiohttp >=3.14.0 for CVE-2026-34993 + CVE-2026-47265
pywebpush brings aiohttp in transitively with no version bound, so the
  resolver kept installing 3.13.5. Both CVEs are fixed in 3.14.0; direct
  floor pin here, same shape as the existing idna / urllib3 / starlette
  transitive pins. Our usage in services/external_camera.py is unaffected
  by 3.14.0 (ClientSession, ClientTimeout, ClientError, iter_chunked all
  unchanged); 29 external_camera tests pass on 3.14.1; pip-audit clean.
2026-06-09 13:40:03 +02:00
MartinNYHC c193db0acf Merge pull request #1699 from maziggy/0.2.4.6
**Bambuddy 0.2.4.6**

**⚠ Upgrade Notes — Read Before Updating**

0.2.4.6 is a fix-led patch release on the same 0.2.4 code base — no schema breaks beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), no Docker entrypoint changes. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.1 or later.

Five behavior-change callouts to know about before you upgrade:

- /api/v1/inventory/spools/{id}/reset-usage renamed to /reset-consumed-counter (breaking for external API consumers). The old name implied the endpoint zeroed weight_used; in practice it only stamps the baseline so the consumed-counter widget reads 0 going forward while remaining weight is preserved. Same for the bulk variant and the Spoolman-mode mirror. No compat shim — the old name actively misled callers. If you scripted against the old path, the migration is a one-line URL swap. Frontend, Bambuddy UI, and SpoolBuddy kiosk all migrated in-tree.

- Slicer-API sidecar now ships as pre-built images on GHCR + Docker Hub (#1657). The old slicer-api/docker-compose.yml used build: { context: https://github.com/maziggy/orca-slicer-api.git#... } which required git inside the Docker BuildKit worker — Container Station, Synology DSM, and QNAP don't ship it there, so the build failed outright. New compose uses image: ghcr.io/maziggy/orca-slicer-api:${SIDECAR_TAG:-latest}. After pulling this release:

  cd slicer-api
  docker compose pull
  docker compose up -d

- No more --no-cache --pull build dance. SIDECAR_TAG env var defaults to latest; pin to bambuddy-X.Y.Z to lock to the sidecar image that shipped with a specific Bambuddy release. Both images are linux/amd64 only; ARM64 hosts (Pi 4/5, Apple Silicon Linux) should run the sidecar on a separate x86_64 box and point Bambuddy at it via the Sidecar URL field.

- VP MQTT bridge net.info[].ip rewrite: FQDN-configured printers now work without manual reconfiguration (#1429). If you added a printer to Bambuddy by hostname (p1s.fritz.box, a router-provided DNS name, a Tailscale MagicDNS name, etc.) on 0.2.4+, the bridge couldn't rewrite the slicer-facing IP and Send quietly went to the real printer instead of the Bambuddy archive whenever the printer was powered on. After upgrade the bridge resolves the configured hostname to IPv4 at arm time and the rewrite path engages automatically — no UI action required. The unresolvable-input case (typo, dead DNS) is now logged with a specific reason instead of silently no-opping. If you were on the IPv4 workaround you can switch back to hostnames after upgrade.

- VP access codes auto-derived from the target printer in non-proxy modes. A startup migration syncs any pre-existing diverged Queue / Archive / Review VPs to match their target printer's code on first boot, with one INFO log per VP for audit. If the codes had previously diverged and your slicer was failing to connect because of it, that bridge starts working automatically after this upgrade. No user action; you may need to re-add the device in your slicer if the code changed.

- Docker bridge-mode default port mapping narrowed from 50000-51000 (1001 ports) to 50000-50029 (3 VPs worth) by default (#1646). The wide range from 0.2.4.5 spawned ~2000 docker-proxy host processes consuming ~3.5 GB host RAM on the reporter's box. New compose file maps just the slices for the first 3 VPs by default; bridge-mode users with more than 3 VPs widen the mapping by editing the published comment (50000-500N9 where N = vp_count - 1). Host-mode Linux and proxy-mode VPs are unchanged. Per-VP slicing is non-overlapping by design; collisions beyond 100 VPs fall back to the per-session retry the pre-#1646 code already had.

Make a backup before upgrading via Settings → Backup → Create Backup. Native install with update.sh snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

**Docker**

  docker compose pull
  docker compose up -d

docker-compose.yml doesn't need refreshing unless you map the VP passive FTP port range (see Upgrade Notes above) or you run the slicer-API sidecar (see Upgrade Notes above).

**Native install — recommended path**

  sudo BRANCH=main /opt/bambuddy/install/update.sh

Snapshots the database first and rolls back on failure.

**Native install — manual path**

  sudo systemctl stop bambuddy
  cd /opt/bambuddy
  sudo -u bambuddy git fetch --prune --tags --force origin
  sudo -u bambuddy git checkout main
  sudo -u bambuddy git reset --hard origin/main
  sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
  sudo systemctl start bambuddy

requirements.txt picks up one new optional dependency this release — curl_cffi, used by the firmware-update check to clear Bambu Lab's Cloudflare JA3 challenge (#1666). If the wheel fails to install on your platform Bambuddy falls back to httpx automatically; only the in-app firmware download URL stops resolving and the wiki-based version-detection badge keeps working.

Native install — INSTALL_PATH under /home

If your install lives under /home/... rather than the default /opt/bambuddy/, the systemd unit's ProtectHome=true directive previously prevented ExecStart from resolving the venv binary (#1685). The new install.sh detects INSTALL_PATH == /home/* and emits ProtectHome=read-only automatically. Existing /opt/... installs keep the stricter ProtectHome=true.

**Windows install**

0.2.4.6 ships the same Windows installer as 0.2.4.5. Existing installs upgrade in place via the Service / Update entries on the installer.

---

**Highlights**

0.2.4.6 is a reporter-credited fix release on top of the 0.2.4 line. Two big-ticket adds: Orca Cloud profile sync end-to-end — read/list/slice with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration, with four sign-in providers (Google, Apple, GitHub, email+password), a 4-tier preset picker (Orca Cloud → Bambu Cloud → local → standard), and full AMS + SpoolBuddy surface integration — and native CSV import / export for the inventory (#1576, PR #1659 by   @samedyuksel), backed by a 18-column round-trippable schema with formula-injection hardening and a per-row dry-run preview.

The VP surface got another sweep. The hostname/FQDN side of #1429's net.info[].ip rewrite is finally closed (root-caused by @Mape6 — every IPv4 codepath now resolves the configured hostname before parsing). #1548 round 2 fixes the underlying reason OrcaSlicer dropped idle MQTT connections to the VP — the slicer never sends PINGREQ at all, so spec-compliant keep_alive * 1.5 disconnects fired against a slicer that holds idle sessions indefinitely against real Bambu firmware; the fix drops the application-level read timeout in favour of TCP SO_KEEPALIVE for dead-connection detection. #1646's FTP passive-port pool is now per-VP-sliced, dropping bridge-mode host RAM by ~16× on the reporter's single-VP setup. #1658 closes the BambuStudio 2.7.x "Downloading" hang — the slicer flipped the Send sequence to FTP-before-MQTT in 2.7.x, so the set_gcode_state(FINISH) synthesis fired before the real _send_print_response overwrote it back to PREPARE; the fix re-fires FINISH 1.5 s after the synthetic ack.

K-profile + AMS-slot work: #1688 / #1689 (both reported and diagnosed by @Spionkiller01 with H2C testing) fix two related symptoms with one shared root cause — spool preset ids (GFSG98_09) and K-profile filament_ids (GFG98) look different but normalise to the same value, and previously the matchers used name parsing only. The follow-up to #1689 (Spionkiller01's diff, applied verbatim) closes the residual unconfigured-but-loaded slot case where the original cali_idx safety net was unreachable. #1694 surfaces "?" instead of "Empty" on loaded-but-unconfigured AMS slots (matches the slicer's own convention). #1680 fixes the Assign-Spool toast that claimed success on the deferred-configure-on-insert path.

Reliability and dispatch: #1679 fixes the false-cancel-and-duplicate-archive bug when Bambuddy restarts mid-print (the connected-edge reconciliation fired against state.state="unknown" before the printer's first real push_status landed). #1678 fixes the print-queue wedge when a printer accepts project_file but never starts (the watchdog returned success on subtask_id advance alone; new Phase B waits up to 180 s for the active-state transition before reverting). #1697 fixes multi-plate filament over-counting — a 190 g lid from a multi-plate 3MF was getting debited the whole file's 273 g. #1397 closes the long-standing "bed already dropped" finish-photo regression by extracting the last frame of a brief timelapse Bambuddy now force-records on every dispatched print (post-park, pre-bed-drop window) — verified on N=2 H2C prints, bed-slinger field verification welcome.

Cloud / auth: #1666 routes the Bambu Lab firmware-download fetch through curl_cffi to clear the Cloudflare JA3 challenge that started 403-ing plain Python TLS — the HTTP-layer User-Agent stays Bambuddy/1.0 honest per the 2026-05-12 compliance commitment, only the TLS handshake bytes match Chrome (pinned by a test that fails the build if the UA override is ever dropped). #1698 fixes the silently-zombie tabs after JWT expiry — setAuthToken(null) from the API client wasn't reaching AuthContext.user, so ProtectedRoute kept rendering with no token and every subsequent request 401'd silently. Fix (mirrored from @TCL987's working fork patch) dispatches an auth:expired event the AuthContext listens to.

---
**New Features**

- Orca Cloud profile sync — end-to-end (slicer + AMS + SpoolBuddy). Bambuddy now reads, lists, and slices with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration. OrcaSlicer 2.4.0-alpha shipped its own Supabase-backed cloud (auth.orcaslicer.com / api.orcaslicer.com); this integrates using a standard PKCE handshake. Four sign-in providers: Google, Apple, GitHub (paste-flow PKCE, since Orca's Supabase project only allowlists localhost for redirect_to), and email+password (direct grant). UI: the Cloud Profiles tab is now two — "Bambu Cloud" (existing, unchanged) and "Orca Cloud" (new, same rich layout). Slicer integration surfaces Orca Cloud as the top tier in a 4-tier preset picker (Orca Cloud → Bambu Cloud → local → standard); AMS slot configuration accepts orca_cloud as a 4th preset   source; SpoolBuddy modal fetches Bambu + Orca filaments in parallel via Promise.allSettled. Persistent storage: 5 new columns on users (token / refresh / expiry / provider / email) plus 3 transient PKCE state columns with 10-min TTL, dialect-branched for SQLite and Postgres. Auth-disabled mode falls back to the global Settings table. Refresh tokens are single-use (Supabase behaviour) and rotated just-in-time with <5 min leeway; new pair persisted before the downstream call so a mid-flight crash doesn't strand the user. New explicit orca_cloud:auth permission flag folded into the existing can_access_cloud API-key scope. Filed OrcaSlicer/OrcaSlicer#14028 upstream to widen the redirect_to allowlist so future Bambuddy versions can ship clean OAuth instead of paste-flow. 32 backend unit + 6 preset-resolver + 6 sidecar-fetch + 6 frontend tests; ~35 new i18n keys translated across all 10 non-English locales.

- Native CSV import / export for the inventory (#1576, PR #1659 by @samedyuksel). Bulk-add spools without manually clicking through the form, and back up / migrate the local inventory in a single round-trip. Export downloads bambuddy-spools-YYYY-MM-DD.csv with one row per active spool; Import shows a per-row preview classifying each row as valid / error / skipped / duplicate-warn before anything hits the database, then a confirm click persists only the valid rows in one transaction. Fixed 18-column schema includes weight_used / last_used / storage_location / category / low_stock_threshold_pct for a lossless round-trip. Colour resolution: explicit rgba wins, otherwise brand + color_name resolves against the Color Catalog in a single in-memory pass. Hardening: 5 MB upload cap with bounded 64 KB chunked read (handles chunked uploads where file.size is None), spreadsheet formula-injection guard on export with the inverse strip on import for lossless round-trip, soft duplicate-warn flag when an active spool with the same material+brand+color exists. Local inventory only — in Spoolman mode the buttons render disabled with a tooltip pointing at Spoolman's own   CSV import/export. 25 backend integration tests + 3 frontend modal tests; full i18n in all 11 locales. Companion wiki PR maziggy/bambuddy-wiki#41.

- Archives page banner: reactive install-step-4 nudge for the slicer-side "Store sent files on external storage" setting. Companion to the new external_storage diagnostic check (below) — catches the slicer-side variant of the same setting that never reaches the printer on older BambuStudio / OrcaSlicer. New GET /archives/no-3mf-warning endpoint returns true iff any archive in the last 30 days has extra_data.no_3mf_available=True and isn't soft-deleted; banner is amber, dismissible, and one-shot via localStorage (persistent across browser restarts — "you've been told" should outlive a session). 5 backend integration tests; 4 i18n keys in all 11 locales.

- Connection diagnostic now verifies install step 4 ("Store sent files on external storage"). Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a .gcode.3mf on the printer's SD card, every archive falls back to no-thumbnail / no-metadata, and the cause is invisible. New external_storage check reads state.store_to_sdcard (the MQTT home_flag bit 11 Bambuddy already parses). Pass when the printer reports the bit on, fail when off, skip when no live state or the field has never been populated. Slot in the check list sits between port_ftps and mqtt_auth. The skip text explicitly calls out the older-slicer limitation so users on that path know to verify manually. Wiki updated on the System and Troubleshooting pages.

- Connection diagnostic now verifies the printer is publishing on its report topic (#1622). A printer with a wrong-cased serial — or one that isn't publishing for any reason — would previously pass mqtt_auth because the broker accepts the subscription regardless. User-visible symptom was "AMS / K-profiles / custom filaments missing on the slicer side". New printer_publishing check turns the existing log warning into a structured diagnostic result. Bounded 10 s polling at 0.5 s intervals; exits the moment a message arrives, with an elapsed-seconds counter on the modal so the spinner doesn't look hung. The support-package gathering path stays fast (instant pass/fail, no wait).

- "Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by @hasmar04). Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing preferred_slicer setting drove both, so picking one forced the other. New open_in_slicer setting ('bambu_studio' | 'orcaslicer' | null) drives only the desktop "Open in Slicer" URI handoff. Default is null — existing installs fall back to preferred_slicer and behave identically until a user changes it. Five call sites across ArchivesPage, MakerworldPage, ModelViewerModal migrated. MakerWorld's "Slice in {{slicer}}" button label additionally branches on useSlicerApi so the text always matches what the button does.

- Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by @CMW-ISS). Multi-printer farms with 40+ plate runs need to know which physical plate each queued job needs. New extract_bed_type_from_3mf(file_path, plate_id) helper alongside the existing filament-usage extractor; PrintQueueItemResponse gains bed_type; the /archives/{id}/plates (and library equivalent) include per-plate bed_type for the modal's plate selector. Per-plate accuracy matters because the archive-level capture stores only the first plate's value — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, but plate 17 may actually need Engineering. 8 unit cases on the extractor.

- Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905). Every row in Archives → Print Log now has a trash icon next to the filament cell, gated on archives:delete_own / archives:delete_all. Filament / time / cost contribution drops out of Quick Stats in the same response cycle (stats aggregate over PrintLogEntry). The matching archive (if any) stays untouched — the log row is a sibling, not a child.

- Print Log page: per-row failure-cause classification (#1687 part 4, reported by @IndividualGhost1905). Reporter clarified after part 1 shipped that what he actually wanted for failure-cause grouping on the log (spaghetti, jam, bed-adhesion, etc.) was different from archive tags (which describe the model). The storage and aggregation were already there; the gaps were the GET serialiser silently dropping failure_reason from PrintLogEntrySchema, and orphan log entries (no archive —
  dispatch errors, aborts, manual entries) having no edit path. New PATCH /print-log/{entry_id}, vocabulary validated against the same 11-key canonical set the Archive Edit modal uses. Pencil icon beside the trash icon; modal saves invalidate both print-log and archives-stats query keys.

- Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by @MartinNYHC, root-caused by @IndividualGhost1905). SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so the existing Discover pass couldn't find printers on a different subnet. New "Custom subnet..." sentinel in the AddPrinterModal picker reveals a CIDR text input. When custom is picked, discovery routes through POST /discovery/scan with the typed CIDR (which already does the unicast probe). Last custom CIDR persisted to localStorage. No backend changes — SubnetScanner.scan_subnet() already caps at /22 (1024 hosts) with batch-50 concurrency.

- VP MQTT bridge surfaces why net.info[].ip rewrite didn't arm (#1429 defensive). The bridge had 4 silent early-return paths in _refresh_ip_encoding. When the rewrite silently no-op'd, the only signal was the absence of the armed INFO line. Each path now emits one specific-reason INFO line, throttled by a dedup field so an idle unarmed bridge doesn't spam at 30 s tick. This is exactly the surface that pinpointed #1429's hostname/FQDN root cause on the reporter's bundle.

---
**Changes**

- Slicer-API sidecar ships as pre-built images on GHCR + Docker Hub (#1657, reported by @d3nn3s08). Removes the build-from-source git dependency that broke installs on QNAP, Synology DSM, and Container Station. See Upgrade Notes for the new docker compose pull workflow.

- VP access code is now auto-derived from the target printer in non-proxy modes (Discord report). With the live target-printer mirror that landed earlier in the 0.2.5 cycle forwarding slicer auth bytes through to the real printer, the slicer's stored code has to clear both checks (VP listener + real printer). If the codes diverged the bridge silently failed at the second hop. The fix removes the foot-gun: when a target printer is selected, the VP card's access-code field becomes read-only with an
  Eye-toggle reveal showing the target's code. One-shot startup migration syncs any pre-existing diverged VPs with one INFO log per VP. Wiki corrected (previously framed code-match as a camera-only concern — wrong, all bridged protocols inherit).

- File Manager sidebar: "All Files" now scopes to your own uploaded files; new "External" entry holds the combined linked-folder view (#1621, reported by @kcw96). Restores the pre-external semantics so long-time users get their muscle memory back. The combined "everything across every external mount" view moves to a sibling sidebar entry that only renders when at least one external folder is linked (zero-cost on installs without the feature). New internal_only / external_only query flags
  on /api/v1/library/files. Empty-state copy distinguishes "no internal files yet" from "no external files" so a user staring at an empty External view doesn't think their NAS is broken.

- Empty AMS units no longer trigger hourly humidity / temperature notifications (#1619). The hourly recorder fanned out alarms for every AMS unit above threshold without checking whether the unit was actually loaded. New _ams_has_filament() helper inspects the firmware-reported tray_exist_bits bitmap (with a fallback to the tray array's tray_type strings for early-pushall shapes). Sensor history still records regardless of the gate so the System page humidity / temperature charts stay continuous — only the outbound notification is suppressed.

- Inventory: /reset-usage renamed to /reset-consumed-counter; UI label is now "Reset counter". Old name implied the endpoint zeroed weight_used; in practice it only stamps the baseline. See Upgrade Notes for the breaking-change scope. Behaviour is unchanged in both internal and Spoolman modes — only the path and the UI label moved.

- docker-compose.yml: bridge-mode warning about the FTP passive range + docker-proxy RAM footprint (#1646, reported by @TheFou). Default port mapping narrowed from 50000-51000 (1001 ports / ~3.5 GB RSS in bridge mode) to 50000-50029 (3 VPs). Comment explains how to widen for more VPs. See Upgrade Notes.

- AMS drying now enabled for H2C starting at firmware 01.02.00.00. Moved from _DRYING_UNSUPPORTED_MODELS to _DRYING_MIN_FIRMWARE with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes the H2C advertises (O1C, O1C2) get the same gate.

---
**Fixed**

  **UI / rendering**

- AMS drying popover's "Start Drying" button no longer hidden behind iOS Safari's bottom URL bar on iPhone (#1669). Popover sizing switched from 100vh → 100dvh; popoverPosition now defaults viewportHeight from window.visualViewport?.height so the flip-above decision uses the actually-visible area.

- Project edit modal couldn't be scrolled on short screens, so Save / Cancel were unreachable (#1642, reported by @klevin92). Standard flex-modal-scroll fix: max-h-[calc(100vh-2rem)] + sticky footer with border-t separator.

- Label printing produced two identical PDFs per click (#1628). window.open(url, '_blank', 'noopener,noreferrer') returns null even on success — the fallback <a download> click fired on every click. Dropped noopener,noreferrer (same-origin blob URL, passive PDF preview, no script context).

- Service-worker activate handler no longer hangs first-install browsers (demo-site stuck spinner + Firefox Corrupted-Content). The forced client.navigate(client.url) added for kiosk-reload-on-deploy fired on every fresh origin, not just upgrades. Reload logic moved from sw.js to sw-register.js gated on hadController = !!navigator.serviceWorker.controller captured at script load — only fires when a previous SW was controlling the document.

  **Inventory / AMS**

- K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by @Spionkiller01 with H2C testing; #1689 also reported by @IndividualGhost1905). Spool preset ids (GFSG98_09) and K-profile filament_ids (GFG98) normalise to the same value via the new toFilamentId helper (drops _NN variant suffix, strips the S in GFS); generic GFx99 ids excluded from id-match so they don't over-match. Both surfaces — isMatchingCalibration (PA profile suggester) and matchingKProfiles (AMS slot modal) — switched to id-match-first with name fallback. SpoolBuddy kiosk surfaces inherit via shared components.

- Configure Slot now keeps the active K-profile on reopen for assigned-but-unconfigured slots (#1689 follow-up, reported and patched by @Spionkiller01). Residual case where the original cali_idx safety net was unreachable because selectedPresetInfo was null for slots with tray_type="" / no slot_preset_mappings row. Fix is Spionkiller01's diff verbatim with the existing extruder guard.

- AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by @kleinwareio). Compact label below the slot circle was rendering tray.tray_type || "Empty", falling back to Empty whenever the firmware hadn't been told which material is in the slot. New branch on getEmptySlotKind matches the slicer's own convention. SpoolBuddy kiosk's AmsUnitCard carried the same bug and got the same fix.

- "Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by @kleinwareio). Backend defers the ams_filament_setting MQTT publish for empty slots (Bambu firmware drops the push) and stores the assignment with pending_config=true; the toast was always "Spool assigned and AMS slot configured" regardless. Fix branches on pending_config and surfaces a new "Assigned. Slot will configure when you insert the spool." message.

- Home-page filament assign no longer leaves the slicer unaware of PFCN cloud presets (#1648, reported by @ferch-G). Polymaker's "(Custom)" Bambu Lab H2D variants use PFCN... preset IDs (alongside GFS... and PFUS...); the cloud-detail lookup branch in apply_spool_to_slot_via_mqtt only routed the first two prefixes. Fix extends the cloud-detail-lookup branch + the discard safety net to include PFCN.

- Bambu cloud A1 Mini filament / process profiles no longer hidden in AMS slot picker (#1649, root-caused by @technopaw). Bambu shifted the @BBL suffix from A1 Mini to A1M across 106 cloud profiles mid-2026; Bambuddy's filter compared the token verbatim against the display name. New PRINTER_MODEL_SUFFIX_ALIASES table holds the bidirectional A1 Mini ⇄ A1M mapping. Same helper covers SliceModal Process/Filament compatibility. Aliases narrow on purpose — wide-net aliasing like X1 ⇄ X1C would silently group truly distinct printers.

  **Archive / stats / metadata**

- Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by @volodymyr-doba). Reporter printed a single 190 g lid from a 5-box+5-lid file and got debited for the whole file's 273 g. Two completion-time recorders (internal _track_from_3mf and store_print_data for Spoolman mode) called the extractor with no plate_id and summed every plate. Fix threads plate_id end-to-end via a new PrintSession.plate_id field captured from on_print_start + a parallel _print_plate_ids dict for the direct-Print path (which bypasses the queue). 9 new tests across test_usage_tracker.py, test_spoolman_tracking.py, and test_print_start_expected_promotion.py.

- VP archive/queue names with & no longer render as &amp;amp; (#1658 follow-up, reported by @IndividualGhost1905). ThreeMFParser._parse_3dmodel was parsing <metadata> payloads without html.unescape(); raw &amp; landed in the DB and React re-escaped on render. Fix applies the same loop-until-stable unescape pattern the sibling ProjectPageParser already had. Same drop: tooltip rewritten in all 11 locales to spell out the BambuStudio 2.7.x reality that BS unconditionally overwrites the user-typed Send-dialog name with the slugified 3MF Title field — there's no MQTT field carrying the original.

- Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK). Bambu's end-gcode lowers the build plate at print completion; capturing at gcode_state=FINISH showed the print well below the camera's natural framing. Fix sources the photo from a brief Bambu timelapse Bambuddy now force-records on every dispatched print — firmware stops recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly. BackgroundDispatchService overrides timelapse=True on the MQTT command + marks PrintArchive.bambuddy_forced_timelapse; new extract_video_last_frame ffmpeg helper extracts the last frame; _cleanup_forced_timelapse deletes both the locally-attached file and the on-printer copy when the user didn't ask for one. Verified on N=2 H2C prints; bed-slinger field verification welcome. Round-2 fix in the same drop: extractor switched from -sseof -1.0 to -update 1 (frame-per-layer recording produces sub-second videos on small prints, where the 1-second seek-from-end went before the start of the file); resolver wired into print_scheduler.py so queue-dispatched prints get the override too (not just background_dispatch.py).

  **Virtual printer**

- VP MQTT no longer disconnects idle OrcaSlicer at keep_alive * 1.5 (#1548 round 2, reported by @hollajandro). Round 1 shipped the MQTT §4.4-compliant idle disconnect; the reporter's follow-up pcap proved the spec compliance was itself the regression — OrcaSlicer sends zero MQTT packets after the initial burst (no PINGREQ), so any spec-compliant server disconnects. Real Bambu firmware doesn't enforce §4.4 (verified against the reporter's identical Orca install holding idle sessions against real hardware on the same network). Fix drops the application-level read timeout after CONNECT/auth and sets SO_KEEPALIVE on the underlying socket so the OS TCP stack detects truly dead connections.

- VP MQTT bridge net.info[].ip rewrite never armed when the printer was added by hostname/FQDN (#1429, root-caused by @Mape6, also hit @TrickShotMLG02). _ip_to_uint32_le and the host-interface picker both assume dotted-quad IPv4 and bailed on the FQDN string. New _resolve_target_to_ipv4 resolves via socket.getaddrinfo(target, None, family=socket.AF_INET) (IPv4-only filter — the net.info[*].ip field is uint32 LE). Returns None on transient DNS hiccups so the encoding doesn't break permanently. The configured FQDN is preserved into the armed log line as configured→resolved so a bad-DNS regression stays legible.

- FTP passive-port pool now sliced per-VP (10 ports each) so bridge-mode Docker drops from ~3.5 GB to ~210 MB host RAM (#1646, reported by @TheFou). Reporter measured 2002 docker-proxy host processes from the 1001-port range. Class-level PASSIVE_PORT_MIN/MAX constants are gone; new module-level compute_passive_port_slice(vp_id) returns a 10-port window allocated by VP id. Result for the reporter (single VP): ~70 MB instead of ~3.5 GB. Wrap-around behaviour at 100 VPs falls back to the per-session retry the pre-#1646 code already had. Docs corrected in the same drop (userland-proxy: false framing tightened).

- VP Queue / Archive / Review: Bambu Studio 2.7.x stayed stuck at "Downloading" after Send (#1658, reported by @IndividualGhost1905). BambuStudio 2.7.x flipped the Send sequence to verify_job → .3mf → project_file, so on_file_received's set_gcode_state("FINISH") fired first and the synthetic _send_print_response ack overwrote it back to PREPARE. The slicer waited for a FINISH transition it'd never see. Fix re-fires set_gcode_state(FINISH) from  on_print_command 1.5 s after the synthetic ack, for every non-proxy mode. Proxy mode is exempt — there the real printer drives bridge state.

- VP settings card now shows the target printer's serial in proxy mode. Runtime services (SSDP, MQTT bind identity, certificate subject) all use the target printer's actual serial; _vp_to_dict always returned the self-generated suffix-based serial, breaking the visual "one identity per VP" mental model. Fix queries the target's serial when vp.mode == proxy and target_printer_id; falls back to the self-generated serial when the target row is missing (printer deleted, race) so the card still renders.

  **Print queue / dispatch**

- Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by @IndividualGhost1905, corroborated by @Arn0uDz on watchtower-driven restarts). On startup, _on_connect broadcast on_state_change(self.state) immediately after the broker accepted the connection — BEFORE _request_push_all round-tripped real status. The reconcile saw state.state="unknown" + state.subtask_name="" and synthesised an aborted PRINT COMPLETE for every in-flight archive. When the real PRINT COMPLETE arrived, _active_prints didn't have the entry, so a brand-new archive row was created. Fix: two-layer guard — on_printer_status_change now gates reconcile spawn on state.state being a real value (parsed firmware enum); _is_active_archive_stale returns (False, "") when state.state is empty / "unknown" / None.

- Print queue no longer wedges in "Currently Printing" when a printer accepts project_file but never starts (#1678, reported by @kleinwareio). The _watchdog_print_start returned success as soon as subtask_id advanced — added for H2D's #1078 FINISH→PREPARE delay — but subtask_id advance is "command landed" not "actually printing". When the printer accepted the file but wedged (cloud+LAN re-auth dance after a power cycle, old firmware), the queue row stayed status='printing', in-memory _expected_prints kept the entry, and every subsequent queue item was blocked. Fix splits the watchdog into Phase A (existing 90 s) + Phase B (new 180 s, ~3.5× the worst observed H2D FINISH→PREPARE delay): if Phase A exited via subtask_id-alone, Phase B keeps watching for the active-state transition before reverting. Phase B explicitly does NOT force-reconnect (subtask_id advance proves the publish landed; force-reconnect mid-parse triggers 0500_4003 per #1150).

- Print Log "User" column now shows the user for prints started from the Queue (#1670, reported by @JmanB52D). Two-link gap on the Queue→manual-start path: POST /queue/{id}/start discarded the user dep, and PrintScheduler._start_print never called set_current_print_user. Fix writes item.created_by_id on the route (first-claim-wins so UI-added attribution isn't overwritten), and forwards the resolved username into printer_manager.set_current_print_user from the scheduler.

- Print queue require_previous_success no longer cascades indefinitely after a user-cancelled print (#1667, fully root-caused by @599w6c26tv-droid). Reporter saw a single user-cancelled print block 18 downstream queue items over 3 days, with reproducible logs + DB dumps proving two distinct bugs: the lookback query excluded cancelled (so user-cancellations were never found as the recent predecessor — the query walked past) AND included skipped (so one bug-cascaded skip became the next item's "failed predecessor"). Fix swaps the lookback list to ["completed", "failed", "cancelled", "aborted"] and broadens the success check to prev_item.status in ("completed", "cancelled"). Conservative one-shot recovery migration in run_migrations resets only the skipped items whose immediate real predecessor was cancelled — narrow enough not to disturb skipped items whose true predecessor was a real failure.

- Print modal now exposes a "Nozzle Offset Calibration" toggle for dual-nozzle printers (#1682, reported by @louiskleiman). The nozzle_offset_cali field was hardcoded to 2 (skip) in the MQTT project_file payload. New end-to-end plumbing with a hard MQTT-layer gate on dual-nozzle (is_dual_nozzle_model() + runtime _is_dual_nozzle flag) — even a stale queue item from when the printer was misidentified gets downgraded to 2 at dispatch. New default_nozzle_offset_cali setting (default True, matches BambuStudio behaviour); Settings row + QueuePage bulk-edit toggle only render when at least one registered printer is dual-nozzle.

  **Cloud / external**

- Firmware-update check no longer 403s against Bambu Lab's Cloudflare-gated download page (#1666, reported by @arekm). CF upped bot protection on bambulab.com to a JA3 / TLS-fingerprint challenge; plain Python TLS handshakes (httpx, requests, urllib) don't match Chrome's ClientHello bytes. Fix uses curl_cffi for the two bambulab.com fetches only. The HTTP-layer User-Agent stays Bambuddy/1.0 honest per the 2026-05-12 compliance commitment — only TLS handshake bytes match Chrome. Pinned by a test (test_bambulab_curl_cffi_session_keeps_honest_user_agent) that fails the build if the UA override is ever dropped. Soft dependency: if the wheel doesn't install on the host platform, the service logs a startup warning and falls back to httpx (wiki-based version detection continues to work; only the in-app firmware download URL stops resolving). wiki.bambulab.com and the CDN path stay on httpx — neither sits behind the same JA3 gate.

- MakerWorld URL imports into a writable external folder wrote bytes to internal storage, not the NAS (#1645, reported and root-caused by @needo37). save_3mf_bytes_to_library accepted folder_id but never loaded the folder or inspected is_external / external_path — hardcoded the destination to internal storage and left the row with is_external=False. Same class of bug as #1112 (fixed for multipart-upload + move paths, never applied to byte-import). Fix mirrors the multipart-upload path: load the target LibraryFolder, feed it through _resolve_upload_destination, persist via _stored_file_path(dest, is_external).

  **Auth / session**

- Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by @TCL987, fix mirrored from reporter's fork). When a 401 with a token-invalidating message landed in client.ts, the handler called setAuthToken(null) to drop the token, but AuthContext.user is a React state value that doesn't react to module-level setters. ProtectedRoute only redirects when user === null, so the protected tree kept rendering, every request went out with no Authorization header, and the UI silently looked empty. Fix dispatches a window.dispatchEvent(new CustomEvent('auth:expired')) after setAuthToken(null); AuthContext listens and calls setUser(null). Generic 401 Authentication required (no token-invalidating message) still doesn't fire the event — preserves the existing transient-401 tolerance.

  **System / install**

- System page now reports the container's uptime / boot time, not the host's (#1690, reported by @IndividualGhost1905). psutil.boot_time() reads /proc/stat:btime, which on shared-kernel containers is the host's boot time. Fix reads psutil.Process(1).create_time() — PID 1 in a container is the entrypoint, on bare metal / VMs it's effectively the host init. Defensive fallback to boot_time() if /proc/1/stat is unreadable.

- Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by @Geoff-S). bambuddy.service shipped with ProtectHome=true, which makes /home/* invisible to the service namespace; ReadWritePaths=$INSTALL_PATH doesn't reliably re-expose subpaths for executable resolution. Fix detects INSTALL_PATH == /home/* and emits ProtectHome=read-only for that case; the default /opt/bambuddy/ install keeps ProtectHome=true.

- X2D archives lose 3MF metadata because FTPS handshake fails on firmware 01.01.00.00 (#1638, reported by @vasmarfas). Same shape as the P2S 01.02.00.00 FTPS bug from #1401 — Python 3.13's TLS-1.3 default rejected by the X2D's implicit-FTPS server. Fix adds an X2D entry to the per-model ftp_profiles.py registry with cap_tls_v1_2=True and an N6 → X2D SSDP alias. Honest caveat: hypothesis-driven trial. The TLS-1.2 cap is the most likely cure given symptom family resemblance to #1401; if it doesn't clear the error, the registry slot stays useful as a tuning anchor for the next round of diagnostics.

  **Settings**

- Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by @Bgabor997). The filament_type select shipped with 11 base materials; expanded to 25 BambuStudio-aligned options grouped by family (PLA + CF/GF/AERO, PETG + CF, ABS + GF, ASA + CF/GF, PC, PCTG, PA family + CF/PAHT-CF/PA6-CF/PA6-GF, PET-CF, TPU, PPS family + CF/GF for X1E, PVA, HIPS).

- Scheduled local backup time is now interpreted as local time, not UTC (#1602 follow-up). Pre-fix the picker stored HH:MM and _calculate_next_run replaced into datetime.now(timezone.utc) — a UTC+3 user who wanted 21:00 local had to enter 18:00. Post-fix uses the container's local timezone resolved from TZ via zoneinfo.ZoneInfo. UTC fallback when TZ is unset or unrecognised. UI now renders the resolved zone name next to the time field. One-time behaviour change: users who   entered a UTC-offset workaround will see the first scheduled cycle after upgrade run at their local-TZ offset earlier than expected.

  **Internal / observability**

- Background asyncio tasks no longer get garbage-collected mid-flight (#1648 follow-up). Support-bundle review surfaced 94 Task was destroyed but it is pending! warnings in 8 days of v0.2.4.5 — asyncio holds only a weak reference to the result of create_task, so "fire and forget" call sites that don't store the returned task got GC'd before completion. The warning gives no traceback, so the originating exception (if any) vanished silently. New spawn_background_task(coro, *, name=None) helper stores a strong reference, attaches a done-callback that auto-removes on completion AND logs uncaught exceptions with the originating traceback. 16 truly-orphan create_task call sites migrated; other sites already keeping strong refs are unchanged.

---
**Credits**

  External contributors and reporters who drove this release: @samedyuksel (PR #1659 CSV import / export), @Spionkiller01 (root-caused + patched #1688 / #1689 / #1689 follow-up with H2C testing), @TCL987 (#1698 JWT zombie tabs — fix mirrored from their fork), @needo37 (#1645 MakerWorld external folder, plus the original #1593 multi-plate diagnosis), @volodymyr-doba (#1697 multi-plate filament tracking), @kleinwareio (#1680 / #1694 / #1678), @IndividualGhost1905 (#1679 / #1658 / #1690 / #1687 / #1670), @hasmar04 (#1329), @CMW-ISS (#1281), @hollajandro (#1548 round 2), @klevin92 (#1642), @kcw96 (#1621), @TheFou (#1646 + Docker docs corrections), @louiskleiman (#1682), @ferch-G (#1648), @technopaw (#1649), @arekm (#1666 with the working bypass demonstrated), @JmanB52D (#1670), @599w6c26tv-droid (#1667 fully root-caused), @Bgabor997 (#1686), @Geoff-S (#1685), @vasmarfas (#1638), @MartinNYHC (#1564), @d3nn3s08 (#1657), @rtadams89, @Jeff-GebhartCA, @MA2ZAK (#1397), @vmhomelab's Windows installer carries forward unchanged. 
  
Thank you!
2026-06-09 13:35:47 +02:00
maziggy ecdf577cfc test(security-fixtures): nosec B104/B108 on new 0.2.4.6 test code
Two adversarial-input fixtures added on the 0.2.4.6 branch were
  missing the # nosec annotation that 32b3a93e established for the
  same pattern. New TestNotArmedDiagnosticLogging test for the #1429
  defensive diagnostic uses bind_address="0.0.0.0"; new
  test_queue_start_user_attribution.py (#1670 fix) uses a /tmp path
  in a PrintArchive fixture. Same annotation-only convention as the
  test_virtual_printer.py sites.
2026-06-09 13:32:15 +02:00
maziggy e7574fa67c Merge remote-tracking branch 'origin/main' into 0.2.4.6 2026-06-09 13:28:38 +02:00
maziggy 3d35482c0c Bumped version 2026-06-09 12:59:34 +02:00
maziggy 53b318c797 fix(ams): surface active K-profile when no slot preset is resolvable (#1689 follow-up)
Configure Slot dropped to "default 0.020" on reopen for slots that were
  physically loaded but unconfigured (tray_type="", no slot_preset_mappings
  row). The #1689 cali_idx safety net was unreachable from that path —
  matchingKProfiles early-returned [] on !selectedPresetInfo before the
  safety net ran.

  Split the early return so the cali_idx fallback survives the no-preset
  case: when selectedPresetInfo is null but slotInfo.caliIdx > 0, return
  the active profile as a single-item list (extruder-matched when known).
  Strictly additive; caliIdx == 0/null still returns [], existing matcher
  unchanged when a preset is resolvable.
2026-06-09 12:45:36 +02:00
maziggy f87b5bb3b8 feat(diagnostic, archives): install step 4 — proactive check + reactive banner
Two complementary surfaces for the most-missed install step ("Store sent
  files on external storage"):

  1. Connection diagnostic check (printer-side variant)
     - Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
     - Pass / fail / skip; instant, no I/O.
     - Catches the newer-firmware variant where the toggle moved onto the
       printer itself (P2S 01.02 / Studio 2.6+).

     An FTP upload-and-verify probe was tried first and rejected. /cache
     is always writable from Bambuddy regardless of the slicer setting;
     only BambuStudio's own behaviour changes when the toggle flips.
     Empirically confirmed against X1C + H2D with the slicer option
     toggled off: probe still succeeded, home_flag bit 11 stayed True.

  2. Archives-page banner (slicer-side variant)
     - The slicer-side toggle is invisible to the printer — older
       BambuStudio doesn't push the change to the printer. The diagnostic
       can't see it.
     - Symptom is deterministic: archiver creates rows with
       extra_data.no_3mf_available=True (main.py:2770) when it can't pull
       the 3MF from /cache after a slicer-initiated print.
     - New endpoint GET /archives/no-3mf-warning returns whether any
       archive in the last 30 days has the flag (excluding soft-deleted).
     - Amber dismissible banner at the top of /archives; one-shot
       localStorage dismissal (matches Layout.tsx update-banner pattern,
       but persistent across sessions).
     - React-Query disabled after dismissal so the endpoint isn't polled
       once the user has been told.
2026-06-09 12:20:01 +02:00
maziggy 60e31634b8 feat(diagnostic): add "Store sent files on external storage" check (install step 4)
Detects the printer-side variant of install step 4 — many users (esp. on
  clean installs) forget to enable this and only notice when their archive
  cards have no thumbnails. The diagnostic now catches it upfront.

  Detection: read state.store_to_sdcard, which Bambuddy already parses from
  MQTT push_status home_flag bit 11 (bambu_mqtt.py:153). Instant, no I/O.

  An FTP upload-and-verify probe was tried first and rejected. /cache is
  always writable from Bambuddy regardless of the slicer setting — only
  BambuStudio's own behaviour changes when the toggle flips, not the
  printer's acceptance policy. Confirmed empirically against X1C + H2D
  with the slicer option toggled off: probe succeeded, home_flag bit 11
  stayed True. So the only reliable signal is what the printer actually
  reports about its own state.

  Limitation: the printer-side variant only exists on newer firmware
  (P2S 01.02 / Bambu Studio 2.6+). On older versions the toggle lives
  only in the slicer and the printer never hears about it, so this check
  will pass even when the user is missing step 4 in BambuStudio. The
  skip-text and the wiki call this out explicitly. A reactive banner on
  the no-3MF archive-fallback path is planned as a follow-up to cover
  that case.

  Statuses:
  - pass:  state.store_to_sdcard is True
  - fail:  state.store_to_sdcard is False (-> overall escalates to problems)
  - skip:  no live state, disconnected, or field never populated
2026-06-09 12:08:37 +02:00
maziggy bebdb38e41 feat(print-log): per-row classification editor + fix silent-drop in GET
(#1687 part 4, reported by @IndividualGhost1905)

  Reporter clarified after part 1 shipped that point 2 wasn't about
  archive `tags` (which describe the model — home decor, toys), but
  about failure-cause classification on the *log* row itself:
  spaghetti, jam, bed-adhesion, etc. Different surface, different
  lifetime.

  The data field he wanted already existed. PrintLogEntry.failure_reason
  is a String(100); the Failure Analysis widget already groups by it;
  the Archive Edit modal already mirrors archive.failure_reason into
  the most recent log entry (archives.py:1421, shipped with #1444).
  The only gaps were:

  1. The GET endpoint silently dropped failure_reason (and archive_id
     and created_by_id) from PrintLogEntrySchema construction even
     when set in the DB — so the Print Log table couldn't render what
     the Failure Analysis widget grouped by. Fixed independently of
     the editor; regression test added.

  2. Orphan log entries (no archive — dispatch errors, aborts before
     archive creation, manual entries) had no edit path at all because
     the Archive Edit modal cannot reach them. The new endpoint is
     the only way to classify those rows.

  Changes:

  - Backend: new PATCH /print-log/{entry_id} taking
    {failure_reason, status}, gated on require_ownership_permission(
    ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape
    as the per-row DELETE. Validates against the same 11-key failure
    vocabulary and 5-key status set the Archive Edit modal uses;
    unknown values return 400 rather than getting stored as raw text
    (the i18n layer maps the value back through the vocabulary,
    unrecognised values would render as literal strings).
    Empty-string failure_reason stores back as NULL so the column's
    nullable=True intent is preserved end-to-end. GET endpoint now
    surfaces failure_reason, archive_id, created_by_id.

  - Frontend: FAILURE_REASON_KEYS moved to an export from
    EditArchiveModal.tsx so the new editor reuses the exact same
    vocabulary — backend and frontend stay in lockstep. Pencil icon
    beside the existing trash icon on every Print Log row, opens a
    compact two-field modal (status + failure reason). Save
    invalidates print-log and archives-stats query keys so the
    Failure Analysis widget reflects the re-classification on the
    same response cycle. Failure reason rendered as a sub-label under
    the status badge, matching PrintLogTable.tsx's convention.

  - i18n: 10 new keys (editEntryTitle, editEntryDescription,
    entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus
    a 5-key statuses block) translated across all 11 locales. No
    English fallbacks.

  - Wiki: features/print-log.md gains per-row actions section,
    updated permissions table, PATCH/single-DELETE endpoint docs.
2026-06-09 11:21:03 +02:00
maziggy 85fbd7fc35 fix(queue): persist "Print Anyway" so scheduler stops re-flagging it
When the user clicked Print Anyway on a filament-deficit warning, the
  acknowledgement was one-shot. The route cleared manual_start and
  filament_short, then the next scheduler tick re-ran
  compute_deficit_for_queue_item against identical spool state, found
  the same deficit, and re-set both flags. The item bounced between
  "user said anyway" and "scheduler re-blocked" — every Play click
  returned 409, every confirm got rolled back on the next tick.

  Add a persistent acknowledgement flag on the queue item:

  - New column `skip_filament_check` on print_queue. SQLite + Postgres
    migration branched on is_sqlite() so Postgres doesn't reject
    DEFAULT 0 on BOOLEAN.

  - PrintQueueItemCreate + PrintQueueItemResponse schemas + the
    TypeScript types carry the field.

  - POST /print-queue/{id}/start with skip_filament_check=true now
    ALSO sets item.skip_filament_check = True (not just clearing
    manual_start / filament_short).

  - PrintScheduler._block_on_filament_deficit short-circuits to
    False — no compute, no flag-setting, no notification — when
    item.skip_filament_check is True. We trust the operator's
    decision and stop fighting them.

  - PrintModal at queue-creation time threads
    skip_filament_check=true into the create payload when the user
    clicks Print Anyway on the frontend deficit warning, so a print
    that was warned-then-acknowledged at add-to-queue time goes in
    pre-acknowledged — scheduler never blocks it on first tick.

  Flag is not auto-cleared on spool swap by design: if remaining is
  now sufficient, the check returns no deficit anyway, so the flag
  is moot. Auto-clearing would add lifecycle complexity without
  changing behaviour.

  AMS Backup awareness (the other half of the discussion) intentionally
  NOT included — verified the H2D's bit-26 of print.cfg toggles with
  the printer-side AMS Backup setting, but the X1C's cfg has a
  different shape entirely and verifying every model family isn't
  realistic. Silently under-warning would be worse than always
  per-slot. The check stays single-slot for now.
2026-06-09 10:43:07 +02:00
maziggy fdcc063d9f fix(k-profile): match by filament_id, surface active profile in Configure Slot (#1688 + #1689)
Two related bugs in K-profile matching, same root cause.

  #1688 — spool form's PA-profile suggester (PAProfileSection via
  isMatchingCalibration in spool-form/utils.ts) matched K-profiles by
  parsing the profile NAME for material/brand/variant. Spools already
  store slicer_filament (the slicer preset id) and K-profiles already
  carry filament_id, but both were ignored — so a user's custom
  K-profile whose name doesn't agree with the slicer preset got silently
  dropped from suggestions even when the underlying filament_id was
  identical.

  #1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same
  name-only logic on the slot's selected preset. A spool assigned under
  "Generic PLA" with a custom K-profile actively bound on the printer
  landed in the modal as "K profile not assigned, default 0.020 will
  be used", while the printer-card hover-card correctly showed the
  active profile. Two paths, only one was filtering by name.

  Shared root: spool preset ids and K-profile filament_ids look
  different but are equivalent after normalising. Spools store
  slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is
  the variant suffix, the S infix marks setting_id form); K-profiles
  store filament_id as the bare form ("GFG98"). Plain === doesn't
  work; both need normalising. This conversion already existed in the
  other direction at buildFilamentOptions (filament_id → "GFS" +
  filament_id.slice(2)), so the inverse toFilamentId helper is just
  the matching reverse, not new ground.

  Fix — one shared helper, two surfaces:

  - spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN"
    variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98)
    and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids
    which are shared across many filaments and must NOT id-match —
    the name fallback handles those correctly).

  - isMatchingCalibration: gains slicer_filament?: string in formData,
    tries id-match (with generic exclusion) before the existing name
    parse. PAProfileSection already passes the full formData so no
    caller edit needed. Strictly additive precedence.

  - ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId
    field (toFilamentId(cp.setting_id) for cloud presets,
    toFilamentId(builtinFilamentId) for builtin; empty for local /
    orca paths which fall through to name match).

  - ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top
    of the per-profile predicate (preferred when both sides agree
    after normalisation), then the existing name-parse logic, then
    ALWAYS unshifts the slot's currently-active K-profile by
    slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx
    0/null doesn't leak unrelated profiles in), extruder-matched when
    slotInfo.extruderId is known. This is Spionkiller01's #1689 patch
    verbatim with the activeIdx > 0 guard added.

  SpoolBuddy: both kiosk K-profile surfaces reuse the shared
  components. SpoolBuddyWriteTagPage renders PAProfileSection;
  SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes
  propagate automatically, no kiosk-specific edits.

  What this does NOT change: spools without slicer_filament, K-profiles
  without filament_id, and generic GFx99 ids all fall through to the
  existing name-based matching path. Strictly additive precedence; no
  input shape that matched under the old logic fails to match under the
  new. The #1053 cloud-preset PFUS* path is preserved because the
  toFilamentId regex /^GFS/ doesn't match a "PFU" prefix.
2026-06-09 09:53:28 +02:00
maziggy e9b6fefe95 fix(auth): redirect to /login when JWT expires mid-session (#1698)
When the JWT expired on an open tab, the next API request hit a 401 with
  "Token has expired"; client.ts cleared the token from storage but
  AuthContext.user stayed populated from the original mount. ProtectedRoute
  only redirects when user === null, so the protected tree kept rendering
  and every subsequent request silently failed with no Authorization
  header — the UI looked like every list was empty until a manual refresh
  remounted AuthProvider.

  The 3 other setAuthToken(null) sites live inside AuthContext itself and
  already pair with setUser(null), so only the client.ts cross-module
  site needed a React-tree signal.

  - client.ts: after setAuthToken(null) on a token-invalidating 401,
    window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on
    `typeof window !== 'undefined'` for SSR / test safety. Generic
    "Authentication required" 401s still don't clear the token or fire
    the event — treated as transient timing issues per the pre-existing
    comment at client.ts:155.

  - AuthContext.tsx: mount useEffect adds a window listener that calls
    setUser(null) under the mountedRef guard; cleanup removes the
    listener so unmount → remount doesn't double-bind.

  Mirrors the patch the reporter shipped on their fork (deec96d1).
2026-06-09 09:25:12 +02:00
maziggy 72044e3a53 fix(usage): scope 3MF filament tracking to dispatched plate (#1697)
When a print targets a single plate from a multi-plate 3MF, both the
  internal Filament Inventory tracker and the Spoolman-mode tracker parsed
  the 3MF without a plate filter and summed every plate's filament — so a
  single lid print debited the spool the entire file's grey + black totals.

  The 3MF parser already supports plate_id (queue pre-flight uses it at
  print_queue.py:254/:286). Plumbed it through both dispatch paths:

  Queue path:
  - PrintSession gains a plate_id field; on_print_start queries the
    printer's currently-printing queue row and records queue_item.plate_id
    onto the session.
  - _track_from_3mf accepts plate_id and passes it to the extractor.
  - store_print_data moves its existing queue-item lookup above the
    extract and uses queue_item.plate_id as the plate filter.

  Direct-Print path (reprintArchive / printLibraryFile — never goes
  through the queue):
  - _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
  - register_expected_print accepts plate_id and stores it; the 2 sites in
    background_dispatch.py and the 1 site in print_scheduler.py now pass
    it (resolve was already happening, just needed reordering before the
    register call so the value is available).
  - Expected-print promotion in main.py injects _print_plate_ids[archive_id]
    into the session, guarded so a queue capture wins over the dict.
  - _get_start_plate_id helper feeds plate_id into all 3
    _store_spoolman_print_data call sites; spoolman_tracking.store_print_data
    takes the caller value first, falls back to queue_item.plate_id.

  PrintArchive.filament_used_grams stays file-level summed by design
  (#1593's contract — the archive describes the file, not the run); only
  the per-run usage attribution becomes plate-aware. Single-plate direct
  prints resolve to plate_id=1 → plate 1 = whole file, identical to the
  prior no-filter behaviour.
2026-06-09 09:16:36 +02:00
maziggy 529155413f fix(ams): show "?" for loaded-but-unconfigured AMS slots (#1694)
Reporter on a 3-AMS P1S saw slots labelled "Empty" even though spools
  were physically loaded - OrcaSlicer's Device view showed the same
  slots as loaded.

  Root cause: the compact label below the AMS slot circle rendered
  tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever
  the printer firmware hadn't been told which material is in the slot.
  getEmptySlotKind already distinguishes 'physical' (firmware confirmed
  empty via state 9/10) from 'reset' (tray_type absent but firmware
  hasn't confirmed empty - spool loaded, just unassigned). The hover
  card and circle border already used that distinction; the compact
  label did not.

  Fix: label branches on emptyKind - 'physical' keeps "Empty", 'reset'
  shows "?" matching the slicer's own convention. External / VT tray
  label is unchanged (external trays have no "configured/unconfigured"
  distinction - they're either loaded or not). SpoolBuddy AmsUnitCard
  carried the same bug and got the same fix plus a tooltip.
2026-06-09 08:41:46 +02:00
maziggy 7a9c78c32c ● fix(vp): stop enforcing MQTT keepalive 1.5x to match real Bambu firmware (#1548)
Round 1 (b6636053 + 4ffefa60) shipped the keepalive parser, 1.5x idle
  disconnect per MQTT spec section 4.4, and a per-minute status-push
  diagnostic. Reporter's follow-up pcap showed the round-1 logic was
  correct as designed, but the actual root cause sits one layer down:
  the same OrcaSlicer install that stays connected to a real Bambu P1S
  indefinitely sends zero MQTT packets after the initial CONNECT /
  SUBSCRIBE / pushall / get_version burst - no PINGREQ at all - so any
  spec-compliant server disconnects it at keep_alive x 1.5.

  Real Bambu firmware does not enforce section 4.4. The reporter's
  identical Orca install holds idle sessions against real hardware on
  the same network. Spec compliance was itself the regression.

  Fix: after CONNECT/auth, drop the application-level read timeout
  entirely (read_timeout = None) and set SO_KEEPALIVE on the underlying
  socket so the OS TCP stack reaps dead connections within a few
  minutes. The 60s pre-CONNECT cap is preserved - a client that opens
  TCP but never sends CONNECT still gets reaped. Negotiated keepalive
  is still parsed and now logged at INFO ("MQTT client X authenticated
  (negotiated keepalive=Ys, idle disconnect disabled)") for support-
  bundle visibility.

  After this ships, OrcaSlicer should stay connected to the VP
  indefinitely while idle and reconnect cleanly on real network drops.
  The publish_json code -4 and -6010 errors reported in the original
  thread were downstream of this disconnect and should also clear.
2026-06-09 08:22:38 +02:00
maziggy 58d1b1eb74 fix(system): use PID 1 create_time for uptime/boot time on containers (#1690)
System -> Uptime / Boot Time read psutil.boot_time(), which on shared-kernel
  containers (Docker, LXC, Proxmox containers) is /proc/stat:btime - the host
  kernel's boot time, not the container's. Reporter on Proxmox LXC saw the
  Proxmox node's uptime instead of the Bambuddy container's.

  PID 1 is the container's entrypoint (or the host init on bare metal), and
  its create_time is the POSIX wall-clock timestamp of when it started.
  Switching to psutil.Process(1).create_time() reports the right value on
  containers and matches host boot within a sub-second on bare metal.

  Defensive fallback to psutil.boot_time() on psutil.Error / OSError so the
  endpoint still returns 200 with the best-available answer if /proc/1/stat
  is unreadable (locked-down container, custom seccomp policy).
2026-06-09 08:09:02 +02:00
maziggy 40729da013 feat(print-log): per-row delete on Print Log page (#1687 part 1)
Reporter noted the existing "Also remove this print from Quick Stats"
  toggle at archive delete is one-shot: if you kept stats then, there was
  no later way to drop the row; and rows without a backing archive
  (errors, aborts, manual entries) had no delete affordance at all.

  Backend: DELETE /print-log/{entry_id} mirrors delete_archive's
  ownership flow via require_ownership_permission(ARCHIVES_DELETE_ALL,
  ARCHIVES_DELETE_OWN). Owners drop their own rows; admins drop any row;
  missing IDs return 404 rather than 200-silently. /archives/stats
  aggregates over PrintLogEntry, so the filament / time / cost / count
  contribution drops out of Quick Stats in the same response cycle. The
  linked archive (if any) is untouched - the log row is a sibling, not a
  child.

  Frontend: trash icon next to the filament cell on every row, gated on
  the same permission shape as the archive trash. Confirm modal -> row
  gone. Mutation invalidates both print-log and archives-stats query
  keys so the totals re-render without a manual refresh.

  #1687 also asks for per-row tagging (already covered by
  EditArchiveModal's tags field) and per-row filament-usage-history
  edits (deferred - "restore deducted grams" is only consistent for the
  most recent usage row per spool; needs a separate design call).
2026-06-09 07:54:27 +02:00
maziggy bfbf5d59af fix(profiles): add PLA-CF + Bambu CF/GF/specialty variants to filament_type select (#1686)
The Profiles page edit modal (shared by BL Cloud / Orca Cloud / Local
  Profiles) renders filament_type from backend/app/data/filament_fields.json
  via GET /cloud/fields/filament. The curated 11-option list pre-dated Bambu's
  CF/GF lineup expansion, so PLA-CF and most carbon/glass-fiber variants were
  unselectable — saved presets carried the wrong material code at dispatch.

  Expanded to 25 BambuStudio-aligned options grouped by family: PLA (+ CF/GF/
  AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/
  PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS.

  K-profiles editor unaffected (picks filament_id, not filament_type).
2026-06-09 07:38:59 +02:00
maziggy fb1e9a917e fix(install): use ProtectHome=read-only for /home-rooted installs (#1685)
bambuddy.service shipped with ProtectHome=true, which makes /home/* invisible
  to the service namespace. Installing into /home/bambuddy/ (instead of the
  default /opt/bambuddy/) made ExecStart=/home/bambuddy/venv/bin/uvicorn fail
  with status=203/EXEC because systemd couldn't resolve the binary path.
  ReadWritePaths=$INSTALL_PATH does not reliably re-expose /home/* subpaths for
  exec resolution.

  install/install.sh now detects /home/* INSTALL_PATH and emits ProtectHome=read-only;
  default /opt/bambuddy installs keep ProtectHome=true. The manual deploy template
  defaults to read-only with a comment on when to tighten it.

  read-only keeps /home immutable to the service - no security regression, since
  ReadWritePaths still gates writes to the install/data/log dirs only.
2026-06-09 07:31:23 +02:00
maziggy 68877c639e feat(settings): split API slicer + Open-in-Slicer preferences (#1329)
Reporter wanted to slice via the Bambu Studio sidecar but open files
  locally in OrcaSlicer. preferred_slicer drove both the in-app
  SliceModal sidecar selection AND the desktop "Open in Slicer" URI
  handoff, so picking one forced the other.

  New open_in_slicer setting (str | None) drives only the desktop URI;
  null inherits from preferred_slicer so existing installs behave
  identically. Storage in the existing app_settings key/value table;
  GET normalises the "None" string back to null mirroring the
  default_printer_id convention.

  Frontend: Settings -> Slicer card adds a second dropdown ("Open in
  Slicer" with "Same as API slicer" / Bambu Studio / OrcaSlicer);
  ArchivesPage, MakerworldPage, ModelViewerModal switch desktop-URI
  call sites to open_in_slicer ?? preferred_slicer. MakerworldPage's
  "Slice in {{slicer}}" label additionally branches on useSlicerApi
  so the label matches what the button actually dispatches.
2026-06-08 10:39:21 +02:00
maziggy 432080d500 feat(queue): show build plate type on queue items + print modal (#1281)
Reporter on a multi-printer farm with 40+-plate runs needed to walk to
  the printer with the right physical plate, but the queue and the
  scheduling modal didn't surface curr_bed_type the way the archive card
  already did.

  New utils/threemf_tools.extract_bed_type_from_3mf helper (per-plate) so
  both the queue API and the /plates endpoint can return per-plate values.
  PrintQueueItemResponse.bed_type populated from archive.bed_type /
  library_file.file_metadata['bed_type'] as the default, then overridden
  per-plate via the helper when item.plate_id is set. /archives/{id}/plates
  (and library equivalent) include bed_type in each plate object.

  Per-plate accuracy matters because archive.bed_type is captured at
  ingest as only the first plate's value (services/archive.py:235) -
  a 40-plate 3MF mixing PEI + Engineering returns PEI at the archive
  level for every plate. The helper re-reads the 3MF and returns the
  truth.

  Frontend: queue card meta row + PlateSelector per-plate row + PrintModal
  header all render bed icon + canonical label via the existing
  getBedTypeInfo() helper, same as the archive card.
2026-06-08 10:23:02 +02:00
maziggy 7b4c5b3c1f fix(vp): show target printer's serial on proxy-mode VP card
The runtime services (SSDP, MQTT bind identity, cert subject) already
  advertise the target printer's serial via target_printer_serial or
  self.serial in proxy mode, but the API response that drives the VP
  settings card always returned the self-generated suffix-based serial.
  The card therefore displayed a serial that didn't match what slicers
  see, breaking the "one identity per VP" mental model.

  _vp_to_dict now resolves vp.target_printer_id -> Printer.serial_number
  when mode == VP_MODE_PROXY and substitutes the result into the response
  serial field. Archive / queue / review keep the self-generated serial
  (those modes never speak the target's identity). Orphaned target falls
  back to self-generated so the card still renders.
2026-06-08 09:54:56 +02:00
maziggy 294b926327 Updated BACKERS 2026-06-08 09:43:45 +02:00
maziggy e22c7f5d64 Updated BACKERS 2026-06-08 09:43:23 +02:00
maziggy 5fbbcb6077 Post work PR #1659 2026-06-08 09:35:24 +02:00
Samed Yüksel 66c09dff2d feat(inventory): CSV import/export for the inventory page (#1576) (#1659) 2026-06-08 09:30:16 +02:00
maziggy 2c2725cb53 fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
  giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
  BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
  calibration off.

  start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
  1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
  always send 2 even if a stale flag arrives. The kwarg threads through
  printer_manager, both background_dispatch sites, and print_scheduler so
  every dispatch path respects the per-item setting.

  print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
  branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
  defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
  library FilePrintRequest, archive ReprintRequest, settings.

  PrintModal renders the new toggle only when the selected printer is dual-
  nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
  SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
  hide unless any registered printer is dual-nozzle. Labels reuse the existing
  settings.default* keys so the only new i18n strings are
  settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
  - real translations in all 11 locales.
2026-06-08 09:20:19 +02:00
maziggy 8957cfaeef fix(inventory): assign-spool toast no longer claims AMS configured when slot was empty (#1680)
Backend correctly defers MQTT configuration when the AMS slot is empty
  at assign time (state ∈ {9, 10}) — firmware drops the push silently;
  on_ams_change replays the config once a spool is detected. The
  response carries pending_config=true to communicate that. The
  printer-card AssignSpoolModal was ignoring the flag and always
  showing "Spool assigned and AMS slot configured" — the SpoolBuddy
  modal has handled this since it shipped. Now mirrors the same
  branch and shows "Assigned. Slot will configure when you insert
  the spool." when pending_config is true.
2026-06-08 08:34:33 +02:00
maziggy d0d6a659e9 fix(reconcile): don't synthesize aborted PRINT COMPLETE on the bare-connect edge before push_status arrives (#1679)
on_printer_status_change fires from MQTT _on_connect BEFORE the first
  push_status round-trips, when PrinterState is still on construction
  defaults (state="unknown", subtask_name=""). The connected-edge
  reconcile was treating that degenerate state as evidence and
  synthesising aborted PRINT COMPLETE for every in-flight archive on
  every Bambuddy restart. The reactive PRINT COMPLETE then created a
  duplicate archive (lookup misses on cleared _active_prints), so
  filament got deducted twice.

  Two-layer guard: gate the reconcile spawn on a real state.state, and
  make _is_active_archive_stale return not-stale on unknown/empty input
  as defensive fallback. #1542 behaviour preserved — real stale archives
  still get caught the moment a real push_status arrives.
2026-06-08 08:18:13 +02:00
maziggy fdaff37975 fix(queue): two-phase dispatch watchdog so a printer that accepts project_file but never starts doesn't wedge the queue (#1678)
_watchdog_print_start now treats subtask_id-advance as Phase A
  "command landed", not as final success. Phase B (180s) keeps watching
  for the active-state transition; if it never arrives — printer
  accepted the file but stalled (cloud+LAN re-auth after a power cycle
  on old firmware was the reported trigger) — revert the queue item to
  'pending' instead of leaving it stuck in 'printing' until container
  restart. Phase B skips force_reconnect because subtask_id-advance
  proves the project_file landed; a reconnect mid-parse would trigger
  0500_4003 (#1150). Phase A's H2D 50s FINISH→PREPARE tolerance (#1078)
  is preserved by Phase B's 180s headroom.
2026-06-08 07:54:24 +02:00
maziggy a104ccc95c fix(pwa): don't force-navigate first-install clients in SW activate
Repro on every fresh demo subdomain: visitor lands on Printers OK, first
  sidebar click sticks on a spinner (Chromium) or trips "Corrupted Content
  Error" with sw.js stuck `activating` (Firefox). Manual reload recovers.

  Root cause is the `client.navigate(client.url)` call added to the activate
  handler in 18d534c9 (intended to force kiosks to pick up new bundles).
  Its guard — `client.url && typeof client.navigate === 'function'` — does
  not distinguish first install from upgrade. On any fresh origin (every
  demo session, every first-time visitor, every cleared profile) it still
  fired: Chromium raced it against the in-flight SPA mount; Firefox
  deadlocked the activate's waitUntil on `await client.navigate(...)`
  because the SW intercepts its own document fetch while still activating.

  Split the lifecycle correctly:
  - sw.js activate handler: just cache cleanup + clients.claim().
  - sw-register.js: capture `hadController = !!serviceWorker.controller`
    at load, listen for `controllerchange`, reload only when hadController
    was true. Returning kiosk hits a new deploy -> had controller -> reloads
    as before; first-install visitor -> no controller -> no forced nav ->
    React mount completes.

  Bump CACHE_NAME v29->v30 and STATIC_CACHE v28->v29 so existing browsers
  fetching the new sw.js drop the old CacheStorage in the same pass.

  SpoolBuddy unregister branch and notificationclick's client.navigate are
  unrelated and unchanged.
2026-06-07 15:40:22 +02:00
maziggy 1aaa3dfc7d Updated BACKERS.md and README.md 2026-06-07 12:18:00 +02:00
maziggy 54ac359081 Updated BACKERS.md and README.md 2026-06-07 12:17:28 +02:00
maziggy 96ce403554 fix(archive): HTML-unescape 3MF Title metadata; correct VP name tooltip (#1658)
ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of
  "PCB Vise & Solder Station" landed in the DB as the literal "&amp;" and
  React re-escaped it on render to "&amp;amp;". Apply the same
  loop-until-stable html.unescape() the sibling ProjectPageParser already
  uses, uniformly across all <metadata> values.

  Same drop: rewrite the VP archive-name-source tooltip in all 11 locales.
  BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed
  Send-dialog name with the slugified 3MF Title field whenever one is
  present, so the previous "handy if you renamed the job in the send dialog"
  copy was false. New text spells out the actual behavior; both Filename
  and Metadata modes often produce the same string for that reason.
2026-06-07 11:43:19 +02:00
maziggy d597d36d5b fix(vp): slice FTP passive ports per VP, drop bridge-mode RAM by 95% (#1646)
The 50000-51000 docker-compose port range spawned ~2000 docker-proxy
  host processes (~3.5 GB RSS) under Docker's default userland-proxy.
  The 1001-port pool was symptom treatment — collisions only matter for
  multi-VP-on-shared-bind, but the cost was paid by every install.

  Each VP now gets a non-overlapping 10-port slice computed from its id
  (VP 1 -> 50000-50009, VP 2 -> 50010-50019, ...). Class constants are
  gone; VirtualPrinterFTPServer takes passive_port_min/max instance args.
  Wraps modulo PASSIVE_MAX_SLOTS = 100, with the existing 10-attempt
  random retry as same-slot collision fallback.

  Compose default narrowed to 50000-50029 (3 VPs). Proxy-mode VPs forward
  the real printer's full range and stay on the separate TCPProxy
  constants. Compose comment rewritten to acknowledge Linux multi-service
  hosts as a primary bridge-mode audience and drop an over-stated
  "confirmed by reporter" claim about userland-proxy=false.
2026-06-07 08:39:40 +02:00
maziggy 47fe30c5ad fix(queue): credit user who clicks /start in print log when auth on (#1670)
The print log's User column came from printer_manager.get_current_print_user,
  but only background_dispatch (Archive Print, Library Print) ever populated
  that dict. The Queue manual-start path went straight from
  POST /queue/{id}/start into PrintScheduler._start_print, neither end
  recording the clicker — so any print started from the queue landed in
  PrintLogEntry with created_by_username NULL even with auth enabled.

  Two-sided fix: /start now writes user.id to item.created_by_id when no
  prior owner is set (preserves UI-added items' original uploader), and
  PrintScheduler gains _propagate_owner_to_printer_manager called from
  _start_print to hand the owner into set_current_print_user before the
  print command goes out.
2026-06-07 08:22:42 +02:00
maziggy ea4d5eb794 fix(ui): drying popover footer no longer clipped by iOS Safari URL bar (#1669)
100vh and window.innerHeight report the layout viewport on iOS Safari, so
  the popover's Start Drying button rendered behind the bottom toolbar overlay.
  Switch the popover maxHeight to 100dvh and default computePopoverPosition's
  viewportHeight to visualViewport.height (with innerHeight fallback). The
  flip-above decision and the body-scroll fallback now both run against the
  actually-visible area, so the footer button stays reachable on iPhone.
2026-06-07 08:08:44 +02:00
maziggy edaf7c4559 fix(queue): cancelled prints no longer block require_previous_success chain (#1667)
Two bugs in PrintScheduler._check_previous_success:
  - Lookback excluded 'cancelled' so user cancellations were walked past
  - Lookback included 'skipped', so one skip cascaded indefinitely

  Swap to ['completed', 'failed', 'cancelled', 'aborted'] and accept
  both 'completed' and 'cancelled' as predecessor success. Real
  'failed' / 'aborted' still gate.

  One-shot migration in run_migrations resets only the skipped items
  whose true predecessor was cancelled — surgical reversal of the exact
  bug fingerprint, leaves genuine failure-gated skips alone. Portable
  across SQLite and Postgres, idempotent on re-run.
2026-06-06 13:40:56 +02:00
maziggy 4bcab89eee fix(firmware-check): bypass Cloudflare TLS-fingerprint gate via curl_cffi (#1666)
Cloudflare on bambulab.com now serves cf-mitigated=challenge to plain
  Python TLS handshakes. Use curl_cffi.AsyncSession with impersonate="chrome"
  for the two bambulab.com fetches (index page + per-model JSON); wiki and
  CDN paths stay on httpx. HTTP User-Agent stays honest "Bambuddy/1.0" —
  only TLS-handshake bytes match Chrome, per the compliance commitment.

  Soft dependency — falls back to httpx with a startup warning when
  curl_cffi isn't importable; wiki-based version detection still works.
2026-06-06 13:30:13 +02:00
maziggy b362f159ac docs(slicer-api): switch sidecar to pre-built images on GHCR + Docker Hub (#1657)
Local build via Docker git context required `git` in the BuildKit worker,
  which QNAP Container Station and Synology DSM don't provide. Both sidecar
  images are now published to ghcr.io/maziggy/{orca-slicer-api,bambu-studio-api}
  and docker.io/maziggy/{orca-slicer-api,bambu-studio-api}; compose pulls
  :latest by default, SIDECAR_TAG=bambuddy-X.Y.Z pins per release.

  Wiki, slicer-api README, .env.example, and CHANGELOG aligned. Build-from-source
  path kept under "Building from source (advanced)" for forks / dev work.

  Both images are linux/amd64 only — OrcaSlicer ARM64 on hold pending upstream
  fix; BambuStudio doesn't publish ARM64.
2026-06-06 13:06:45 +02:00
maziggy f243e4e598 fix(asyncio): track strong refs on orphan create_task sites
asyncio holds only a weak reference to tasks returned by
  ``create_task``. Fire-and-forget callers that discard the return value
  let the event loop GC the task before it finishes, logging
  ``Task was destroyed but it is pending!`` with no traceback. The #1648
  support-bundle review surfaced 94 such warnings in 8 days of v0.2.4.5
  -- the silently-vanished exceptions reach support bundles as opaque
  GC notices instead of actionable errors.

  New backend/app/core/tasks.py::spawn_background_task(coro, *, name=None)
  is the one place in the codebase that calls asyncio.create_task. It
  stores the task in a module-level set, attaches a done-callback that
  auto-removes on completion AND surfaces any uncaught exception via the
  logger with the originating traceback, and accepts name= so a leak
  source is traceable through /tracebacks and the log line. Cancelled
  tasks don't log (a shutting-down service is not an error).

  Migrated the 16 truly-orphan create_task call sites to the helper:

    main.py (8):
      reconcile-stale, cooldown-poweroff, energy calc, smart-plug,
      maintenance-check, photo-then-notify, layer-timelapse,
      scan-timelapse, print-scheduler, notify-no-archive (the last one
      was hand-rolling the same pattern with task + no-op done_callback)
    printers.py:3123        apply-pa-after-refresh
    print_queue.py:1034     queue cooldown-poweroff
    firmware_update.py:261  firmware upload
    archive.py:1514         timelapse mp4 convert
    print_scheduler.py:2199 watchdog print-start
    library.py:1614         STL backfill
    smart_plugs.py:259      tasmota scan
    discovery.py:159        subnet scan
    smart_plug_manager.py   x3 plug auto-off-pending
    background_dispatch.py  x2 (lambda-wrapped inside
                            loop.call_soon_threadsafe) upload progress

  Sites that already kept strong refs are unchanged:
    self._tasks.append(asyncio.create_task(...)) -- VP manager,
      tcp_proxy, mqtt_server
    self._x_task = asyncio.create_task(...) on service instances --
      mqtt_bridge, obico_detection, github_backup, archive_purge,
      local_backup, library_trash, discovery service
    Locally assigned + awaited/gathered -- tcp_proxy bidirectional
      pumps, camera_fanout, slice_dispatch, slicer_api progress_task,
      manager._finish_release_task, main.py module-level cleanup loops
2026-06-06 10:41:28 +02:00
maziggy d82f4e032f fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)
Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
  the spool from the Dashboard left the slicer's filament dropdown
  showing "unknown", but clicking Configure right after made the
  slicer recognize it correctly. "Configure" felt like a mandatory
  follow-up step rather than a refinement.

  Bambu cloud uses three preset-ID shapes:
    GFS…   — Bambu official cloud preset
    PFUS…  — cloud user-created preset
    PFCN…  — cloud shared / partner preset (Polymaker's "(Custom)"
             Bambu Lab H2D variants ship this prefix)

  apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
  cloud-detail lookup that extracts the underlying filament_id. PFCN
  slipped past the cloud-lookup branch, fell into the local-preset
  int() parse path, raised ValueError, dropped into
  normalize_slicer_filament which returns any P-prefix unchanged, and
  the raw PFCN landed in tray_info_idx. The printer's calibration
  table can't index that, so the slicer rendered "unknown". The
  Configure modal rescued every assign because it does its own
  getCloudSettingDetail and writes the resolved filament_id.

  Extend the cloud-detail-lookup branch (inventory.py:129) and the
  discard safety net (inventory.py:223) to include PFCN alongside
  GFS/PFUS. Three behaviours fall out:

    * Cloud-authenticated: the real filament_id from
      detail["filament_id"] ships as tray_info_idx (Polymaker PLA
      Matte resolves to GFL05).
    * Cloud unavailable: raw PFCN discarded, the slot reuses an
      existing valid P-prefix preset if material matches.

  Source comment now lists all three cloud-ID shapes so the next time
  Bambu invents a new prefix the maintainer doesn't have to re-derive
  the structure from a bug report.
2026-06-06 10:17:09 +02:00
maziggy b8916ac3de fix(presets): match Bambu cloud @BBL A1M as A1 Mini (#1649)
Reporter on an A1 Mini saw the AMS slot Configure dropdown render no
  Bambu / Generic filament profiles, and saw the Profiles tab strip
  A1 Mini results when filtering by that model. Bambu rolled out a
  profile rename mid-2026: the @BBL <code> suffix on 106 cloud profiles
  shifted from the long display form to a terse model code -- e.g.
  "Bambu PLA Basic @BBL A1 Mini ..." is now
  "Bambu PLA Basic @BBL A1M ...". User-authored profiles still use the
  long form. Bambuddy's filters did a verbatim uppercase compare
  ("A1M" vs "A1 MINI"), so every renamed cloud profile silently
  disappeared from the picker.

  Centralize the alias check in slicerPrinterMatch.ts. New
  PRINTER_MODEL_SUFFIX_ALIASES table maps "A1 Mini" <-> "A1M"
  bidirectionally; exported matchesPrinterModelSuffix() does the
  case-insensitive compare with the alias fallback. Two consumer
  sites swap to the helper:

    * ConfigureAmsSlotModal.tsx (Orca cloud and Bambu cloud filter
      branches) -- the AMS slot picker, hit directly and reached from
      SpoolBuddy's AMS page via mapModelCode(printer?.model)
    * slicerPrinterMatch.ts:classifyByBambuName -- the SliceModal
      Process / Filament compatibility check

  Backend printer_models.py also gets a "Bambu Lab A1M" -> "A1 Mini"
  entry so server-side 3MF model normalization stays consistent if a
  3MF ever embeds the short form.

  Kept the alias table narrow on purpose. Wide-net aliasing (e.g.
  "X1" <-> "X1C") would silently collapse physically distinct
  printers. When Bambu introduces the next rename, it is one new row
  in the table -- /api/v1/cloud/settings is the place to grep, called
  out in the source comment.
2026-06-06 09:10:27 +02:00
maziggy e895d8350a fix(vp): re-fire FINISH after project_file ack so slicer releases (#1658)
Reporter on Bambu Studio 2.7.1.57 + X1C saw the Send modal stuck at
  "Downloading" after sending to a Queue-mode VP. Delete-from-queue and
  even Auto-Dispatch ON + a successful real print didn't release it.

  Root cause: BS 2.7.x flipped the Send sequence from
    MQTT project_file -> FTP upload -> done
  to
    FTP verify_job -> FTP .3mf -> MQTT project_file.

  The #1280 fix sets gcode_state=FINISH in on_file_received (after the
  FTP upload). Under the new order, the synthetic project_file ack in
  _send_print_response then runs and overwrites _gcode_state back to
  PREPARE. The 1 Hz cached-as-base push stream carries PREPARE forever,
  the slicer never sees the FINISH transition it waits for, and the
  modal sits stuck. Auto-Dispatch ON shares the cause: the real
  printer's PREPARE->RUNNING->FINISH on the bridge gets masked by the
  local _gcode_state override in _send_status_report.

  Re-fire set_gcode_state("FINISH", filename, prepare_percent="100")
  1.5 s after the project_file ack for every non-proxy mode (queue /
  archive / review). The 1.5 s window lets the slicer see at least one
  PREPARE push on the 1 Hz cycle so the transition reads as
  PREPARE -> FINISH, matching what the slicer expects. Proxy mode is
  exempt -- there the real printer drives the bridge state and a
  synthetic FINISH would clobber a real PREPARE/RUNNING transition.

  The scheduler cancels any in-flight timer when a new project_file
  arrives so a retrying slicer doesn't end with two competing FINISH
  timers. The pending timer is also cancelled on stop_server.
2026-06-06 08:51:09 +02:00