Reporter asked for an option to order printed label sheets by colour
instead of spool number so multi-colour rolls group related colours
together physically on the sheet.
Backend (labels.py) already preserves caller order, so this is
frontend-only. LabelTemplatePickerModal gains a "Sort: By ID / By
colour" chip pair next to the material filter. Colour mode converts
each spool's rgba to HSL: chromatic colours (s >= 0.1) cluster in
bucket 0 ordered by hue 0..360, achromatic colours go in bucket 1
ordered by lightness so neutrals trail the rainbow black -> white.
Stable tiebreaker on spool ID.
Also fixes a latent issue exposed by the same code: the submit was
always re-sorting selected IDs ascending, which would have clobbered
any frontend order. Submit now uses sortedSpools.filter().map() so
the visible order flows through to the PDF.
Session-only state; toggle resets to "By ID" each time the modal
opens. 3 new i18n keys translated across all 8 locales (parity 4852
leaves). 2 new modal tests pin the colour-sort payload order and
the unchanged ID-default. 17 modal tests + i18n parity + build all
green.
Editing a Spoolman spool used to mint a brand-new filament every time
a match-key field (subtype/material/brand/color_hex) changed, orphan
the previous one, and re-link the spool. The reporter ended up with
dozens of duplicate "Amazon Basics / PLA Glow" filament rows.
PATCH /spoolman/inventory/spools/{id} now:
- Reuses the current filament_id when no filament-shaping field
changed (a note/weight_used edit never touches the catalogue).
- PATCHes the existing filament in place when it's a singleton
(only this spool points at it, archived spools included).
- Falls back to find_or_create_filament only when the filament is
genuinely shared with another spool.
Mirrors internal-inventory behaviour where editing a spool updates
the thing the spool points at instead of proliferating new entities.
Step 2 of the camera architecture overhaul agreed after #1395. When
the camera viewer hits its error state OR before a print at any
time, a Diagnose button runs a staged check against the printer and
renders the result inline: which stage failed, how long it took,
and a translated remediation hint. Cuts off the "user opens a
'camera broken' ticket → ask for support bundle → triage" loop at
the user's screen.
Backend
- New `backend/app/services/camera_diagnose.py` orchestrator with
CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
- New POST /printers/{id}/camera/diagnose route in camera.py.
- Stages:
tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
with 3 s timeout. Distinguishes timeout, refused, and host-
unreachable into distinct summary codes so the frontend can
show a precise remediation (firewall vs LAN-only off vs
wrong IP).
first_frame — captures one JPEG end-to-end via the existing
capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
first keyframe collapse into one stage; the user-facing
answer is the same regardless of which sub-layer failed.
- Live-stream shortcut: when a viewer is currently watching the
camera with a buffered frame < 10 s old, the diagnostic skips
the real test and returns live_stream_active_healthy. Opening a
fresh socket would kick the live viewer off on single-camera-
connection firmwares (the #1348 reconnect-storm trigger), so we
trust the real-world evidence instead.
- Response surfaces protocol, port, and profile name for support
triage — lets us ask "what does your modal say?" instead of
"send the support bundle".
Frontend
- New CameraDiagnoseModal renders one row per stage with green-
check / red-X / grey-skipped icons, the per-stage duration in
ms, a remediation banner styled by overall status, and a Run
again button.
- Two entry points:
1. The viewer's error overlay grows a Diagnose button next to
Retry. Retry stays the primary action; Diagnose is the
escape hatch for users who can't see what's wrong.
2. A stethoscope icon in the viewer's always-visible control
bar, between Refresh and Fullscreen. Pre-flight testing
("did my firmware update break the camera?", "is the
camera up before I send a print?") doesn't require waiting
for the stream to fail first.
- Also lifted the previously-hard-coded "Camera unavailable" /
"Retry" strings into camera.unavailable / camera.retry so the
error UI is fully translated alongside the new keys.
Reporter on a P2S running firmware 01.02.00.00 saw the camera connect
for a few seconds then time out, repeating. P1S on the same install
worked fine — different protocol (chamber-image port 6000 vs RTSP via
ffmpeg).
The P2S RTSP path was running ffmpeg with `-probesize 32
-analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower
keyframe pacing means ffmpeg can't lock onto the stream within 32
bytes — its own stderr says "consider increasing probesize" before
giving up after ~2s. Bambuddy reconnects, cycle repeats.
Instead of bumping the globals (which would regress every other RTSP
model's startup latency), this lifts the per-model tuning into a new
`camera_profiles` registry. CameraProfile dataclass holds the
previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max,
rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future
per-model flags). get_camera_profile(model) returns the model's profile
or DEFAULT_PROFILE.
Default profile preserves the historical X1/H2 fast-startup values
verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no
behaviour change. P2S is the only override:
P2S: probesize=1_000_000, analyzeduration=500_000
SSDP internal codes (N7→P2S) resolve via an alias map so the camera
path works during the early-connect window before the display name
is settled.
This is the first step of the camera-architecture overhaul agreed
after #1395. Adding the next quirky model is a config entry, not
another module-level constant.
Reporter on a 27" monitor saw long filenames truncated even though
the Print Log table had plenty of unused horizontal space. The
print-name `<span>` had a hard `truncate max-w-[200px]` cap that
ignored viewport width entirely.
Replaced with `break-words` + a `title` attribute, dropping the
explicit max-width so the column auto-sizes to content. On wide
screens the full name shows on a single line; on narrow ones it
wraps inside the cell instead of forcing horizontal scroll. The
`title` hover preserves the original tooltip affordance for the
edge case where a really long name still gets wrapped.
Reporter typed into the Add Spool modal's hex colour input and only
the first character stuck - everything after that defaulted to "0"
with no way to override except by pasting the full hex.
Pre-fix, the #1055 fix aggressively normalized the input to a valid
8-char rgba on every keystroke. After typing the first char the
controlled input value snapped to e.g. "A00000", the browser placed
the cursor at the end, and the user's next keystroke landed at
position 7. The #1055 fix's 7-char branch then truncated that byte
away, leaving the form state unchanged - so the user appeared to
type nothing.
Fix splits the typing-state from the backend-state:
- The hex input gets its own `hexDraft` useState holding 0-6 chars
freely. Typing one char at a time works naturally because the
controlled value matches what the user typed.
- `updateField('rgba', ...)` fires only when the draft reaches a
complete 6-char RGB (commits as `<6chars>FF`). Below that, the
form state stays untouched - no mid-keystroke snap.
- On blur, a partial 1-5 char draft is right-padded with `0` and
committed. Keeps the #1055 invariant: anything reaching the
backend is exactly 8 hex chars matching /^[0-9A-F]{8}$/.
- A `useEffect` resyncs the draft when an external action (color
picker, swatch click, edit-mode load) changes the canonical hex.
- Paste of 7-/8-char strings truncates to the leading RGB. Bambu
filaments are opaque; the UI never exposed an alpha affordance,
so dropping the (undocumented) paste-with-alpha case is fine.
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
queue, started from the queue, and got no timelapse video. Their
dispatch chain itself was correct (queue item -> scheduler -> MQTT
command honors `timelapse`); the gap was at queue-add time.
The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
other print-option settings) from the workflow settings card. That was
introduced in #1235 to stop column-level defaults from winning. But it
also discarded the slicer's actual choice carried on the MQTT
`project_file` command, which all the slicers (Studio / Handy / Orca)
ship as `timelapse: true|1`. Result: a user with the new-install value
`default_timelapse=false` had to either flip the global setting or
edit every queue item by hand, even though their slicer's "Print
options" UI clearly said "record timelapse".
Investigation went wider than #1403 because Martin's hypothesis was
"the print options modal isn't respected either." Cross-checking
86 captured P1S `project_file` commands across the support packages
shows 46 from the queue scheduler and 33 from background_dispatch
emitting `"timelapse": true` correctly to real printers - the modal +
re-print path is intact end-to-end. The slicer-side gap was the only
real bug. Two unrelated dead-code issues turned up in the same dig and
are folded in below.
Fix (VP queue inheritance)
- `on_print_command` in the VP manager now stashes the slicer's
project_file dict keyed by filename, then signals an asyncio.Event.
- `_add_to_print_queue` checks the dict first; if empty, creates the
event and waits up to 2 s for it before reading the settings
fallback. Each option flows through per-field - slicer value wins
if present, else the existing settings default (so users who
explicitly set `default_timelapse=true` in their VP workflow card
still get that on slicers that don't send a print command).
- MQTT field naming preserved exactly: `bed_leveling` (single L) on
the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
column. Integer 0/1 from H-family slicers and bool true/false from
P1/X1 slicers both coerce via `bool()`.
- Capture is gated on `mode == "print_queue"` so immediate / review /
proxy modes keep their pre-fix no-op `on_print_command` and don't
accumulate stashed entries over the VP's uptime.
- Wait is also skipped when there's no MQTT server attached
(`self._mqtt is None`), so unit tests that invoke
`_add_to_print_queue` directly don't pay the 2 s tax.
- Capture is consumed on use so the dict stays bounded.
- `printer_manager.get_status(...).get(...)` against a `PrinterState`
dataclass that has no `.get()` method.
- Every print option discarded (timelapse, bed_levelling, AMS mapping).
The route 500'd before ever reaching the printer. Rewritten to mirror
`POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
next pending queue item and let the scheduler dispatch with the
queue's stored options intact. Response shape preserved.
Side-bug b: vibration_cali default drift in background_dispatch
- `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
both default to `True` (matches Bambu Studio behavior for X1/P1).
- Both `_process_job` call sites read
`job.options.get("vibration_cali", False)`.
Cosmetic today because the frontend always sends the field, but a
latent landmine for any future caller that bypasses the schema. Both
sites flipped to `True`.
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
Several support reports traced back to one root cause: a mistyped access
code in Add Printer left an empty card on the dashboard. POST /printers/
was persisting the row first, then firing connect_printer() fire-and-forget.
Now we test_connection() BEFORE the insert; failure returns HTTP 400 and
the row is never written.
Structured error response -- detail={"code", "message"} -- so the toast
shows the localized message instead of the English fallback. New
ApiError.code field on the frontend; printers.toast.connectionFailedNotAdded
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
The LabelTemplatePickerModal correctly branches on a spoolmanMode prop
and the /spoolman/labels backend endpoint exists, but InventoryPage was
instantiating the modal with spoolmanMode={false} hard-coded. Every
click in Spoolman mode resolved to /inventory/labels with Spoolman spool
IDs and returned 404 "Spool(s) not found".
The hard-coded value came with a stale comment from the original label
printing PR that said "Spoolman path hands users an iframe to Spoolman
so the per-spool button never shows in that context" -- that assumption
stopped being true when the unified inventory UI shipped.
Pass the actual spoolmanMode value through, drop the stale comment.
The existing LabelTemplatePickerModal.test.tsx already covers both
branches at the component level (line 209: "routes to the Spoolman
endpoint when spoolmanMode is true"). The gap was that no test exercised
the InventoryPage wiring.
First cut of this action only wired the built-in inventory path, so the
eraser buttons vanished when the user switched to Spoolman mode. Mirror
the endpoints on the Spoolman router:
- POST /spoolman/inventory/spools/{id}/reset-usage
- POST /spoolman/inventory/spools/reset-usage-bulk
Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
/spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
guard (rejects empty/missing spool_ids), and individual Spoolman failures
are logged + counted out without aborting the batch.
InventoryPage mutations now switch on spoolmanMode to pick the right
client method, and the three "spoolmanMode ? undefined : ..." gates on
the eraser buttons are gone.
After #1378 moved Quick Stats to print_log_entries, six widgets and
Failure Analysis still iterated the archive list. That made reprints
multiply event-based widgets while leaving archive-based ones unchanged,
and made hard-deleted archives drop from archive-based widgets while
their orphan events kept feeding Quick Stats.
Swap the data source in two places:
- GET /archives/slim now reads PrintLogEntry, LEFT JOINs the archive for
the sliced print_time_seconds estimate, prefers PrintLogEntry's own
duration_seconds as the measured-time field. StatsPage is the only
caller -- every widget realigns in one step.
- FailureAnalysisService swapped from PrintArchive to PrintLogEntry for
every aggregation. project_id filter still resolves through archives
but counts matching events.
Conftest archive_factory now syncs the synthesized event's created_at
with the archive's so backdated test data survives the change.
bambu_ftp.upload_file (and upload_bytes) wrapped the voidresp() call in a
broad "except Exception: log warning and proceed" because H2D printers
can take 30+ seconds to send the 226 and we don't want to fail on that.
But the same handler was swallowing ftplib.error_temp (e.g. 426 "Failure
reading network stream") from buggy printer firmware, which explicitly
means the data stream was cut mid-transfer and the file on the SD card
is partial.
Bambuddy then sent the print command anyway, and the printer surfaced a
generic "unable to parse 3mf file" error 30 seconds into the print
attempt -- with nothing in the log on the user side to suggest the
upload had actually failed.
Split the catch: ftplib.Error subclasses (server-reported failure)
re-raise so the outer handler returns False; everything else (socket
timeout etc.) keeps the existing proceed-with-warning behaviour so the
H2D 226 tolerance survives.
Two regression tests patch _ftp.voidresp to raise error_temp("426 ...")
and assert both upload_file() and upload_bytes() return False.
The underlying P2S firmware / TLS-data-channel issue that triggers the
426 for the reporter is separate -- this change just stops Bambuddy from
hiding it.
Drop the Spoolman-mode hijack that replaced the local spool tare catalog
with an inline filament editor — two unrelated concepts that should never
have shared a card. Spool Catalog now renders the same way in both modes;
Spoolman users edit filament name and spool_weight in Spoolman's own UI.
Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
on the Filament settings page whenever Spoolman was disabled.
- frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
- frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
- SpoolCatalogSettings test file rewritten to match the simplified component
- PATCH /spoolman/inventory/filaments/{id} backend route left in place
Reporter @iitazz uploaded slicer output to Bambuddy, clicked Print,
and the printer rejected every job with "Printing stopped because
the printer was unable to parse the 3mf file". Support bundle showed
the stored library file ended in .gcode (not .gcode.3mf), and
background_dispatch.py appends ".3mf" to filenames that don't
already end in .gcode.3mf/.3mf — so raw gcode shipped to the printer
named .gcode.3mf and the firmware's 3MF parser choked. Same shape
also surfaced as "File is not a zip file" on Bambuddy's own plate
parser.
New validate_print_file_upload() helper in library.py runs at upload
time:
- Reject filenames ending in .gcode (but not .gcode.3mf) with a
clear message — Bambu printers need .gcode.3mf zip containers,
not raw gcode.
- For .3mf / .gcode.3mf uploads, verify body starts with PK\x03\x04
(ZIP magic); reject otherwise pointing at the slicer's "Export
Plate Sliced File" action.
Applied to every relevant upload route: POST /library/files (covers
File Manager + printer-card drag-drop), POST /archives/upload,
POST /archives/upload-bulk (rejects per-row so one bad file doesn't
abort the batch), POST /archives/{id}/source, POST /archives/upload-source.
Runs after _resolve_upload_destination so folder-permission errors
(403 readonly, 400 missing-path, 409 collision) still take precedence.
STL / image / other non-print uploads bypass the validator.
FileUploadModal frontend fix: the modal auto-closed after every
batch regardless of per-file results, so a 400 rejection was captured
but invisible. Now:
- Errors render inline as red text under the file row instead of
as a hover-only title tooltip.
- Modal stays open if any file ended with status='error', so the
user can read the backend's remediation message before closing.
- Successful-only batches still auto-close as before.
UploadModal (bulk archive) was already showing inline errors and
not auto-closing — no change needed there.
Reporter pgladel manages multiple physical filament storage
locations and wanted to narrow the inventory list to a specific
location without typing a search query each time.
Adds a new Storage Location dropdown chip on the inventory page,
next to the existing Material / Brand / Category / Spool Name
filters. Distinct values are pulled from the spool list with
.trim() so accidental trailing whitespace doesn't render as a
separate option. A "No location set" entry appears when at least
one spool has an empty storage_location (mirrors the categoryNone
group). Chip self-hides when no spool has a storage location set.
Same shape as the Category chip from #729 — clear-all-filters and
hasActiveFilters both include the new state.
i18n: reuses existing inventory.storageLocation label, adds
inventory.storageLocationNone in all 8 locales. Parity check
holds at 4818 leaves per locale. 24 InventoryPage tests still
pass, frontend build clean.
Reporter Kyobinoyo asked for the equivalent of the existing
print-finish auto-off but triggered when AMS drying ends.
Two new SmartPlug columns: auto_off_after_drying (default false),
off_delay_after_drying_minutes (default 10 — AMS chamber is hot
post-cycle so longer cooldown than the print-finish default of 5).
SQLite + Postgres migrations both idempotent.
Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
tracks the dry_time > 0 → 0 falling edge and fires a new
on_drying_complete(ams_id) callback. Plumbed through
PrinterManager.set_drying_complete_callback to
SmartPlugManager.on_drying_complete(printer_id, db), which walks
linked plugs and respects the per-plug toggle. Catches queue,
ambient and manual drying identically because it observes firmware
state, not scheduler intent.
Frontend: single "Auto Off After Drying" toggle + delay input on
the smart plug card, next to the existing print-finish auto-off
section.
Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
on dual-AMS printers) deferred — Bambuddy's plug model is
plug→printer, so the trigger fires whenever any AMS on the linked
printer finishes a cycle.
Reporter IndividualGhost1905 followed up after the #1378 / #1343
backfill landed and pointed at the next inconsistency: the per-
archive delete dialog has had a "Also remove this print from Quick
Stats" checkbox since #1343, but the "Purge Old" button and the
scheduled daily auto-purge sweeper both ignored that choice and
hard-deleted unconditionally. From the user side this looked like
"automatically deleted from statistics without any warning" — half-
true, and the inconsistency was real either way.
The actual current shape (before this fix):
- POST /archives/purge -> archive_purge_service.purge_older_than
-> ArchiveService.delete_archive (hard). Archive row dropped.
Linked PrintLogEntry rows have ON DELETE SET NULL so they
survive as orphans with archive_id=NULL. Quick Stats keeps the
filament / cost / energy contribution because the log rows are
still there, but the archive-list-iterating widgets (Filament
Trends, By Material, Color Distribution, Printer Stats) lose
the row, and Time Accuracy loses its join target. Visibly
inconsistent.
- Scheduled _maybe_run_auto_purge -> same code path, same effect.
- Single-archive DELETE /archives/{id} -> already takes
purge_stats=true|false (default false=soft) and routes either
soft_delete_archive (keeps everything, flips deleted_at) or
deletes PrintLogEntry rows first + hard-deletes archive.
The fix threads the same purge_stats flag through every bulk surface
with soft as the default, matching the single-archive default:
Backend:
- archive_purge_service.purge_older_than(..., purge_stats=False)
-> per-row soft_delete_archive when False, per-row
PrintLogEntry deletion + delete_archive when True. Each runs in
its own session (same pattern the sweeper already used).
- preview_purge gains the same kwarg so the eligible-count
matches what an actual run would touch: soft mode excludes
already-soft-deleted rows, hard mode counts them as eligible
for promotion.
- get_settings / set_settings now persist archive_auto_purge_stats
(default False). _maybe_run_auto_purge reads it on every tick.
- ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings
schemas extended.
- Route /archives/purge accepts the body flag, /purge/preview
accepts the query param, /purge/settings GET + PUT echo the
setting.
Frontend:
- "Purge old archives" modal: new "Also remove from statistics"
checkbox under the preview, unchecked by default. Plumbed into
the preview query key + the execute mutation.
- Settings -> Archives auto-purge card: matching toggle next to
the days slider, disabled when auto-purge itself is off.
- api.previewArchivePurge / api.executeArchivePurge accept the
flag; ArchivePurgeSettings type gains purge_stats.
- All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get
new purgeStatsLabel / purgeStatsHint / purgeStatsDescription
keys, plus rewritten effect / warning copy in archivePurge and
archiveAutoPurge to reflect that the default no longer
"permanently removes from the database" but instead hides the
row + removes files while keeping Quick Stats intact. i18n
parity check clean: 4814 keys across all 8 locales, no fallback.
Behaviour change for existing users on auto-purge: the sweeper used
to hard-delete by default and now soft-deletes by default. After
the upgrade those installs start *preserving* more data in Quick
Stats rather than losing it — safer direction of the two, but worth
the explicit call-out. Anyone who wants the old behaviour ticks the
new toggle once and it persists.
Reporter wintsa123 (China-region user, bambulab.cn) couldn't log
into Bambuddy. Looked like a code bug; turned out the code is fine
and only the docs were wrong.
What's already there (no change needed):
- PR #1013 (April) added the China region selector to the
token-login flow and routes validation to api.bambulab.cn instead
of api.bambulab.com. The selector is in the form
(frontend/src/pages/ProfilesPage.tsx:204-205) and the backend
dispatch is in backend/app/services/bambu_cloud.py:15 +
backend/app/api/routes/cloud.py:174.
What was wrong:
- The in-app `accessTokenHint` said "Paste your Bambu Lab access
token (from Bambu Studio)" in all 8 locales. Bambu Studio never
exposed the token in any UI, and the profile page on
bambulab.com that used to show it has been removed. The hint was
pointing users at sources that don't exist.
- For China-region accounts the email / password flow is
fundamentally unusable because those accounts are bound to phone
numbers, not email — token login is the only working path. The
hint didn't say so, so reporters kept trying email login first
and getting confused.
- The wiki's "Access Token Login" section in
features/cloud-profiles.md only described the dead profile-page
method and a Python-script alternative that only works against
the global API. No mention of the China-region selector and no
mention of the MakerWorld-cookie method that actually works
today.
What this change does:
- Rewrites `accessTokenHint` in all 8 locales (en, de, fr, it, ja,
pt-BR, zh-CN, zh-TW) to state that China-region accounts must
use the token path and point at the wiki for the cookie
retrieval procedure. zh-CN and zh-TW translations were written
for the audience that actually needs this.
- Rewrites the wiki's Access Token Login section: adds a
"Region: China must use token login" callout, replaces the
dead profile-page guidance with the MakerWorld cookie method
(both makerworld.com and makerworld.com.cn, with the browser
DevTools steps), keeps the Python-script alternative explicitly
scoped to global-region accounts, and warns that the cookie
value is sensitive and shouldn't be pasted into screenshots or
threads.
No backend changes. Once a user has the token + Region: China
selected, the existing flow works.
Reporter vmhomelab ran a Print Queue VP against a P1S, opened
BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch
(which restarts the VP) made AMS briefly appear, then it reverted to
defaults. Proxy Mode worked fine.
The earlier #1371 sticky-keys fix only handled one of two firmware
incremental-push shapes: it preserved cached `ams` when the incoming
push OMITTED the key entirely. P1S firmware (01.09.01.00) instead
sends incrementals with the `ams` key present but the inner `ams.ams`
array stripped — `{ams_status: 1, humidity: 2}` rather than
`{ams: [...], ams_status: 1}`. To the existing "key present? leave it"
check that read as "no need to preserve," so the bridge cache got
overwritten with the stripped blob, the slicer's next 1 Hz read saw
`ams` with no unit list, and BambuStudio fell back to its "no AMS"
default render. Toggling Auto-Dispatch restarted the VP and got a
fresh pushall through; the next P1S incremental stripped it again.
H2D rarely trips this because its incrementals typically don't carry
`ams` at all, so #1371 alone was enough — which is why H2D users
(including the project owner) didn't see the bug while P1S/A1 users do.
Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the
structure Bambuddy itself already does in
`bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value,
but the `ams.ams` array is merged unit-by-unit by `id`, each unit's
`tray` array is merged tray-by-tray by `id`, and units / trays the
incremental doesn't mention survive intact from the cached full
state. A tray-targeted incremental during a print
(`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one
tray's state without dropping the other trays' tray_type / tray_color.
Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called
from the existing sticky-keys block when both prev and new carry the
`ams` key as dicts. Other sticky keys (vt_tray, net, ipcam,
lights_report, ams_extruder_map, mapping) keep the prior absent-only
preservation; only `ams` has the multi-shape partial problem worth
the merge complexity.
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
per-event aggregation rewrite from #1378) and saw Quick Stats split
between consistent values (Total Prints, Print Time, Filament Used,
Energy, Success Rate matched the archive list) and zero-or-empty ones
(Filament Cost, Time Accuracy).
Root cause: #1378's migration added six columns to print_log_entries
- archive_id, cost, energy_kwh, energy_cost, failure_reason,
created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
on legacy data); the time-accuracy query JOINs PrintArchive ON
archive_id (drops every legacy run from the average). Counts and the
pre-existing per-row fields (status, duration_seconds,
filament_used_grams) kept working - which is why some panels looked
right and others didn't.
Two-step backfill added inside run_migrations next to the existing
column-add block, as DML inside begin_nested() (not _safe_execute,
which is documented DDL-only):
Step 1: link each orphan log entry to its archive via
print_name + printer_id (highest archive id wins on
tiebreak - newest matches the overwrite-then-stop shape
pre-#1378 reprints left behind).
Step 2: copy archive.cost / energy_kwh / energy_cost onto the
latest matching log entry per archive, BUT only for
archives where no log entry yet carries a cost. That
second clause is the idempotency anchor and the
double-count guard for users running this after #1378
has already written cost-bearing rows for new runs -
those archives are left untouched.
Earlier reprints stay NULL, matching the "first/latest writes, rest
stay NULL" convention #1378 introduced for new prints. Sum across the
legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
Stats Filament Cost matches the pre-upgrade total instead of dropping
to zero.
SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
end-to-end on SQLite (4 new unit tests in
test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
cost, idempotent, skip-archives-with-any-costed-run) and against a
live postgres:16-alpine + asyncpg container (first-pass and second-
pass produce identical state).
The other widgets the reporter listed (Printer Stats, Filament
Trends, By Material, Success by Material, Color Distribution) iterate
the archives list on the frontend rather than calling /stats - they
read consistent pre-upgrade data and aren't part of this fix; the
inconsistency between them and Quick Stats resolves once the backfill
brings Quick Stats in line.
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit
Save, and saw the value snap back to the subtype on the next read.
The earlier #1319 fix correctly handled the read/form-prefill half
(the color_name_is_synthesized flag, blank-on-synth form init), but
the write half assumed Spoolman has a `color_name` field on Filament.
It doesn't. Verified against the live FilamentUpdateParameters schema
on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material,
price, density, diameter, weight, spool_weight, article_number,
comment, settings_extruder_temp, settings_bed_temp, color_hex,
multi_color_hexes, multi_color_direction, external_id, extra. No
color_name. Spoolman's PATCH happily returns 200 for
{"color_name": "Red"} and silently discards the unknown key, so
find_or_create_filament was either patching a void or creating
filament after filament with the same field-that-doesn't-stick (which
is what produced the "BB also created a bunch of new filaments"
duplicate trail on each save attempt).
The fix follows the same pattern as the existing BambuStudio slicer-
preset storage: persist color_name on spool.extra.bambu_color_name as
a JSON-encoded string, register the extra field via
ensure_extra_field before write (Spoolman 400s on unknown extra keys),
and read it back in _map_spoolman_spool with priority
extra > filament.color_name (forward-compat for any future Spoolman
release that adds the field) > subtype synth.
Dropped the now-dead color_name passing through
find_or_create_filament and create_filament — Spoolman would discard
it anyway and keeping the dead pipe risked the same confusion the
next time someone reads this code. The previous "match by name then
patch color_name" loop is gone; what survives is the name-match
resilience that lets an AMS-sync-created filament named "Glow" still
match the user-driven edit's composed "PLA Glow", which prevents
re-introducing the duplicate-filament trail.
The frontend form's color_name_is_synthesized handling is unchanged
— that part already worked.
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
a search prefix matching a multi-entity device (one switch.* plus
several sensor.*/binary_sensor.* siblings under the same friendly
name), clicked one of the non-switch siblings, and got a 422 on Save:
String should match pattern
'^(switch|light|input_boolean|script)\.[a-z0-9_]+$'
The screenshot confirms the bug shape — the X button next to the
"empty-looking" Select Entity field only renders when haEntityId is
truthy. So haEntityId was set, but selectedEntity (haEntities.find by
that id) returned undefined, so the input rendered the placeholder
text instead of the friendly-name display. That can only happen when
the user had earlier picked an entity whose domain is NOT in the
schema's allowed list, then the search cleared, the entity-list
refetched without a search param, and the refreshed list (filtered to
the default domains) no longer contained the user's pick.
Root cause was in HomeAssistantService.list_entities: when a search
query was present, the function bypassed the domain filter entirely
and returned matches across every HA domain. Offering a clickable
choice the schema can't accept is broken UX, and the cryptic Pydantic
pattern echo on save made it look like a backend/schema problem
rather than a search-permissiveness problem. Confirmed via git diff
that the smart-plug code path is unchanged between v0.2.4 and
0.2.4.1 — this has been latent since the script-domain commit in
February 2026, only noticed now because the reporter hadn't reopened
the modal in months.
Fix: always apply the allowed-domains filter ({switch, light,
input_boolean, script} — kept in sync with the regex in
backend/app/schemas/smart_plug.py:17). Search composes on top as a
substring match against entity_id or friendly_name, instead of
replacing the domain filter. Whitespace-only search strings now
fall back to the no-search behavior.
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
The two # noqa: S501 comments on the local-sidecar reachability probes
were using ruff/flake8 suppression syntax; bandit only honors # nosec,
so the scan flagged both calls as high-severity. Switched to
# nosec B501 with strengthened reasoning (reachability/health probe
only, no secrets in the request). No behavioural change.
urllib3 2.6.3 was being pulled in transitively (none of our top-level
deps require >=2.7.0 yet) and trips two recent CVEs. Direct pin in
requirements.txt forces the resolver to install 2.7.0, which is the
upstream-fixed release for both findings.
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
Archives → Print Log was 404-storming the thumbnail endpoint on every
render: PrintLogEntry.thumbnail_path is copied by value from the archive
at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so
log entries survive archive deletion to preserve stats history - and the
cached path keeps pointing at a file that was removed when the archive's
directory was deleted. Same shape for failed prints whose extractor never
wrote the thumbnail.
Two-part fix:
1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the
entry and commits before returning 404 when the file is missing on
disk. The frontend's <img> tag is gated on entry.thumbnail_path being
truthy, so the next fetch of the log list skips the request entirely.
2. Eager clear on archive delete: new _null_print_log_thumbnail_paths()
helper called from both soft_delete_archive and delete_archive before
the on-disk files are removed. Avoids the one-time storm for future
deletes; covers both the manual delete route and the auto-purge
sweeper at archive_purge.py.
Regression tests cover soft delete, hard delete via ArchiveService, and
the route's lazy-NULL for failed-print orphans where the file was never
written.
Obico polling could freeze the live camera stream within seconds of opening
the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame
saw an empty _last_frames[printer_id] entry (stream startup before the first
JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell
through to capture_camera_frame_bytes() and opened a second RTSP socket. On
firmwares that allow only one camera connection, that second socket forced
the printer to drop the live fan-out connection - the viewer's ffmpeg then
hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing
the next Obico poll, and the broadcaster pump exited.
Widen the gate from "do we have a buffered frame?" to "is any fan-out stream
registered for this printer?". New is_stream_active() helper checks
_active_streams / _active_chamber_streams independently of buffer state.
_capture_frame consults it first: if a viewer is attached, it returns the
buffered frame when available or None (skip this poll cycle) when not. Never
opens a competing socket while a viewer is connected.
Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag).
Benefit: zero competing-socket events while any viewer is connected.
try_get_active_buffered_frame() refactored to delegate to is_stream_active()
so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged
behaviorally (snapshot is a user-initiated single-shot; falling through to
fresh capture on an empty buffer is the desired behavior there).
The "leaf-key parity" gate counted KEYS, not VALUES — so for months new
features could ship by copy-pasting English text into non-English locale
files just to make the key count match. ~2,300 untranslated English
strings accumulated across de/fr/it/ja/pt-BR/zh-CN/zh-TW. Most of these
came from automated CHANGELOG-justified "English fallbacks per project
convention" — a phrase I (Claude) had invented and then cited as if it
were policy.
Two structural fixes so it can't happen again:
1. New Check 4 in check-i18n-parity.mjs flags any leaf whose value is
identical to en.ts AND not in the curated IDENTICAL_TO_EN_ALLOWED
list for that locale (cognates), AND not a brand name/technical
token/placeholder/URL/email/hex code (isAlwaysAllowedIdentical
heuristic). Future English-into-non-English shortcuts fail CI loudly.
2. Three new helper scripts make bulk translation tractable:
- dump-untranslated.mjs: list every flagged (locale, key)
- expand-translations.mjs: unique-source table → per-(locale, key) JSON
- apply-translations.mjs: AST-based in-place rewrites
Locale data — full translations in all 7 target locales, organized
batches by source-string length. Cognates that legitimately match en
(Status/Firmware/Tag/etc. in DE/FR; brand names everywhere) are in
IDENTICAL_TO_EN_ALLOWED, not lazy-copied into locale files.
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
Matplotlib (imported lazily by stl_thumbnail.py) tried to create its
font/style cache at $HOME/.config/matplotlib on first STL upload.
HOME=/app per the Dockerfile but /app is root-owned and not writable
by the PUID:PGID the entrypoint drops to, so matplotlib logged
"Permission denied" and fell back to /tmp/matplotlib-* — wiped on
every restart, paying the font-scan cost again on the next STL.
Add ENV MPLCONFIGDIR=/tmp/matplotlib to make the cache directory
writable and persistent across the container's lifetime. /tmp is
writable by any uid, so this works regardless of PUID.
The bridge cache replaced _latest_print_state wholesale on every
push_status arrival. Bambu firmware sends full pushall responses
(with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
requests, but ~1 Hz incremental updates with only the fields that
changed. The first incremental push after a pushall therefore wiped
AMS info from the bridge cache, and slicers reading the cache (via
the VP's 1 Hz status push) saw a stripped-down state with no AMS
visible until the next pushall — typically only on a manual printer
power-cycle.
Preserve a small set of slicer-visible sticky keys from the previous
cache when the incoming push doesn't carry them: ams, vt_tray,
ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
same pattern Bambuddy uses for its own internal state.raw_data.
Both the queue-side _watchdog_print_start and the direct-dispatch
_verify_print_response used `status.state != pre_state` to decide
whether a project_file command had been accepted. When a printer was
in FINISH at dispatch time (un-dismissed post-print prompt from a
prior job), the firmware silently rejected the new command; if the
user then dismissed the screen prompt, the printer moved FINISH ->
IDLE and the watchdog returned early as "command landed" — leaving
the queue row stuck at status='printing' indefinitely and the
scheduler permanently marking the printer as busy.
Narrow the "command landed" check in both verifiers to an allow-list
of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
the revert. The subtask_id-advance signal stays in place for H2D's
slow FINISH -> PREPARE transition (#1078).
Also wrap _watchdog_print_start's revert commit and
printer_manager._persist_awaiting_plate_clear in run_with_retry so
SQLite single-writer contention can't silently drop these writes.
The revert path returns a tristate sentinel so the post-revert MQTT
session-recovery logic only runs when we actually reverted (or the
commit failed) — not when on_print_complete had already cleared the
row, where a forced reconnect could break a healthy concurrent print.
The #765 guard against shutdown-time data wipes skipped any AMS update
with power_on_flag=False, but some X1C firmware emits power_on_flag=False
while idle with tray_exist_bits still reflecting the real slot inventory.
Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
the bitfield path is the only signal — muting it left spool removals
undetected until a manual reconnect.
Narrow the skip to the exact shutdown pattern: zero bits AND
power_on_flag=False. Non-zero bits with power_on_flag=False are now
applied. The #765 shutdown protection is preserved (its regression test
uses tray_exist_bits='0' and still passes); newer firmwares are
unaffected because their per-tray state path catches the removal first.
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
hostnames serve the same webhooks. The validation now accepts either
prefix while keeping the check itself in place to catch the
paste-the-wrong-thing error.
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.
Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.
Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button
Reporter @Andlar94 hit a permanent "Build plate not empty" on every print
start on an A1 with an external RTSP camera. The runtime auto-check at
main.py:1819 called check_plate_empty with use_external=external_camera_enabled,
but the manual UI routes (camera.py) declared use_external: bool = False
and the frontend client always sent use_external=false. So calibration
captured a built-in frame and stamped it as the reference; the runtime
check captured an external frame and diffed it against that reference --
a permanent mismatch.
Centralise the default on the backend: both routes now take bool | None,
deriving the default from the printer's external_camera_enabled +
external_camera_url + external_camera_type. The frontend client stops
sending the flag unless the caller explicitly sets it, so the existing
UI call sites immediately benefit and any future caller gets the right
camera automatically. Explicit overrides still win.
Adds 4 regression tests pinning the new default for both the
external-enabled and external-disabled cases, plus the explicit override
path so a future "always built-in" caller stays supported.
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
print queue and got no MP4 output even though the log said "Stitching layer
timelapse for printer 1" after each print. Support bundle confirmed the
external camera was working (Obico was polling the snapshot URL fine for
plate detection).
Root cause: start_session() only ran in the two new-archive paths in
on_print_start (fallback_archive at main.py:2510 and regular new-archive at
2600). The expected-archive branch at main.py:1981-2052 — where every
reprint and every queue/VP-dispatched print lands — updated the existing
archive row to status=printing but never started a timelapse session.
So _background_layer_timelapse ran at print complete, called tl_complete(),
found nothing in _active_sessions, returned None silently, and the wrapper
at main.py:3917 produced no log message for the no-session case. Every
print through the queue silently lost its timelapse — likely the reason
this hasn't been caught before (direct slice-and-send-to-printer prints
take the new-archive path and work fine).
Fix: mirror the same start_session() call in the expected-archive branch,
guarded by the same external_camera_enabled + external_camera_url check the
other two paths use.
Also reworded the snapshot URL help text across all 8 locales to make clear
that timelapse and plate detection each require their own per-printer
toggle — the URL is just the image source they pull from when active. The
previous wording read as if filling in the URL was sufficient.