The original #1322 fix widened empty-slot detection to (state == 11 OR
tray_type != ""), which closed the configured-slot reconfig case but
didn't help the "Reset Slot on printer screen with spool still inserted"
flow. On these firmwares the AMS reports state=3, tray_type="" after a
Reset Slot regardless of whether a spool is physically present, so the
empty-detection still decided "empty", skipped MQTT, marked pending —
and on_ams_change replay never re-fired because the AMS never reported
any state change either.
RosdasHH traced the path: tray_state=3 falls into the else: branch,
slot_is_empty = not (fingerprint_type and fingerprint_type.strip()),
fingerprint_type is "", so slot_is_empty=True, MQTT is skipped, and the
slot stays unconfigured forever. He verified empirically that removing
the gate makes the firmware accept the push when a spool is physically
present.
Drop the tray_type fallback entirely. Only state in {9, 10} (firmware's
explicit "no spool" / "spool present but no feed") short-circuits the
MQTT publish. Every other state — including 3 (default-idle, ambiguous)
and missing-state (older firmwares) — attempts the publish. Bambu's
"firmware silently drops on empty slots" behavior makes the worst case
a no-op for a truly-empty slot, and on_ams_change replay still serves
as the safety net for state=9/10 slots whose spools get inserted later.
pending_config is now (slot_is_definitely_empty OR not configured) so a
printer-offline / no-client publish failure correctly flags the
assignment for replay instead of falsely showing "configured".
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
misleading error "Firmware file is not available from Bambu Lab" while the
logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
on the reporter's network, and the buildId was cached in memory only, so a
single 403 broke download-URL resolution for the rest of the session.
- Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
so the request stops tripping Cloudflare's "bare scraper" signal.
- Persist the buildId to <data_dir>/firmware/build_id.json so a transient
403 or a backend restart can't wipe a previously-valid buildId.
- Add a download_page_unreachable flag and use it in the prepare-update flow
to render an honest error ("page unreachable from this network — try later
or download manually from bambulab.com") instead of implying Bambu doesn't
have the file.
- Retry the per-model JSON once when a cached buildId returns 404 (page
rebuild), give up gracefully on 403 without churning.
Reported by @IndividualGhost1905: printing the same model ten times and
then deleting nine archive entries (to keep the file list tidy) silently
rewound the totals on the Statistics page — total prints, filament,
cost, and per-print energy all dropped back to whatever the surviving
row contributed, as if the other nine prints had never happened.
Root cause: every metric in get_archive_stats is recomputed live from
PrintArchive rows via COUNT / SUM, so removing a row removes its
contribution. Energy in the default "Total" mode already survived
deletion because it reads the smart-plug lifetime counters — that's
the architectural shape we now generalise to the rest.
Fix: soft delete with opt-in hard purge.
Backend:
- New nullable, indexed deleted_at column on print_archives, dialect-
conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
- ArchiveService.soft_delete_archive flips deleted_at and removes the
files from disk (still reclaims storage); the path-safety checks were
extracted into _resolve_archive_dir_for_delete so soft and hard delete
share the rules.
- DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
- Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
detection (so a 1-live + 9-soft-deleted group no longer marks the
survivor as a duplicate), and ArchiveComparisonService's "similar"
suggestions. GET /stats and GET /slim deliberately do NOT filter so
Quick Stats and the dashboard widgets keep counting deleted prints.
Frontend:
- ConfirmModal gained an optional children slot.
- ArchivesPage (both card and detail views) own a per-instance
deletePurgeStats boolean and render an opt-in checkbox in the delete
dialog; resets to off on every close so the destructive option is
never sticky.
- api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
when the box is ticked.
- One new i18n key archives.modal.deletePurgeStats added across all 8
locales (full German, English fallbacks elsewhere).
* feat(auth): proxy OIDC provider icons server-side (#1333)
Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
The assign flow was sending slicer-invalid values for tray_info_idx and an
empty setting_id, which the slicer rejected — slot detail modal showed
empty fields. With a stored k-profile the realignment path masked the
issue; without one, garbage hit MQTT.
Backend (apply_spool_to_slot_via_mqtt):
- Discard tray_info_idx values that aren't real preset IDs: literal
material names ("PLA", "PETG-CF") AND PFUS-prefix cloud setting_ids
(valid as setting_id but rejected as tray_info_idx). Same check applied
to current_tray_info_idx so stale slot values don't get reused as
garbage.
- Local-preset path now reads the printer-recognized filament_id from
the preset's setting JSON (e.g. P4d64437) instead of falling through
to a generic material ID.
- Derive setting_id from filament_id_to_setting_id when empty so
ams_filament_setting always carries a matched pair.
- No stored k-profile: always send cali_idx=-1 (Default K), regardless
of the live cali_idx on the slot. The live value belongs to whatever
filament was there before, so reusing it would apply the wrong K to
the new spool.
Frontend (spool-form/utils.ts):
- Local preset options use String(preset.id) as the unique code instead
of preset.filament_type — every PLA local preset was collapsing onto
the same "PLA" code, so picking any of them saved slicer_filament=
"PLA" and lost the specific preset identity.
Spoolman counterpart in spoolman_inventory.py mirrors the cali_idx=-1
reset.
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
A1 Mini BMCU (01.07.02.00) and P1S Standard AMS (00.00.06.75) always
report tray.state=3, even for loaded configured slots. The empty-slot
detection preferred state==11 with tray_type as a fallback only when
state was absent, so every assign was classified as empty and MQTT
was skipped — both for "assign to unconfigured slot" and the secondary
"PETG over a PLA-configured slot won't reconfigure" symptom.
Empty-slot detection in the assign route and the on_ams_change replay
now treats the slot as loaded when EITHER state==11 OR tray_type is
non-empty. Reset-slot case (state=11 + tray_type="") still works
through the first clause; configured slots on these firmwares now
work through the second.
Truly empty unconfigured slots (state!=11 + tray_type="") still hit
the pending-config path, and the deferred publish now fires when the
user later configures the slot in Bambu Studio (tray_type goes
non-empty), since the replay uses the same disjunction.
Bambuddy's external SpoolmanDB lookup in `_find_or_create_filament` matched
on material+color only, with no manufacturer filter. Because SpoolmanDB is a
multi-vendor catalog and entries are roughly ID-sorted, the first hit for
any common combination is almost always a competitor — `bambulab_pla_black_1000_175_n`
is the 15th entry for PLA + `#000000`. Bambu Lab RFID spools were being
labeled with competitor product names (`3DJAKE Black`, `3DXTECH™ Black`, etc).
Restrict the external-library loop to entries whose manufacturer is
`"Bambu Lab"` (with `id.startswith("bambulab_")` as a defensive fallback
for schema drift). When multiple Bambu Lab candidates exist, prefer the
entry whose `name` equals the AMS `tray_sub_brands` so `"PLA Basic"` wins
over generic `"Black"` when both are present. Forward `density` from the
chosen external entry so it is no longer overwritten by the PLA-default
1.24 in `create_filament`.
Six unit tests added: internal short-circuit preserved, non-Bambu external
entries skipped, PLA Basic > generic PLA tiebreaker, no-match fallback,
id-prefix defensive fallback, density propagation.
Fixes#1309
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
control sent the nozzle straight into the build plate. Reporter
triggered it with the 50 mm step and crashed their nozzle.
Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
gap). The frontend maps "Up" to negative distance with that convention
in mind.
A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
firmware uses standard cartesian Z (Z+ = toolhead up). On those models
G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
classification at the bed-jog code path, so every printer got the same
X1-convention G-code.
Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
already-defined A1_MODELS frozenset which covers display names and
internal codes N1 / N2S). The bed-jog route now inverts the signed
distance before emitting G-code when the printer model is in that set,
so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
regardless of which physical part moves. Frontend untouched, single
source of truth lives in the backend, keyed off the Printer.model
column. Route Query description and docstring updated to spell out the
new contract: distance is the gap adjustment, not the raw Z value.
Editing a spool's color name on Spoolman-backed inventory appeared to
accept the new value but the inventory list column and the next edit
showed it back to the subtype. Three layers stacked to produce this:
1. find_or_create_filament matches by material/name/color_hex/vendor —
color_name is intentionally not part of the match key, but on a
match it returned the existing filament's id unchanged, silently
dropping the new value.
2. The read helper falls back to subtype when filament.color_name is
empty (kept on purpose: without it Spoolman installs that don't
fill the field render every spool as "Unknown color").
3. The edit form prefilled color_name from spool.color_name — which
on those installs was the synth value. Changing subtype but not
color_name silently round-tripped the OLD subtype back to Spoolman
as if it were a real user-set color_name.
Fixes:
- find_or_create_filament now patches the matched filament's
color_name via the existing patch_filament wrapper when the request
differs. Parameter convention: None = don't touch, "" = explicit
clear, any other string = set/update. A patch failure is logged but
does not block the match.
- The PATCH route uses model_fields_set to distinguish "field omitted"
from "field explicitly set to null" (mirrors the existing
storage_location pattern at the same site).
- The map helper returns color_name_is_synthesized: bool. The edit
form leaves the input blank when true, so the user sees the real
stored state and can't accidentally round-trip the synth value back.
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285)
Three User-FK tables (user_oidc_links, user_totp, user_otp_codes)
declare ON DELETE CASCADE in their models, but SQLite ships with
PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored
for APIKey in PR #1182). Without explicit DELETEs, deleting a user
on SQLite leaves orphan rows behind:
Issue #1312 follow-up. Investigation traced the "Name cannot be empty"
report to a sidecar image pre-dating the /profiles/bundle endpoint
addition. Two changes so the next occurrence is self-diagnosable from
the support bundle without a manual curl.
Backend: new _fetch_slicer_health(url) helper does a 2s GET on /health,
walks every non-dataPath key under checks looking for a version field
(the wrapper labels both sidecars as checks.orcaslicer regardless of
which CLI is bundled). _collect_slicer_api_info now exposes
bambu_studio_version and orcaslicer_version. Strips trailing slash
before appending /health to avoid double-slash 404s.
Docs: bambuddy-wiki/docs/features/slicer-api.md gains a Quick Start
callout that branch-built sidecars don't auto-update, a corrected
/health troubleshooting entry (both "unknown" version and "orcaslicer"
field name on bambu-studio-api are cosmetic wrapper bugs, not stale-
image indicators), a new "Name cannot be empty" troubleshooting entry,
and an Updating section that requires --no-cache --pull together
(BuildKit caches the git context separately from layers, so --no-cache
alone silently reuses the old checkout).
The route mapped sidecar 4xx/5xx to HTTPException with detail but never
logged it. Reporters seeing a 400 toast were giving us only the status
code, not the reason, and the access-log line was all that landed in
support bundles.
Add WARNING-level logs on each error branch (400 SlicerInputError,
503 SlicerApiUnavailableError, 502 SlicerApiError) with the sidecar's
own message + the filename / byte count / configured URL. Next reporter
on this code path produces a support bundle that contains the answer.
The settings-table passthrough auto-captured everything in `settings` (with
sensitive-key redaction), but features storing config in dedicated tables
were invisible. Triaging recent OIDC / 2FA / group bugs and the X1C slicer
investigation needed data that wasn't in the bundle.
New blocks in _collect_support_info:
- auth: OIDC providers (cleartext names, no secrets), TOTP / OTP /
API-key / long-lived-token / group counts
- library: file / folder / external / trash / makerworld totals
- inventory: spool + k-profile counts
- queue: pending count, oldest pending age
- maintenance: items total + enabled
- integrations.github_backup: providers used + recent failures
- integrations.slicer_api: enabled, URL source, reachability ping
- per-printer obico_enabled flag
Plus three smaller fixes caught testing against a real bundle:
- mqtt_broker no longer leaks (broker keyword added)
- virtual_printer_tailscale_auth_key no longer leaks (auth_key keyword
+ tskey- value-prefix safety net for future Tailscale settings)
- slicer-API reachability check now mirrors the route's three-level URL
precedence (DB → env var → default), instead of only looking at the
DB setting. Previously returned null for every installation running
the sidecar via env var or default port — i.e. most of them.
Restarting Bambuddy mid-print misfired the plate-check + archive flow.
The is_new_print guard treated _previous_gcode_state=None → RUNNING as
a transition, but None just means we haven't seen any prior state yet —
catch-up from a printer that was already running, not a fresh start.
Add `_previous_gcode_state is not None` to the guard. _was_running still
flips on unconditionally, so completion detection is unchanged. 3 tests
that asserted the buggy behavior now seed an explicit prior state; new
regression test pins the contract for the reporter's exact scenario.
External scan hung on a 1200-subdir NAS because (1) every STL crashed
with TypeError ('str / str') inside generate_stl_thumbnail and (2)
thumbnail generation ran synchronously per file, so the FE timed out
before db.commit() and nothing was persisted.
stl_thumbnail.py now coerces inputs to Path defensively, and
scan_external_folder defers STL thumbnail generation to a background
asyncio task that opens its own session and processes each file
post-commit. Subdirs appear in the sidebar immediately; thumbnails
backfill over the next seconds/minutes.
The Clear Plate button (and 4 other features on the Printers page) read
their state from /settings, which requires SETTINGS_READ. Granting that
permission also adds the Settings nav item and leaks SMTP/LDAP/MQTT
credentials — exactly what users were trying to avoid by giving an
operator only printers:clear_plate.
New /settings/ui-preferences endpoint returns a curated, opt-in subset
of non-sensitive fields. Matches the existing /default-sidebar-order
precedent. PrintersPage switched to the new endpoint; admin pages still
use /settings for full access.
_sync_ldap_user used to replace user.groups entirely on every login,
wiping manual admin assignments to groups outside the LDAP mapping.
Now partitions on LDAP-managed group names (mapping values + default
group) and only rebuilds that slice from LDAP truth. Manual assignments
to non-managed groups are preserved; revocation in LDAP still
propagates for managed groups.
The column existed on the Spool ORM model but was missing from
SpoolBase, SpoolUpdate, and SpoolResponse. Pydantic silently
dropped writes and reads omitted the field, so the inventory
table always showed "—" in the Storage Location column even
after saving. Adding the field to the two schemas is enough —
the update route already uses model_dump + setattr.
Real-printer prints broadcast archive_created from the MQTT print_start
handler, which the Archives page listens for to invalidate its query
cache. The VP file-receive paths created the archive in the DB but
never emitted the event, so the new card only appeared after a tab
switch triggered refetch-on-focus.
Added a small _broadcast_archive_created helper on VirtualPrinterInstance
and called it from _archive_file (immediate mode) and _add_to_print_queue
(queue mode). Review mode is unaffected — it creates a PendingUpload,
not a PrintArchive. Broadcast errors are swallowed at debug level so a
transient WebSocket issue can't break the file-receive flow.
Bambuddy's VP supports two slicer flows: Send (file upload only — what
queue/immediate/review modes are designed for) and Print (file upload
+ start-print, intended for proxy mode). When a user clicks Print
against a non-proxy mode the VP must still respond gracefully — the
file is fine to receive, just the start-print never happens. Instead
the slicer wedged at "Downloading...(0%)" and blocked the next
dispatch with "The printer is busy with another print job".
Cause: on_file_received transitioned gcode_state PREPARE -> IDLE
directly. Print-flow slicers watch the state cycle and only release
their in-flight-job lock on PREPARE -> ... -> FINISH (or FAILED).
PREPARE -> IDLE looks like "printer abandoned my job" and keeps the
prior job pinned in the slicer's memory.
Fix: transition PREPARE -> FINISH with prepare_percent=100. The 1-Hz
periodic status push broadcasts the new state to every connected
slicer within a second. Send-flow slicers don't watch this state so
the change is a no-op for them; Print-flow slicers see the FINISH
they were waiting for and unwedge.
ams_set_filament_setting and reset_ams_slot encoded the single-external
case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0"
comment was a misread of the printer's response (which echoes the local
slot position), not the request semantics.
Captured BambuStudio -> X1C exchange shows the request encoding is
{ams_id: 255, tray_id: 254, slot_id: 0} (global tray index in tray_id).
The previous code's tray_id: 0 is what the P1S in #1279 rejects with
result: "fail", which silently broke external-spool filament selection
on every Bambu printer with no AMS or external spool in active use.
Dual-external (H2D) branch was not in the captured exchange and is
explicitly pinned at the legacy encoding pending a Studio -> H2D capture.
scan_timelapse's Strategy 2 matched filename timestamps against both
archive.started_at and archive.completed_at across seven hypothesised tz
offsets. The filename is always print-START time, so the end-time branch
was a semantic mistake — and the dense offset set [0, +-1, +-7, +-8]
let an unrelated video coincidentally land within minutes of any later
archive at some offset.
Extract Strategy 2 into _match_timelapse_by_timestamp(): compare only
against start time, and refuse to auto-pick when the next-best different
video is within a 15-minute ambiguity margin. The route then returns
available_files and the frontend's existing manual-selection dialog
takes over — which is the fallback the reporter explicitly asked for.
Surfaces in LAN-Only mode where the printer can't reach NTP and its
clock drifts (e.g. P2S filenames in CST while server is in UTC, the
8h offset that exposed this bug).
BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat
ams_mapping array — a convention already documented in
bambu_mqtt.py:start_print(). The spoolman tracking helper was treating
-1 as "unmapped, use position-based default", which mapped slot_id=1
to AMS tray 0 and credited external-spool prints to whatever Spoolman
spool happened to be linked to AMS slot 0. The reporter's TPU prints
on an H2S were credited to a PLA spool for ~49g over 4 prints before
being noticed (regression of #853).
When slot_to_tray[slot_id-1] == -1 and ams_trays contains 254/255,
return the external tray ID directly. Prefers 254 over 255 (matches
single-nozzle tray_now reporting + the vir_slot id=255->254 remap in
bambu_mqtt.py:864). Legacy fall-through preserved for callers that
don't pass ams_trays.
Root cause investigation and patch by @ojimpo.
Prints sent from a slicer to a VP in print_queue mode arrived in the
queue with bed_levelling / flow_cali / vibration_cali / layer_inspect /
timelapse set to the SQLAlchemy column defaults, ignoring the user's
workflow page settings entirely. The manual POST /print-queue endpoint
reads these from the request body (frontend pulls them from settings
before submitting), but manager._add_to_print_queue constructed the
PrintQueueItem without touching any of those fields.
Read default_bed_levelling and the other four settings via get_setting
and pass them explicitly. _bool_setting helper handles the None ->
AppSettings default fallback.
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single
tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7
and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died
with `CHECK constraint failed: ck_ams_id_range`. The internal
spool_assignment table has no such constraint and works fine.
Widen the formula to (0-7) OR (128-191) OR 255 in the model, the
CREATE TABLE DDL, and an idempotent in-place migration for existing
installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula
in sqlite_master, rebuild via _v2 rename pattern).
The MJPEG fan-out broadcaster from #1089 only solved viewer-side
concurrency. Obico polling (every 5s) and the manual /camera/snapshot
endpoint kept opening their own fresh RTSP sockets, which X1/H2/P2
firmwares tolerated but X2D firmware 01.01.00.00 enforces strict
single-connection on — every poll kicked the live stream.
Add try_get_active_buffered_frame(printer_id): returns the broadcaster's
last buffered frame when a viewer is connected, None otherwise. Obico
and /camera/snapshot consult it before opening a fresh socket. When no
viewer is active they fall through to the existing fresh-capture path.
plate_detection and layer_timelapse intentionally not converted.
Spoolman had two mutually-exclusive weight paths gated on the
`disable_weight_sync` flag. The default (False) used AMS remain%
x tray_weight auto-sync, which silently dropped non-BL spools
because the AMS doesn't report tray_weight without RFID. The
inventory_remaining fallback would have covered it, but the
spool_assignment table it reads from is wiped on Spoolman
activation, so non-BL spools got no weight updates at all.
Match the internal Filament Inventory: per-print tracking always
runs, AMS auto-sync no longer writes remaining_weight (it still
maintains spool metadata and slot assignments). The setting
becomes a no-op; left in the schema and UI for backwards compat.
- store_print_data: drop the disable_weight_sync early return
- sync_ams_tray callsites in main.py + routes/spoolman.py: force
disable_weight_sync=True so weight is never written by AMS sync
- new regression test confirming tracking runs with flag=false
The AMS remain% delta path charged every tray with a delta, not just
trays involved in the print. Swapping a spool in an UNUSED slot mid-
print made the slot report remain=0 (fresh spool, no tag), versus a
print-start snapshot of 100%, so the originally-assigned spool got
charged the full 1000g.
Build print_used_keys from ams_mapping, tray_change_log, and
tray_now_at_start, and skip fallback for trays not in that set.
Legacy "scan every tray" behavior preserved when none of the three
signals are present.
The patch on printer_state_to_dict raced against the broadcast coroutine
under pytest-xdist's parallel workers. Mostly won locally, lost
occasionally on CI — surfaced first as AttributeError on .kprofiles,
then (after _fake_state was hardened) as a dict-content mismatch
between the patched return value and the real 36-key dict.
Fix: stop patching printer_state_to_dict; let it run for real against
the complete _fake_state stub. Assertions now check the broadcast fired
with the right printer_id and a dict containing awaiting_plate_clear,
not the exact dict shape — that decouples the test from
printer_state_to_dict's evolving body.
The two TestBroadcastStatusChange / TestEndToEndUnderRunningLoop tests
patch printer_state_to_dict to return a fixed dict, but on parallel
xdist runners (CI's pytest -n 30) the patch occasionally didn't catch
the call and the real function ran against the 4-field SimpleNamespace
fake — first attr access (.kprofiles) AttributeError'd, swallowed by
the try/except in _broadcast_status_change, send_status never awaited,
the assertion failed.
Filled _fake_state with every attribute the real printer_state_to_dict
reads (iterables empty, scalars None, stg_cur=0 for the int comparison
in get_derived_status_name). Test now passes whether or not the patch
lands.
- backend/app/api/routes/spoolbuddy.py: re-formatted via ruff (lambda
conditional wrapped in parens — the formatter check fires when the
expression spans multiple lines without grouping)
- frontend/src/__tests__/pages/SettingsPage.test.tsx: per-test timeout
raised to 15s for the external_camera_snapshot_url PATCH test; the
default 5s was tight enough on GitHub Actions runners that user.type()
of the 49-char URL + 800ms debounce occasionally blew past it
Severity: Warning ×4
Issue: "Probable insecure usage of temp file/directory" — /tmp/<filename> literals used as synthetic DB field values in two integration tests
Status: Fixed
────────────────────────────────────────
Tool: CodeQL Python / JS
Severity: Pending
Issue: Still running on the head SHA
Status: —
────────────────────────────────────────
Tool: Trivy container scan
Severity: Pending
Issue: Still running
Status: —
────────────────────────────────────────
Tool: Bandit (Python Security Analysis)
Severity: Pass
Issue: The separate Bandit run on the changes already passes
Status: ✓
feat(#1239): first cut at Gitea backups silently failing after 1st run
feat(#1239): Added Token Scope for Forgejo edge case. Also included: test coverage for fixes
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
indicating which build plate the print was sliced for (Cool /
Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
with the full plate name in the hover tooltip. Closes the gap where
users had to remember which plate matched a re-print or open the
source 3MF in a slicer just to read the bed setting.
Card row also unified: archives with a real Bambuddy-printer
association used to render "H2D-1 GCODE ..." while slicer-only uploads
rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
Drop the "Sliced for " prefix so both render as a uniform
"<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
regardless of provenance.
Backend: new bed_type column on print_archives (idempotent ALTER TABLE
migration; SQLite + Postgres safe). Populated from curr_bed_type in
Metadata/slice_info.config (per-plate, authoritative -- that's what
got sent to the printer for the exported plate) with a fallback to
project_settings.config for older 3MF shapes. Wired through both
archive_to_response() (the hand-rolled dict converter that bypasses
from_attributes -- easy to miss) and the /rescan endpoint, so old
archives can be re-parsed via the existing per-archive Rescan button.
Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
supported) re-opens every NULL archive's 3MF on disk to populate the
column. Auto-loads .env from project root before importing backend
modules (config.py reads DATABASE_URL from os.environ at import time,
not from pydantic-settings at Settings() time) and prints the resolved
DB URL with credentials redacted, so operators can confirm they're
hitting the intended database -- Postgres or SQLite.
Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
under /img/ because that path is already statically mounted; a
toplevel /bed-icons/ tried first hit the SPA catch-all and returned
index.html as text/html. New utils/bedType.ts maps slicer strings
case-insensitively, covering both Bambu Studio and OrcaSlicer naming
variants for the same physical plate. Unmapped or NULL bed_type
simply omits the icon, so cards stay clean for pre-feature archives.
Three enhancements requested by @oliboehm after the V1 label-printing
ship in #809:
- New box_40x30 single-label template (common DK/Brother roll size,
good for filament-bag and storage-bin labels). Routes through the
existing roomy layout since height >= 20 mm.
- Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on
every label - useful when several near-identical material/colour
spools sit next to each other and the swatch alone isn't enough to
tell them apart. Skipped silently when rgba is None or malformed.
- Brand line bumped to Helvetica-Bold (was regular) and a couple of
points larger on both layouts so it reads cleanly at arm's length.
Wired through the SpoolLabelTemplate union, the modal's
TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n
key in all 8 locales (native translations for de/fr/it/ja/pt-BR/
zh-CN/zh-TW). Modal regression test widened from 4 to 5 template
buttons. Three new renderer tests pin the hex-code render, the
hex-code skip on invalid rgba, and the bold-brand font reference.
The kiosk's Settings -> Update Daemon button returned "API keys cannot
be used for administrative operations" because POST /spoolbuddy/devices/
{id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
Every kiosk-side request tripped the deny-list before the API key's
scope set (Read / Print Queue / Control / Legacy) was even consulted.
Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
(Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
in that audit on the reasoning "replaces the daemon binary, different
threat surface" — but that's wrong: restart_daemon already replaces
the running daemon process, so daemon-replacement is not a step up in
blast radius. The SSH update is also strictly scoped to the one device
the operator physically controls (git fetch + pip install + systemctl
restart on that host) — same threat profile as the system commands
already running on INVENTORY_UPDATE.
Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
(calibration/tare, display, cancel-write, system/command,
system/command-result, update-status). The main Bambuddy in-app updater
at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
the Bambuddy host and is correctly fenced behind the deny-list.
Reported by @1000Delta. The printer file download (and three sibling
endpoints) raised UnicodeEncodeError: 'latin-1' codec can't encode
characters... on any filename outside U+0000..U+00FF (Chinese,
Japanese, Arabic, accented Latin), because the route pushed `filename`
straight into Content-Disposition: attachment; filename="...".
Starlette/uvicorn encodes response headers as latin-1, so the assignment
crashed at write-time.
New backend/app/utils/http.py::build_content_disposition emits both an
ASCII-stripped legacy filename="..." fallback and an RFC 5987
filename*=UTF-8''<percent-encoded> parameter. Every modern browser
prefers the *= form, so the original Unicode filename round-trips
through Save-As intact.
Same shape was latent in three siblings and fixed in the same PR
(no deferred follow-ups): archive QR endpoint (archive.print_name
from 3MF metadata), project ZIP export (project.name — the existing
isalnum() sanitiser passes non-ASCII through), and the PDF label
streamer (latent today, callers ASCII-only but the helper hardens it).
Seven intertwined SpoolBuddy + Spoolman bugs from feature/spoolman-inventory-ui
testing, fixed as one batch since they all live on the same path:
1. /spoolbuddy/nfc/tag-scanned always tried local DB first and only
consulted Spoolman as a fallback on local-DB miss. A stale local
row silently won over the authoritative Spoolman record. Now gates
on _get_spoolman_client_or_none() so the route uses Spoolman
exclusively when enabled, local exclusively otherwise.
2. Dashboard "Assign to AMS" button was a no-op when the matched
spool wasn't yet in the cached spools query (newly created in
Spoolman, or unarchived after page load). The card rendered via
`displayedSpool ?? sbState.matchedSpool` fallback but the modal's
stricter guard silently failed to mount. New effectiveModalSpool
synthesises an InventorySpool-shaped object from the WebSocket-
delivered MatchedSpool (9-field subset, sufficient for the modal
since it only needs `id` to route the assign API).
3. AMS-page slot picker explicitly returned null for the
assign/unassign branch when a slot had a SpoolmanSlotAssignment
but no tag-linked spool — only Configure stayed visible. Now
resolves the assignment via spoolmanSlotAssignmentsAll +
spoolmanInventorySpoolsCache, renders a "Assigned spool" info
card, and exposes an Unassign button wired to a new
unassignSpoolmanSlotMutation (DELETE
/spoolman/inventory/slot-assignments/<id>).
4. LinkSpoolModal showed "Unknown color" for every Spoolman spool
because Spoolman doesn't standardise color_name — most installs
only populate color_hex and filament.name (which often carries
the colour, e.g. "PLA Basic Red"). _map_spoolman_spool now falls
back to the filament's subtype (filament name minus material
prefix) when color_name is empty, so spools are visually
distinguishable. The NFC write-tag warning specifically checks
the raw filament.color_name (not the mapped value) so the
"tag encodes empty color name" warning still fires on installs
that genuinely lack the field.
5. Writing a tag for spool B didn't clear the same tag from spool A,
so a single NFC UID could map to two spools at once and
find_spool_by_tag returned whichever came first in the cached
list. nfc_write_result now searches Spoolman for any other spool
currently bound to the target UID and clears its extra.tag
(best-effort: cleanup failure logs a warning but doesn't block
the write, since the chip is already written).
6. The kiosk display held stale spoolmanSlotAssignments cache
permanently because a long-running browser window has no
focus/remount triggers to fire a refetch. Adds
refetchInterval: 3_000 so the kiosk picks up changes from another
client (Bambuddy main UI, direct Spoolman edit) within seconds.
7. Kiosk QuickMenu System buttons (Restart Daemon / Restart Browser /
Reboot / Shutdown) all 403'd silently. /system/command was gated
on Permission.SETTINGS_UPDATE (T-Gap 2 from a prior security
audit) but every other kiosk-scoped device route uses
INVENTORY_UPDATE; the kiosk operator's session has the latter,
not the former. Lowered to INVENTORY_UPDATE so operators can
recover the kiosk from the kiosk. Risk is bounded — only the 4
named commands are accepted (no RCE), reboot/shutdown require
physical-access recovery anyway, the same operator already
controls printers + weighs spools. /update keeps SETTINGS_UPDATE
because it can replace the daemon binary.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
Slicer "Send to printer" worked on 0.2.3.2 with a queue-mode VP and
started failing on 0.2.4b3 with BambuStudio's generic "storage needs
to be inserted before send to printer" error. Multiple users
reported it across P1S, P2S, Docker bridge, macvlan, and host
networking. @rtadams89's debug-level support archive showed the
smoking gun: slicer establishes MQTT TLS, gets pushall +
get_version, then never opens an FTP connection — pre-flight
rejects before any data transfer.
The 0.2.3.2 synthetic stub baked in three SD/storage indicators
that BambuStudio's "Send" pre-flight reads: home_flag with bit 8
(HAS_SDCARD_NORMAL, 0x100), sdcard=True, and a storage:{free,total}
block. The 0.2.4b3 cached-as-base slicer-mirror (7dea33d0) passes
the live target's push_status through with only an IP rewrite — if
the real firmware doesn't report those fields (P1S/A1 with no SD
card, older field shapes, confirmed on P1S firmware 01.10.00.00),
the slicer sees "no storage" and aborts. H2D and X1C reproductions
worked because those firmwares do report the indicators.
In _send_status_report's cached-as-base branch, after copying the
cache and applying the existing protocol/upload-state overrides:
- home_flag |= 0x100 (preserves any other bits the real printer set)
- sdcard = True (force-set even when real says False)
- storage = setdefault(...) (only fills in if missing — real values
pass through unchanged when the printer reports them)
For VP usage the slicer uploads via FTPS to Bambuddy's filesystem
at /app/data/virtual_printer/uploads/<vpid>/; the printer's actual
SD card is irrelevant on that path, so forcing "storage available"
is correct for the queue / immediate / review modes the
cached-as-base path covers.
chore(i18n): extend parity gate to all locales with strict/info tiers
Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
report informationally until their drift is caught up. ja notably has 27 real
placeholder bugs worth fixing before promotion to strict.
Subsequent backups against Gitea 1.24+ failed with the opaque
"Backup failed: 'tree'" message after the initial-backup fix landed in
7ee89b56. Root cause: Gitea's GET /repos/{owner}/{repo}/git/commits/{sha}
returns the wrapped Commit schema where the tree lives at
data["commit"]["tree"]["sha"], whereas GitHub's same-named Git Database
endpoint returns the unwrapped GitCommit schema with tree at the top
level. The bare commit_response.json()["tree"]["sha"] lookup at
gitea.py:109 raised KeyError: 'tree' and the broad except in push_files
surfaced it as the opaque "Backup failed: 'tree'" string — masking the
real shape mismatch.
Adds a _commit_tree_sha() helper that tries the flat shape first
(GitHub-compatible / older Gitea) and falls back to the wrapped shape
(Gitea 1.24+, Forgejo). Returns None on truly malformed responses;
push_files maps that to a clear "Failed to extract tree SHA from commit
response" instead of leaking a KeyError repr. Keeps the existing-files
diff working on both shapes so subsequent backups don't re-upload every
blob — preferred over the .get()-and-skip approach which would have
required also dropping base_tree from the tree POST and re-uploading
unchanged files on every backup.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.