Closes the loop on the bundle work: users who imported a Printer
Preset Bundle via Settings → Slicer Bundles can now pick it in the
SliceModal and slice through the bundle dispatch path the backend
already supports.
UX:
- New "Slicer bundle" picker at the top of the modal, rendered only
when at least one bundle is imported (GET /slicer/bundles non-empty)
- Selecting a bundle replaces cloud/local/standard preset dropdowns
with bundle-scoped pickers (process + per-slot filament names from
the bundle). Printer is implicit (each .bbscfg has exactly one).
- Submit routes through SliceRequest.bundle so the backend skips
PresetRef resolution and asks the sidecar to materialise the JSON
triplet from the stored bundle by name.
- "None" leaves the modal on the original preset triplet path.
Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest.
When SliceRequest.bundle is set, the dispatch picks the per-category
JSON triplet from a sidecar-stored .bbscfg by name instead of
resolving cloud/local/standard PresetRefs. Mirrors the bundle-aware
preview slice (committed earlier) so live slices match the same
profile triplet the modal previewed against.
Schema:
- SliceBundleSpec: bundle_id + printer_name + process_name +
filament_names (min-length-1 list, plate-slot order)
- SliceRequest.bundle: optional, validator skips preset-required
check when set so bundle-only requests validate
Dispatch:
- _run_slicer_with_fallback branches on request.bundle
- Skips resolve_preset_ref, calls slice_with_bundle
- 3MF + bundle CLI 5xx still falls back to embedded-settings slice
(used_embedded_settings=True surfaces in the response)
- Sidecar 404 (unknown bundle / preset name) maps to 400
Until the preview slice / embedded-metadata read returns the per-plate
filament list, the modal renders a synthetic single-slot fallback so
the auto-pick has something to bind against. That made the Slice button
enabled the moment the modal opened, even before the slicer had told us
which AMS slots the plate actually consumes — clicking would dispatch
against opaque defaults.
Add filamentReqsQuery.isSuccess to the isReady chain so the button
stays disabled while the preview slice is in flight (or before the
backend's /filament-requirements call settles for sliced files) and
flips to enabled the moment the real slot list lands and auto-pick
fills it.
The SliceModal's preview slice runs against unsliced project files to
discover per-plate AMS slot consumption. Until now it always used
slice_without_profiles — accurate slot mapping (a model property) but
gram numbers were derived from the file's embedded process settings,
which can drift from the triplet the real print will use.
When the caller provides a bundle id + printer/process/filament preset
names, get_preview_filaments now routes through slice_with_bundle so
the preview's gram numbers match what the real print will produce.
Cache key picks up a bundle-context fingerprint so different bundle
picks on the same file occupy distinct entries.
Backend:
- slice_preview.get_preview_filaments: optional bundle_* params
- library.py + archives.py: forward params via /filament-requirements
Frontend (forward-compat for the upcoming SliceModal Bundle tier):
- api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
accept an optional 4th-arg bundle context object
Wires Bambuddy to the orca-slicer-api fork's bundle endpoints (shipped
in bambuddy/bundle-import). Users will eventually upload a BambuStudio
"Printer Preset Bundle" (.bbscfg) once per printer; subsequent slices
pick from the bundle by preset name instead of re-uploading the JSON
triplet every time.
Service layer:
- BundleSummary / BundleNotFoundError types
- import_bundle / list_bundles / get_bundle / delete_bundle methods
- slice_with_bundle: POST /slice with bundle id + per-category names
instead of attached profile JSONs
Routes (LIBRARY_UPLOAD perm gate):
- POST /api/v1/slicer/bundles
- GET /api/v1/slicer/bundles
- GET /api/v1/slicer/bundles/:id
- DELETE /api/v1/slicer/bundles/:id
All routes proxy via _resolve_slicer_api_url so they follow the user's
preferred_slicer setting (bambu_studio vs orcaslicer). Status-code
mapping treats sidecar 4xx as 400, BundleNotFoundError as 404,
unreachable as 503, and sidecar 5xx as 502.
Two related failure modes have been biting Docker users repeatedly,
most recently in #1211:
1. Docker named volumes are created by the daemon as root:root, and
the previous `chmod 777 /app/data` Dockerfile workaround only
covered the named-volume root — so subdirs Bambuddy creates at
runtime (virtual_printer/uploads, virtual_printer/certs, etc.)
inherited wrong ownership when the container ran as 1000:1000.
2. The shipped docker-compose.yml ships
`./virtual_printer:/app/data/virtual_printer` uncommented, and
dockerd creates a missing bind-mount source on the host as root
before the container starts — leaving the host directory
unwritable by uid 1000 inside the container even though the named
volume above it had the chmod-777 workaround.
Symptom either way: [Errno 13] Permission denied:
'/app/data/virtual_printer/uploads', no virtual printer ever starts,
"VP doesn't work" support reports follow.
Replace the chmod-777 hack with a proper entrypoint:
- deploy/docker-entrypoint.sh runs as root, chowns /app/data and
/app/logs (and /app/data/virtual_printer when bind-mounted) to
PUID:PGID, then drops to that uid via gosu before exec'ing the
app. The chown is gated behind a top-level ownership check so
subsequent restarts skip the recursive traversal — no multi-
second startup penalty on multi-GB archive directories.
- A sentinel .bambuddy file in each data path prevents Docker from
re-syncing image directory metadata on every mount (otherwise
empty volumes have their ownership reverted from the image on
each restart, defeating the idempotency).
- When the container is started with an explicit `user:` directive
or `--user` flag the entrypoint detects it isn't root and falls
through to direct exec — preserving compatibility for users who
pin a specific uid.
Compose template changes:
- Remove `user: "${PUID:-1000}:${PGID:-1000}"` (entrypoint owns
privilege drop now).
- Add PUID / PGID env vars with the same defaults.
- Comment out the ./virtual_printer:/app/data/virtual_printer
bind mount by default, with explicit "only needed if you also
run a native install of Bambuddy on the same host and want both
to share the VP CA cert" guidance. The entrypoint chowns the
host-side dir through the bind mount the first time it sees
wrong ownership, so existing uncomented installs continue to
work and #1211 specifically gets fixed.
Restoring a settings backup ZIP appeared to succeed but the user found
settings reverted to defaults, most printers/archive rows missing, and
~1 GB of archive files on disk with only 1 row in the database. Same
shape as #668 (closed in March without an actual fix — that user
happened to make it work by rolling back to a stable release, which
masked the bug).
Cause: the live DB runs in WAL mode. Anything the fresh container wrote
between startup and the restore call (seed_default_groups, init_db
migrations, heartbeat writes) sits in bambuddy.db-wal with valid
checksums, and engine.dispose() doesn't checkpoint it. FastAPI's
dependency injection keeps the route handler's own `db: Depends(get_db)`
session checked out across engine.dispose() (per SQLAlchemy docs,
dispose only closes pooled connections, not checked-out ones), so the
WAL inode is held open through the whole restore. After shutil.copy2
rewrote the main DB inode in place, SQLite's WAL recovery on the next
init_db() re-applied the stale frames on top of the restored content,
partially clobbering it with fresh-install state.
Initial fix attempt of deleting -wal/-shm/-journal sidecars before the
copy was insufficient (verified experimentally) — the still-open
request session reads the unlinked sidecars via held fds and bleeds
the WAL state back into the new file when it eventually closes.
Real fix: replace shutil.copy2 with SQLite's online backup API
(src_conn.backup(dst_conn)). The page-by-page protocol opens both DBs
as proper SQLite connections, acquires the right locks, and routes
new pages through the destination's own WAL. Concurrent open sessions
see their own transactional snapshot until they close (transaction
isolation) but can't corrupt the restored state.
backend/tests/integration/test_static_html_cache_headers.py asserted
that "/", "/spoolbuddy/", and "/printers" return text/html with
Cache-Control: no-cache, must-revalidate. The Dockerfile.test
backend-test target intentionally doesn't bake in the built frontend
(saves ~30s of build time per test run), so static/index.html doesn't
exist inside the container. The route handlers correctly fall through
to their "frontend not built" JSON branches, and the test fails with
"non-HTML content-type: application/json" — latent since the test was
added in e9200449 (Apr 26).
Add a fake_static_index fixture that creates a tmp dir with a one-line
<!doctype html> stub and monkeypatches app_settings.static_dir to it.
Both call sites are patched (config.settings and main.app_settings) in
case a future refactor splits the singleton. The test continues to hit
the real serve_frontend / serve_spa route handlers and validates the
real cache-header contract — just doesn't depend on a built bundle
being present.
Verified passing both with and without static/index.html present.
formatTimeOnly calls date.toLocaleTimeString([], …) which respects the
user's locale by design — the en_DK.UTF-8 locale uses "." as the time
separator, so the function correctly returns "02.30 pm" / "14.30" for
a Danish-English user. The two assertions hard-coded ":" as the
separator, which made the tests fail under any locale that doesn't
use ":". The implementation was right, the tests were wrong.
Switch the regex to use \D+ (any non-digit, one or more) for the
separator. This tests the actual contract — "hours and minutes,
separated somehow" — without coupling to a separator that varies by
locale (en_DK uses ".", some en_* locales use a narrow no-break space
at U+202F, most others use ":").
Verified passing under en_DK.UTF-8, en_US.UTF-8, and de_DE.UTF-8.
Audited every other toLocaleTimeString / toLocaleString call site in
the test suite — no other places hard-code separator characters.
Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display
didn't change actual blanking behaviour: whatever value was active when
the kiosk last booted continued to fire. A user who started with the
10m preset and switched to 1m or "Off" still saw the screen blank at 10m.
Cause: blanking is driven by swayidle, started once by spoolbuddy-idle.sh
at labwc autostart with the timeout passed as a command-line argument.
The script fetched blank_timeout from the backend exactly once at startup
and swayidle has no runtime control surface for changing its timeout.
The daemon's display.set_blank_timeout() updated an in-memory variable
that was never reached by swayidle, so UI changes were silently discarded
until the next kiosk restart.
Fix: extend the wake FIFO protocol with a "reload-timeout N" message.
The daemon writes it whenever set_blank_timeout() sees a value that
differs from the current one (the very first call is suppressed because
the watchdog already fetched the same value at its own startup —
signalling there would just thrash swayidle on every cold boot). The
script's FIFO loop is restructured around start_swayidle / stop_swayidle
helpers and a case statement: wake → wlopm --on + arm a re-blank at the
current timeout; reload-timeout N → kill running swayidle, set TIMEOUT=N,
restart swayidle, wlopm --on so the user sees the change took effect
even if the screen was already blanked. The script de-dupes too — a
reload-timeout whose N matches the current value is a no-op. Going from
any positive timeout to 0 ("Off") stops swayidle and doesn't restart
it; going from 0 to a positive value starts a fresh swayidle. Both work
without a kiosk restart.
The script's main loop opens the FIFO read+write (exec 3<>"$WAKE_FIFO")
so bash read never sees EOF when the daemon momentarily disconnects
between writes. A cleanup trap on TERM/INT/HUP stops swayidle, removes
the FIFO, and exits cleanly.
Closes the longest-standing inventory gap — finding a specific spool
in a closet of 50 partials. Per-spool icon button on every inventory
card and table row, plus a "Print labels..." header action that opens
a multi-select picker pre-loaded with the currently filtered spools.
Four pre-built templates: AMS holder (30 x 15 mm) for the popular
Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
the colour swatch (with multi-colour gradient stripes for spools
with extra_colors set), brand, material, name, the *spool ID*
(bsaunder's articulated user-need: telling 8 spools of "PLA White"
apart, especially partials), and a QR code that deep-links to
/inventory?spool=<id> for phone-scan round-trips. Box-label adds
storage location; AMS-holder drops the QR — at 30 x 15 mm there is
no room for swatch + text + QR without truncating away the spool ID,
and AMS-bay identification is at arm's length where the swatch and
ID are enough.
Server-side rendering via ReportLab + qrcode (already a dep). Pure
Python, no headless browser, no system libs. Output is byte-identical
across browsers, Avery sheets align to <0.1 mm, and bulk export is
one click for one PDF. Two endpoints — POST /inventory/labels (local
DB) and POST /spoolman/labels (Spoolman-backed) — gated on
INVENTORY_READ, capped at 500 spools per request, returning
application/pdf via StreamingResponse. The renderer is decoupled
from the SQLAlchemy model via a LabelData dataclass so the same code
path serves both modes.
Modal picker scales to large libraries: search (substring match
across name / brand / #ID), material filter chips derived from the
visible spools, additive Select-all-visible / Deselect-visible /
Clear-all actions so selections survive filter changes. Restyled
twice in development — first cut used generic Tailwind which clashed
with the inventory's bambu-dark palette; second cut switched to
bambu-dark-secondary / bambu-green / bambu-gray to match.
Two render bugs found during visual inspection of generated PDFs and
fixed before commit:
1. AMS-30x15 template originally produced labels with only swatch
+ QR and no text at all — the side-by-side layout left <5 mm
for the text column, so the renderer bailed without drawing
anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
regimes; tight regime drops the QR and gives the right column
to brand + material + a 13pt-bold spool ID.
2. Box-62x29 template aggressively truncated text — swatch + QR
each at ~14 mm on a 26mm-tall label squeezed the text column
to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
"Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
leaving the text column ~30 mm — full names render without
truncation.
Both bugs pinned by regression tests in test_label_renderer.py that
render with pageCompression=0 so the resulting PDF bytes contain the
text as ASCII and `assert b"Polymaker" in pdf` works.
Daily builds since 889c8bd8 (Apr 29) silently destroyed archive copies
and library file bytes on every print completion. Reprint / View G-code
later returned 404 with no log line explaining why; the DB row was
intact and the archive grid kept showing the entry, but the file
behind archive.file_path no longer existed on disk.
Root cause: #1166 added three dispatch sites that cache the live
archive copy (and library file bytes for Direct-Print) in the shared
3MF download cache, so /cover could skip a redundant FTP transfer
mid-print. The cache was originally designed for transient downloads
under archive_dir/temp/, and clear_3mf_cache(printer_id) — called
from on_print_complete to keep that temp dir from accumulating —
happily unlink()'d every cached path. Path.exists() guarded the
unlink, so no exception, no warning, just silent destruction. Listing
didn't change; only acting on the archive surfaced the 404.
Fix: clear_3mf_cache._maybe_unlink refuses to unlink any path outside
archive_dir/temp. Cache dict is still cleared (so re-cache continues
to work and /cover hits a fresh path next print), only the on-disk
delete is gated. Persistent locations — archive/<printer_id>/...,
archive/unassigned/... (VP-archived prints with printer_id=None),
library_files/..., is_external library mounts — all survive.
Regression test test_clear_does_not_delete_persistent_files pins the
contract end-to-end: archive 3mf, library 3mf, and temp 3mf all
cached for the same printer; after clear, all three cache entries
are dropped from the dict, but only the temp file is unlinked from
disk. Two existing tests updated to put fixtures under
archive_dir/temp.
The SpoolBuddy "weigh-then-assign" workflow tried to configure an empty AMS
slot at assign time, but Bambu firmware silently drops ams_filament_setting
and extrusion_cali_sel for unloaded slots — the MQTT calls completed and the
modal closed, yet BambuStudio kept showing the slot as default-PLA forever.
assign_spool now detects an empty target slot (fingerprint_type empty) and
persists the SpoolAssignment without publishing MQTT, returning a new
pending_config flag so the frontend can swap "Assigned!" for "Slot will
configure when you insert the spool." on_ams_change watches for the slot
to load (state == 11, which fires for 3rd-party tags too even when
tray_type stays empty) and replays the deferred ams_filament_setting +
extrusion_cali_sel — including the printer-kp realignment that converts
PFUS-prefix cloud user presets to the P-prefix local-preset filament_id
the slicer actually accepts.
The full assign-time MQTT block was extracted into
apply_spool_to_slot_via_mqtt so both the assign endpoint and the
on_ams_change replay path use the same resolution logic; the helper takes
~270 lines of duplication out of assign_spool.
MakerWorld 3MFs sliced for P2S (and likely other Bambu printers) ship
project_settings.config entries with "-1" on fields BambuStudio writes
to mean "inherit from the parent process preset". The headless slicer
CLI's StaticPrintConfig validator runs against the embedded settings
*before* --load-settings overrides apply, so the sentinel trips the
field's lower-bound check and the CLI exits non-zero before the supplied
profile triplet is consulted. Both slice paths (with-profiles and the
embedded-settings fallback) read the same config and fail the same way,
so the user surfaces the error.
Surgical fix: open Metadata/project_settings.config, remove allowlisted
keys when their value is exactly "-1", re-zip. Allowlist starts with the
two from this report (raft_first_layer_expansion, tree_support_wall_count)
plus prime_tower_brim_width (a known sentinel cited in earlier reports).
Non-allowlisted "-1" values are left alone so a blanket strip can't
corrupt legitimate negative values. Other zip entries pass through
byte-identical — no risk of the failure mode the previous full-strip
experiment hit (silent CLI exit on initialisation).
Sanitiser runs before both slice_with_profiles and slice_without_profiles
paths since both fail on the same sentinel.
13 new unit tests in test_project_settings_sentinel_sanitiser.py cover
the allowlist removal contract (parametrised across all sentinel keys),
preservation of unaffected values, byte-identical round-trip of unrelated
zip entries, and defensive fallbacks for non-zip / malformed / no-config
inputs.
Adding new sentinel keys is one-line: append to
_PROJECT_SETTINGS_SENTINEL_KEYS in library.py.
Two consecutive plates of the same model would create the second print's
archive with the first plate's metadata: subtask_name lags across the
boundary while gcode_file is fresh, so the FTP candidate list (built
from subtask_name first) lands on the previous plate's still-resident
upload. The 3MF parser then locks the wrong _plate_index, name, time
estimate, and per-slot filament data into the archive at creation.
Fix peeks the downloaded 3MF's slice_info plate index, compares against
parse_plate_id(filename) (the plate parsed from /Metadata/plate_N.gcode,
which always reflects what's running), and on mismatch retries FTP with
swap_plate_suffix(subtask_name, expected_plate) — handling both the
spaced "Plate N" and underscored "_plate_N" suffix forms seen in real
subtask_names. If the retry finds a matching 3MF, the wrong file is
dropped and the corrected one feeds the archive; if no match is found
(or no swap is possible) the wrong file is dropped and the existing
no-3MF fallback creates an archive whose name reflects the right plate.
The validation only runs when parse_plate_id() returns a value, so
single-plate / cloud-named / non-Bambu jobs are unaffected.
17 new unit tests in test_archive_plate_validation.py cover both helpers:
plate-index peek across malformed / missing / non-integer / non-zip
inputs, and the suffix swap across both casings, the underscored form,
case-insensitive matching, and rejection of names without a recognised
suffix.
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:
- Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
dialog (IP-only).
- Their printer-MQTT trust path validates only against the bundled BBL CA
store (`printer.cer`), NOT the system trust store. Confirmed against
ClusterM/open-bambu-networking's clean-room reimplementation:
`mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
chain validation against BBL CA only, hostname check intentionally
skipped (because Bambu's printer cert CN is the device serial).
- LE certs don't chain to BBL CA, so the slicer rejects with the
well-known "-1" before any hostname/IP logic runs.
The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:
- Toggle stays as an informational marker — when ON, the VP card surfaces
the host's Tailscale IP + MagicDNS hostname so users know what to paste
into the slicer.
- Cert is always self-signed (signed by `bbl_ca`).
- Tailscale exposure is via the existing bind_ip dropdown, which already
includes `tailscale0` IPs.
- Tailscale's role is strictly network reach — same trust burden as LAN.
Backend cuts:
- `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
`_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
`cryptography` import. Keep `get_status` and `TailscaleStatus`.
- `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
- `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
`_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
`_cancel_renewal_task`, `_cancel_restart_task`. Simplify
`_resolve_cert_and_advertise` to a sync method that just generates the
self-signed cert. Drop `tailscale_disabled` from the change-detection
diff (toggle is informational — no service restart needed).
- `routes/virtual_printers.py` + `routes/settings.py`: drop the
`tailscale_not_available` 409 guard on toggle-enable.
Frontend cuts:
- `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
`multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
no longer populated). Drop the `tailscale_not_available` toast handler.
- `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
- i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.
Docs:
- Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
— remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
steps, document the toggle as informational, keep the Docker socket
mount + LXC TUN troubleshooting (those still apply for daemon
reachability).
- README: drop "the Tailscale benefit here is the tunnel, not cert-import
elimination" framing in favour of "surfaces the IP for paste into
slicer; CA import unchanged because BBL CA store, not system trust
store, is what gets validated".
Tests:
- `test_tailscale.py`: reduced to surviving `get_status` cases (binary
missing, command fails, success, empty DNSName, malformed JSON).
- `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
→ `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
informational; `remove_instance` must NOT be called).
- `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
`TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
directions succeed and daemon is never consulted).
- `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
FQDN-copy block drives data through that query.
DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.
Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
feat(vp): mirror live target printer state to slicer in non-proxy modes
In non-proxy VP modes (Immediate / Review / Print Queue), the slicer now
sees real AMS / FTS / nozzle / k-profile state from the target printer
and streams the live camera — full slicer-as-remote functionality without
giving up Bambuddy's queue / archive / dispatch features.
Architecture (cached-as-base, single source of truth). The bridge caches
the latest real push_status and info.get_version response from Bambuddy's
existing per-printer MQTT subscription — no second session on the printer,
firmware in-flight budget unaffected (#1164). _send_status_report serves
a near-byte-identical copy of the cached push with only the upload-state-
machine fields overridden. Command responses (extrusion_cali_get, AMS
write acks, xcam) fan out raw — they carry sequence_ids the slicer is
waiting on. Slicer-issued commands forward to the printer except
project_file / gcode_file, which still terminate locally because the file
lives on Bambuddy. Camera is a raw TCPProxy on bind_ip:322 → printer:322,
same approach proxy mode uses.
Field-shape gotchas pinned in the bridge module's docstring and the
new test file:
- Real Bambu pushes use json.dumps(indent=4) wire format. Compact JSON
fails BambuStudio's Send pre-flight silently.
- net.info[*].ip is the FTP destination IP (little-endian uint32).
Without rewriting to the VP bind IP, the slicer FTPs straight to
the real printer.
- upgrade_state.sn rewritten to VP serial; AMS-hardware sn fields
(n3f/0.sn etc.) left alone.
- ipcam.rtsp_url passes through unchanged; BambuStudio overrides the
URL host with the device IP it bound on, so :322 lands on the VP's
TCPProxy.
- extrusion_cali_get must forward; answering it locally hides the
user's stored per-filament k-profiles.
Setup nuance for camera: the VP's access code must match the target
printer's because the slicer authenticates RTSPS with whatever access
code is in its profile. MQTT and FTP work either way.
Tested e2e with BambuStudio and OrcaSlicer against H2D (dual-nozzle,
AMS 2 Pro + AMS HT) and X1C across all three non-proxy modes — sync,
send, k-profile lookup, AMS configuration from slicer, and live camera
all work. Proxy mode is untouched: SlicerProxyManager owns its own
proxies and never instantiates SimpleMQTTServer or MQTTBridge.
25 new tests in backend/tests/unit/test_vp_mqtt_bridge.py cover lifecycle,
caching, identity / IP rewriting, wire format, slicer→printer routing,
and the LE-uint32 IP encoder against the real H2D capture value.
In non-proxy VP modes (Immediate / Review / Print Queue), the slicer now
sees real AMS / FTS / nozzle / k-profile state from the target printer
and streams the live camera — full slicer-as-remote functionality without
giving up Bambuddy's queue / archive / dispatch features.
Architecture (cached-as-base, single source of truth). The bridge caches
the latest real push_status and info.get_version response from Bambuddy's
existing per-printer MQTT subscription — no second session on the printer,
firmware in-flight budget unaffected (#1164). _send_status_report serves
a near-byte-identical copy of the cached push with only the upload-state-
machine fields overridden. Command responses (extrusion_cali_get, AMS
write acks, xcam) fan out raw — they carry sequence_ids the slicer is
waiting on. Slicer-issued commands forward to the printer except
project_file / gcode_file, which still terminate locally because the file
lives on Bambuddy. Camera is a raw TCPProxy on bind_ip:322 → printer:322,
same approach proxy mode uses.
Field-shape gotchas pinned in the bridge module's docstring and the
new test file:
- Real Bambu pushes use json.dumps(indent=4) wire format. Compact JSON
fails BambuStudio's Send pre-flight silently.
- net.info[*].ip is the FTP destination IP (little-endian uint32).
Without rewriting to the VP bind IP, the slicer FTPs straight to
the real printer.
- upgrade_state.sn rewritten to VP serial; AMS-hardware sn fields
(n3f/0.sn etc.) left alone.
- ipcam.rtsp_url passes through unchanged; BambuStudio overrides the
URL host with the device IP it bound on, so :322 lands on the VP's
TCPProxy.
- extrusion_cali_get must forward; answering it locally hides the
user's stored per-filament k-profiles.
Setup nuance for camera: the VP's access code must match the target
printer's because the slicer authenticates RTSPS with whatever access
code is in its profile. MQTT and FTP work either way.
Tested e2e with BambuStudio and OrcaSlicer against H2D (dual-nozzle,
AMS 2 Pro + AMS HT) and X1C across all three non-proxy modes — sync,
send, k-profile lookup, AMS configuration from slicer, and live camera
all work. Proxy mode is untouched: SlicerProxyManager owns its own
proxies and never instantiates SimpleMQTTServer or MQTTBridge.
25 new tests in backend/tests/unit/test_vp_mqtt_bridge.py cover lifecycle,
caching, identity / IP rewriting, wire format, slicer→printer routing,
and the LE-uint32 IP encoder against the real H2D capture value.
fix(spoolbuddy): gate display wake on stable scale reading
A noisy load cell that bounced ≥50 g around its midpoint kept the kiosk
screen permanently lit. The wake threshold check ran against last_wake_grams
which itself advanced to noisy values, so every bounce back across 50 g
re-fired display.wake(), keeping the FIFO reader pumping wlopm --on
faster than swayidle could trigger wlopm --off.
The fix gates wake on the scale's `stable` flag — only readings that
have settled within 2 g over a 1 s window count as a real spool
placement / removal. Unstable noise can't fire wake and can't advance
last_wake_grams, so the next genuine settled change is still measured
against the right baseline.
Three regression tests pin the contract: noisy ±60 g unstable readings
never wake, a settled >50 g jump wakes, a noise burst between two
settled readings doesn't poison last_wake_grams.
Pre-fix, _background_notifications in main.py:3434 built archive_data
with print_time_seconds (the slicer's pre-print estimate parsed from
the 3MF at archive creation), and notification_service.py:909 formatted
that field straight into the {{duration}} template variable. A print
cancelled 2 minutes into a 3-hour estimate notified "duration: 3h".
Compute actual_time_seconds from started_at/completed_at in main.py and
add it to archive_data. notification_service.py prefers it, falls back
to print_time_seconds when the actual can't be derived.
Also add "cancelled" to the list of statuses that get completed_at set
in update_archive_status — pre-fix only completed/failed/aborted got a
timestamp, so queue-UI cancellations had no actual elapsed to compute
from. Audited every completed_at consumer; none depend on NULL to mean
"cancelled" (status field already carries that signal), and the
statistics-totals aggregation gets more accurate too as a side effect.
3 new regression tests in TestNotificationVariableFallbacks pin the
{{duration}} variable contract (actual wins over estimate; estimate
falls in when actual is missing; "Unknown" when both absent).
go2rtc and several IP cameras still emit a warm-up / black frame on every
fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
slipped through intermittently for @nkm8's setup. His own bisect named
the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
single-frame endpoint that never returns the encoder's stale keyframe.
Adds an optional external_camera_snapshot_url column on printers. When
set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
on the override URL via plain HTTP GET, bypassing the warm-up dance.
Live view stays on the configured stream URL — only single-frame
captures use the override. Override is camera-type-agnostic. SSRF guard
applies (existing _sanitize_camera_url allowlist). Empty string treated
as unset.
Settings UI: new "Snapshot URL (optional)" input + Test button under
External Cameras, hidden for camera_type=snapshot since the live URL is
already a single-frame source. en + de fully translated; 6 other locales
seeded with English copy.
5 backend tests pin the routing contract; 3 frontend tests pin the
input + debounced PATCH. Documented in
bambuddy-wiki/docs/features/camera.md with the go2rtc example.
Vite's default base of '/' baked absolute asset URLs into the built
index.html (/assets/..., /manifest.json, /img/..., /sw-register.js),
so any path-prefixed reverse proxy (Traefik, nginx subpath, Cloudflare
Tunnel with path routing) served the SPA as a blank white page — the
browser requested assets from the host root and got HTML or text/plain
back, triggering MIME-mismatch errors on every stylesheet/script.
Set base: '' in vite.config.ts so the HTML transform emits relative
URLs everywhere. Update public/sw-register.js to register('sw.js')
(relative) so SW scope auto-pins to whatever subpath the document
loaded from.
Out of scope: API_BASE in client.ts is still absolute. The supported
HA embedding path remains Webpage panel + TRUSTED_FRAME_ORIGINS, not
HA Ingress (subpath-aware SPA bootstrapping has too many failure modes
around PWA scope, push subscriptions, and deep-link reloads to take on
in core). Documented explicitly in docker.md.
Reported by @Spegeli, follow-up to #1167.
paho's default max_inflight_messages=20 silently fills on Bambu's broker
after ~16-20 cumulative commands per session, leaving publish() returning
success while packets sit in paho's internal queue. force_reconnect heals
it because the inflight queue is per-session, but it costs one wasted
user action to trigger.
Lifting the ceiling to 1000 keeps QoS=1 untouched (deliberately chosen
for cross-model reliability — A1, P1S, X1C, H2D, P2S, X2D all need it)
and removes the inflight queue as the bottleneck without changing
wire-protocol behaviour. The 0.2.4b2 watchdog reconnect stays as
defence-in-depth.
Diagnosis credit: RosdasHH's QoS=1/0/2 bisect on #1164.
Bambuddy ships strict anti-clickjacking headers (X-Frame-Options:
SAMEORIGIN + CSP frame-ancestors 'none') by default. Internet-exposed
deployments need this; same-LAN HA Webpage-panel users do not, and
SAMEORIGIN is port-strict so HA on :8123 + Bambuddy on :8000 always
fails. azurusnova hit exactly that case.
Add TRUSTED_FRAME_ORIGINS env var (comma-separated scheme://host[:port]).
When set, drop X-Frame-Options entirely (modern browsers honor
frame-ancestors and the legacy ALLOW-FROM syntax is deprecated /
inconsistent across vendors) and emit "frame-ancestors 'self' <list>"
on every CSP-bearing route. Origin validation is strict: only http(s),
no paths, no query/fragment, no wildcards. Bad entries get a warning
and are dropped — startup never fails.
Default behaviour (no env var) is unchanged: X-Frame-Options:
SAMEORIGIN + frame-ancestors 'none', so existing Docker / bare-metal
deployments are not affected.
fix(oidc): use preferred_username/name claim for auto-created username
When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
filament requirements from the 3MF and writes
required_filament_types + filament_overrides + ams_mapping onto the
queue item, but the VP queue-mode write path skipped all of that.
Net effect: scheduler reached its model-only-matching fallback and
auto-dispatched onto whatever printer was free regardless of loaded
colour.
Extract the scheduler's existing _get_filament_requirements 3MF
parser into a shared helper so the VP path can reuse it. VP's
_add_to_print_queue now populates required_filament_types
unconditionally (cheap; helps the scheduler reject obvious type
mismatches) and writes filament_overrides with force_color_match:
true per consumed slot when a new per-VP queue_force_color_match
toggle is on. Default off to preserve current behaviour for
upgraders.
UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
mirroring the existing auto-dispatch toggle. i18n: en + de
translated, other 6 locales seeded with English copy.
Schema: one nullable column on virtual_printers
(queue_force_color_match BOOLEAN, default 0/FALSE).
11 new backend tests (8 for the extracted parser, 3 for the VP
write path) + 6 new frontend tests (toggle render gating, default
state, click posts queue_force_color_match in update body).
Existing scheduler tests pass against the refactored helper.
README, CHANGELOG, website features page, and wiki virtual-printer
page all updated.
turulix's headless slicing pipeline got cloud preset IDs from
/api/v1/cloud/settings (the /cloud/* gate from #1182 worked), but
slicing those IDs via POST /library/files/{id}/slice failed with
"no Bambu Cloud session is stored" — the slice route lives on a
different router, never saw the api_key_owner stash, and
_resolve_cloud fell through to the empty auth-disabled global
Settings token.
Add a permissive route-level dep that returns the API key's owner
when the key has the cloud scope and None otherwise (never raises),
so non-/cloud/* routes can opt in without breaking the local-preset
path. Wire it into POST /library/files/{id}/slice and
GET /slicer/presets (same root cause, would hit any UI proxied
through an API key). The route picks current_user or
api_key_cloud_owner before deriving user_id.
Auth gate's None-return for API keys is unchanged — keeping the
owner-resolution scoped to the routes that actually need a cloud
token prevents scope creep into routes that fence on
``current_user is None``.
@smandon flagged the 40×40 cover thumbnail as too small to recognise
the print and asked for a click-to-enlarge full preview. Enlarging
the thumbnail itself would shift the card grid layout, so keep the
small thumbnail and show a 384×384 hover popover with the full image
in ``object-contain`` rendering (so tall MakerWorld photos aren't
cropped to a square).
Why a portal: ProjectCard carries ``overflow-hidden`` (rounded
corners + color accent bar), so any in-tree popover gets clipped the
moment it extends past the card. Rendering via
``createPortal(..., document.body)`` escapes every ancestor clipping
context, and ``position: fixed`` with measurements from
``getBoundingClientRect()`` keeps the popover pinned next to the
thumbnail regardless of grid position. ``pointer-events-none`` on the
popover so it can't intercept hover and create a flicker loop;
``z-[100]`` so it stacks above sibling cards.
Edge handling: if the thumbnail is near the viewport's right edge the
popover flips to the LEFT side of the thumbnail; vertical position is
clamped so the popover never overflows the window top or bottom. The
thumbnail's own ``onClick`` is ``stopPropagation``'d so hovering the
popover area never accidentally triggers the parent card's
"open project" navigation.
Tests: 2 new ``ProjectsPage.test.tsx`` cases — mouseenter mounts the
popover at document.body level (not nested in the card subtree, which
would re-introduce the clipping bug, and the assertion catches that);
mouseleave unmounts it; the popover img points at the same
cover-image URL as the small thumbnail with ``object-contain``; cards
without a cover_image_filename never mount the portal-rendering
component.
@maugsburger surfaced four bugs against the original #1154 multi-colour
swatch work:
1. Editing an existing spool always opened with the Extra Colours field
blank, even when the COLOR preview banner above it was rendering
correctly from the saved data. ColorSection seeded its local
``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
mount time, but SpoolFormModal opens *before* its own useEffect
populates ``formData`` from the spool record — so by the time the
saved value landed, the input had already locked onto ''. The user
then had to retype the value before saving anything else.
2. Dual Color and Gradient produced the same diagonal blend
(``linear-gradient(135deg, A, B)``), so the two variants were
visually indistinguishable. The whole point of the Dual Color variant
is that the spool has two distinct bars on the reel — a smooth blend
defeats it.
3. Sparkle was almost invisible on card-sized swatches. The original
4-dot pattern (each ~1px) read fine on the inline 20×20 swatch but
disappeared on the 60-pixel inventory card banners — exactly where
the user actually identifies a spool.
4. Checkerboard cell density scaled with the swatch — the same 4-cell
pattern was either tiny squares on a small swatch or four huge
squares on a card-sized banner. The user couldn't tell a translucent
filament from a multi-colour one because the indicator changed shape.
Fix:
- ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
whenever the parent's ``formData.extra_colors`` changes via an
external update. ``commitExtraColors`` updates the ref before
calling ``updateField`` so live user typing is round-tripped without
the resync useEffect clobbering it.
- ``filamentSwatchHelpers.ts: buildColorLayer``: branch on
``effect_type``. ``dual-color`` and ``tri-color`` produce
``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
double-position stops (hard line, not blend) and equal-width
segments. ``gradient`` keeps the original 135° smooth blend. The
``multicolor`` conic-gradient path is untouched.
- ``filamentSwatchHelpers.ts: EFFECT_OVERLAYS.sparkle``: bumped from 4
dots to 13 flecks in mixed sizes (1 / 1.5 / 2 px) and varying
opacity (0.65 → 1.0) for a depth-of-field "metal flake" feel.
- ``filamentSwatchHelpers.ts: buildFilamentBackground``: now returns
``{ backgroundImage, backgroundSize }`` so per-layer sizes can be
applied — painted layers stay ``cover``, the checkerboard gets a
fixed 12px tile so cell density is constant regardless of element
size. Updated the three existing call sites (``InventoryPage`` group
banner + spool card, ``ColorSection`` preview) to spread the style
object directly. ``FilamentSwatch.tsx`` composes the same per-layer
sizing inline so its output stays in lockstep.
Tests: 8 new frontend cases pinning the four fixes — Dual/Tri Color
hard-split (3 tests + 1 regression guard that Dual ≠ Gradient for the
same stops), Sparkle prominence (≥ 10 distinct radial-gradient layers
in the rendered background), checkerboard density (last backgroundSize
layer is a fixed pixel value, not ``cover``), 4 hydration cases (fills
when formData arrives via parent update, resyncs when the spool
changes mid-form, doesn't clobber live user typing, clears when the
new spool has no extra_colors). Existing buildFilamentBackground tests
updated for the new return-object shape. Full frontend suite: 1610
passed; full backend suite: 3598 passed; no regressions.
@maugsburger surfaced two bugs against the original #1154 multi-colour
swatch work:
1. Editing an existing spool always opened with the Extra Colours field
blank, even when the COLOR preview banner above it was rendering
correctly from the saved data. ColorSection seeded its local
``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
mount time, but SpoolFormModal opens *before* its own useEffect
populates ``formData`` from the spool record — so by the time the
saved value landed, the input had already locked onto ''. The user
then had to retype the value before saving anything else.
2. Dual Color and Gradient produced the same diagonal blend
(``linear-gradient(135deg, A, B)``), so the two variants were
visually indistinguishable. The whole point of the Dual Color variant
is that the spool has two distinct bars on the reel — a smooth blend
defeats it.
Fix:
- ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
whenever the parent's ``formData.extra_colors`` changes via an
external update (modal opening with a spool, or switching to a
different spool mid-form). ``commitExtraColors`` updates the ref
before calling ``updateField`` so the user's own typing is round-
tripped without the resync useEffect clobbering it.
- ``filamentSwatchHelpers.ts``: ``buildColorLayer`` now branches on
``effect_type``. ``dual-color`` and ``tri-color`` produce
``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
double-position stops — the colour change is a hard vertical line
rather than a blend region — and equal-width segments across N stops.
``gradient`` keeps the original 135° smooth blend. The
``multicolor`` conic-gradient path is untouched.
Tests: 4 new ``FilamentSwatch.test.tsx`` cases pinning the hard-split
contract (Dual Color uses ``to right`` not ``135deg``; Tri Color
renders 3 equal hard-split bars; ``gradient`` keeps the smooth
diagonal; explicit regression guard that Dual Color and Gradient never
produce the same CSS string for the same stops). 4 new
``ColorSectionExtraColorsHydration.test.tsx`` cases pinning the input
hydration (fills when formData arrives via parent update, resyncs when
the spool changes mid-form, doesn't clobber live user typing, clears
when the new spool has no extra_colors). Full frontend suite: 1608
passed; full backend suite: 3598 passed; no regressions.
The minor "Sparkle could be more prominent / checkerboard denser"
feedback in the same comment is deferred to a separate cosmetic pass —
the reporter flagged it as finetuning.
@smandon retested the original #1152 fix on the latest daily and surfaced
two distinct holes:
1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.
2. The pending-uploads review card always showed the raw FTP filename,
while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
embedded title (or, with the toggle on ``filename``, the stripped stem).
Net effect: same upload showed two different names depending on which
view you were looking at, with no way for the toggle to flip both
views in lockstep.
Three changes:
- ``resolve_display_stem`` helper in ``services/archive.py`` strips
``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
every flow that produces a ``PrintArchive`` row.
- ``PendingUpload.metadata_print_name`` (new nullable column) is
populated at FTP-receive time by peeking at the 3MF's embedded title
via the existing ``ThreeMFParser``. Read happens once per upload —
the list endpoint then doesn't have to reopen each 3MF on every
render. Parser failures are swallowed and the column stays NULL;
the response model gracefully falls back to the stripped filename.
- ``PendingUploadResponse.display_name`` is a computed field that
mirrors ``archive_print``'s exact precedence — ``filename`` toggle
→ stripped stem; ``metadata`` toggle (default) → cached title or
stripped stem. The frontend's review card reads it (with
``upload.filename`` as a defensive fallback) and surfaces the raw
FTP filename via tooltip so users can still inspect what arrived.
Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
rows have NULL and degrade to filename-stem behaviour without any
operator action.
Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
canonical normalisation rules (Bambu Studio default name, mixed case,
dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
inputs); 6 integration tests in ``test_pending_upload_display_name.py``
pinning the response contract (default toggle uses metadata title when
present, falls back to stripped stem when absent, ``filename`` toggle
overrides metadata, ``filename`` toggle still strips the double suffix,
``GET /{id}`` exposes the same field, whitespace-only metadata behaves
like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
pinning the review card's render path (resolved name shown, fallback
to filename when display_name is empty, raw filename available via
tooltip). Full backend suite: 3598 passed; frontend build clean; no
regressions in any flow that previously processed ``.3mf`` /
``.gcode`` / non-3D filenames.
Tim (@turulix) is building a fully automated headless slicing pipeline
against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
resolve cloud_token per-user from User.cloud_token, but the auth gate
returned None for API-keyed requests, so the route fell back to the
global Settings-table token, which only carries a value in auth-disabled
deployments. Net effect on auth-enabled deployments: API keys reached
the gate just fine, then /cloud/filaments always saw user=None and
returned 401 / empty results — no path to read slicer presets or the
filament catalogue that a CLI workflow needs.
Make API keys carry an owner and route /cloud/* lookups through that
owner; gate the new capability behind an explicit opt-in scope so
existing automation doesn't gain cloud-read access on upgrade.
- APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
FK enforcement off — same pattern as the existing created_by_id
cleanup blocks.
- New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
the API-key owner stashed by a router-level gate. The auth gate itself
continues to return None for API keys so #1182's surface stays bounded
to /cloud/* — without that bound, any route that fences API keys via
`if current_user is None: raise 403` (e.g. long-lived-token
management) would silently start accepting them.
- The /cloud/* router-level dep enforces three independent fences for
API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
recreate copy), can_access_cloud=True (otherwise 403), and owner has
cloud_token (existing fence, unchanged). Two extra one-shot fence
errors at create/update time refuse can_access_cloud=True when auth
is disabled or the key is ownerless.
- Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
"Legacy" badge on ownerless rows; create form gains an "Allow cloud
access" toggle, default off. New i18n keys in all 8 locales (en + de
fully translated, others seeded with English fallbacks pending native
translation — matches the project's flow for newly-added features).
Migration: two idempotent ALTER TABLE statements + an index on user_id
for the auth gate's owner→keys lookup. Postgres-safe.
Tests: 9 backend integration tests in test_api_key_cloud_access.py
covering creation flags, the three /cloud/* fences, JWT no-op, and
deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
matrix and the create-form contract; 5 daemon unit tests for the
related SpoolBuddy ssh-key sync work that landed in the same branch.
Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
regressions.
Permission semantics for existing keys: keys created before this
release become "legacy" and are rejected at /cloud/* with the recreate
message. Every other endpoint they were used against — queue, status,
control — is untouched.
Bambuddy's SSH keypair under <DATA_DIR>/spoolbuddy/ssh/ regenerates whenever
the data dir is recreated (volume remount, container recreate, fresh deploy).
The daemon previously only fetched the pubkey at registration, so any
rotation after a successful boot left ~/.ssh/authorized_keys pointing at
a stale public half — every Update click then failed with "Connection
closed by authenticating user spoolbuddy [preauth]" until the daemon was
restarted by hand. Each prior registration also appended a fresh entry
without pruning, accumulating stale Bambuddy-tagged keys indefinitely.
- HeartbeatResponse now carries ssh_public_key; the heartbeat route reads
it via the same try/except shape as the register route so a missing or
unreadable backend key doesn't break telemetry.
- _deploy_ssh_key() strips lines tagged bambuddy-spoolbuddy and writes
the current key once. No-op when already in sync (no mtime churn on
every heartbeat). User-managed entries are preserved.
- Daemon heartbeat handler calls _deploy_ssh_key when the response
carries a key, so rotations propagate within one heartbeat instead
of requiring a service restart.
Tests: 5 unit (creates-when-missing, replace-stale-pileup, preserve-user-keys,
idempotent, swallows-write-errors) + 2 backend integration (heartbeat carries
the key; backend key-read failure leaves ssh_public_key None but the
heartbeat still 200s).
_capture_mjpeg_frame returned the very first JPEG it found in the
bytes stream, but many MJPEG sources — go2rtc most notably, and
several IP cameras — emit a warm-up frame on the byte that follows
connection accept: usually the last keyframe held in the encoder,
typically black or stale until the encoder catches up to live
content. Subsequent frames on the same connection are fine.
Result: every code path that opened a fresh capture (snapshot UX,
finish photos in notifications, timelapse, plate-detection CV,
Obico ML inference, Settings → Test button) returned a black image
on go2rtc-fronted cameras.
Reporter's support log showed every black frame was 11095 bytes
(pure-black 1280x720 JPEG ≈ 10-15 KB) while real-content frames
from the same source were 30-45 KB.
Fix:
- Read past the first complete JPEG, return the second.
- Fall back to the first frame if the connection closes / times out /
hits the 5 MB buffer cap before a second arrives. Without that
fallback, slow / single-frame streams that pre-fix returned the
warm-up would post-fix return None — a regression. The fallback
guarantees we never do worse than current behaviour.
- Inner while-loop now drains every complete frame already in the
buffer before pulling the next chunk so high-FPS sources that
pack multiple frames per chunk are handled correctly.
Untouched: snapshot / rtsp / usb capture paths, generate_mjpeg_stream
(live-view fan-out).
7 new regression tests in TestCaptureMjpegFrameWarmupSkip cover
two-frames-in-two-chunks, two-frames-in-one-chunk, partial-frame-
split-across-chunks, single-frame fallback, timeout fallback, zero-
frame stream returns None, non-200 returns None.
Latency penalty: at most one frame interval (typically 50 ms - 1 s
on a steady stream), well within every caller's tolerance window.