The dialog weighed each plate against the spool in the slot it mapped to
and knew nothing about AMS Filament Backup, so a two-plate job needing
1441 g of ABS was refused against a 1000 g spool while the identical full
spool in the next slot went uncounted. The dispatcher has pooled matching
spools since #1762 and would have run the print -- "Print anyway" was
always the right answer to this warning.
The rule for which spools back each other up now lives in one place:
build_slot_materials() in filament_deficit, which the dispatcher's pool
and the new slot_materials half of GET /printers/{id}/inventory-remain
both draw on. The dialog groups on the keys it is handed rather than
resolving spools a second time, which is what let the two answers drift
apart, and which also gives the check to Spoolman users -- it read the
internal inventory only, so in Spoolman mode it approved everything.
Where a pool really is short the warning quotes the pooled totals, since
the per-slot figure reads as a contradiction next to a full peer spool.
slice_and_persist always wrote to get_library_files_dir() while giving the
new row the source folder's id, so slicing a file on a NAS mount produced
a .gcode.3mf that showed up in the right folder in the UI and never
reached the share -- invisible from the web UI, which is why it did not
reproduce.
Resolve the destination from the target folder like uploads (#1112) and
moves already do, set is_external and store the absolute path. Collisions
uniquify to "Model (2).gcode.3mf": a 409 would throw away minutes of CPU
on a routine re-slice, and overwriting a file on someone's NAS is worse.
An external folder that cannot take the file (read-only, unreachable, not
writable) falls back to managed storage rather than discarding the slice,
and reports why on SliceResponse.external_write_fallback -- surfaced as a
warning toast. Silent fallback is what made this bug invisible.
Archives, the queue and statistics report ownership as a numeric
created_by_id, and statistics accept it as a filter, but nothing let an
API key discover whose id was whose -- the only user listing returns
emails, roles, group membership and full permission sets, so it is
administrative and rejects keys.
Add GET /users/slim returning id + username only, gated on a new
users:read_slim permission mapped to can_read_status. That grants no
data a key could not already reach: for API-keyed requests the
permission deps return None as current_user, so the stats:filter_by_user
guard short-circuits and ?created_by_id=N is already honoured for every
N. What was missing was the ability to address the filter, not
permission to use it. The full listing stays unmapped = admin-only.
Also fix /auth/me, which answered an API key with a synthetic
administrator: id 0, role admin, is_admin true and every permission in
the enum. A key cannot reach an administrative route at all, so clients
building their UI from that response rendered actions that 403 on use.
It now reports the key owner's identity, is_admin false, and the
permissions the key's scopes actually admit. Ownerless legacy keys keep
id 0 but no longer claim admin.
---
Source user names from the slim listing where only names are needed (#1894)
Stats filter-by-user, the Archives print log filter, the File Manager
username autocomplete, the camera-token owner column and the Finance
member picker all render nothing but a username, but all of them read
the full user listing, which is gated on the admin-level users:read.
An operator granted stats:filter_by_user but not users:read got an
empty filter with no indication why.
Point them at /users/slim under a separate react-query key, since the
full listing shares the 'users' key and the two shapes would clobber
each other in the cache.
Back-to-back prints in chamber-heated materials (ASA, ABS, PA, PC) each paid
a full heat-soak from cold, even when the print that just finished had left
the chamber at temperature. Two changes remove that cost.
Keep bed warm between prints
While a printer sits in FINISH awaiting plate-clear and the next queued item
needs chamber heat, hold the bed hot so the chamber does not cool during the
bed-clearing window. The bed is the chamber's heating element here, not a
print surface, so the hold runs at the new `queue_keep_warm_bed_temp`
(default 90C, which also satisfies bed-threshold-linked aftermarket chamber
heaters), raised to the item's own bed temperature when that is higher.
Gated on `queue_keep_bed_warm` AND `require_plate_clear` AND
`preheat_enabled`, all re-checked in the backend so a stale UI cannot leave
the feature running. `queue_keep_warm_max_minutes` (default 120) bounds the
hold: when it elapses the bed is switched off and the hold latches until the
printer is next a candidate, so a plate nobody clears cannot leave the bed
hot indefinitely. The hold is also released when the item is deleted, the
queue empties, or a gate is toggled off mid-hold, and never when firmware
reports a target other than the one it set — a temperature the user or a
print changed is left alone. Publishing is idempotent.
Smart soak reduction from chamber history
The scheduler samples each connected printer's chamber temperature every tick
into a 2h rolling history. Preheat credits time the chamber has already spent
at temperature against the configured soak, shortening or skipping it.
Credit starts no earlier than the newest sample, the most recent unbroken run
of samples, or the end of the last real dip below target. A dip only counts
once it outlasts a grace period: an enclosed chamber cannot lose and regain
several degrees quickly (measured on an X1C, cooling from 55C to below 48C
takes 23-73 minutes, ~0.2 C/min), so a brief low reading is a door opening or
sensor noise rather than lost soak — and a plate swap, which is exactly when
keep-warm runs, produces one. A stale history credits nothing: at that
cooling rate the chamber can cross the threshold unobserved, so the full soak
runs instead.
Three supporting changes to preheat itself:
* Cancelling or deleting a queued item now stops a preheat already running
for it. Those routes only write `status` to the database, which a dispatch
coroutine parked in `asyncio.sleep` cannot observe, so the heaters ran for
the rest of max_wait + soak — 45 minutes at the default settings — and the
printer stayed in `busy_printers`, blocking every other queued item behind
a print that was not happening. The routes now signal the scheduler
directly, and the stage sleeps in slices so it notices promptly and
abandons the dispatch, letting the existing rollback shut the heaters off.
* A chamber-heated print whose slicer metadata carries no bed temperature
(common for Orca-exported 3MFs) used to skip preheat entirely and start
with a cold chamber. It now heats the bed to `queue_keep_warm_bed_temp`.
A parsed bed temperature still wins, and a print with no chamber
requirement still skips — no bed temperature is invented for the print
itself. Preheat's bed target is transient regardless: the print's own
gcode issues its M140/M190 at start.
* Preheat records which commands it sent (bed, chamber, airduct) and unwinds
them if the dispatch aborts before the print starts — a failed upload, a
cancelled item, an exception — instead of leaving the printer heating for
a job that is not happening.
The settings panel collapsed four causes into one message -- "the picked
preset's own values could not be read" -- with no indication of what to do
about it.
The overwhelmingly common cause has an obvious fix, and it isn't an edge
case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of
which Bambuddy channel it is on, so a current Bambuddy talking to a
sidecar that predates POST /profiles/resolve is the normal state, not a
misconfiguration. Those users would have seen an amber warning on every
slice with nothing pointing at the sidecar image.
resolve_profile now returns ResolvedProfile(values, reason) instead of
None for everything, the route passes the reason through, and the panel
picks its message from it:
sidecar_outdated -> name the fix: update the sidecar image
sidecar_unavailable -> the sidecar did not answer
not_configured -> no sidecar is configured
preset_unresolved -> the previous generic wording
A request that fails outright maps to sidecar_unavailable, since a
backend we cannot reach and a sidecar that will not answer are the same
thing from the dialog.
Every variant still ends with "anything you don't change still uses the
preset" -- that reassurance is the point of the notice, and it is true
whichever way the lookup failed.
Tests pin the distinction rather than just the happy path: a 404 and a
500 must produce different reasons, and each panel case asserts both that
its own message appears and that the "update the sidecar image" line does
not leak into the others.
The panel baselined every field on the option schema's compiled-in
defaults, so a preset setting a 0.42mm line width displayed 0 -- the C++
default meaning "derive from the nozzle". Every field was affected; the
Line width group just made it obvious.
Bambuddy cannot answer this itself. A standard-tier pick is only an
{inherits: ...} stub on our side, and local/cloud presets are deltas whose
remainder lives in the profile tree bundled inside the running sidecar.
The values now come from the sidecar's POST /profiles/resolve, which runs
the same resolver /slice does against the same profiles, so what the panel
shows cannot disagree with what a slice produces. Deliberately not the
local orca_profiles resolver: it walks OrcaSlicer's published tree, which
can differ from the image actually installed.
An untouched field shows the preset's value and reverting returns to it.
isModified compares against that baseline too, so fields the preset moved
off the C++ default are no longer flagged as user edits, and values nobody
typed are no longer sent. When the values can't be read -- sidecar offline
or older than the endpoint -- the panel falls back to schema defaults and
says so rather than presenting them as the preset's.
Row layout, from screenshots:
- The control column is anchored to the right edge at a fixed width. It
had been packed left after a fixed label column, leaving the values
stranded mid-container with dead space beside them.
- Units are no longer truncated to "mm o...". The cap fitted the common
"mm" but not "mm or %" or "mm/s² or %".
- The "from file" tick moved ahead of the control it qualifies; it used to
sit past the unit at the row's right edge, reading as unrelated.
Both the unit and the control keep fixed widths, and the tick's slot is
reserved on rows without one -- sizing any of them to content makes each
row's input land at a different x and the column comes out ragged.
Also fixes a field that could not be cleared: emptying a free-text input
dropped the key, so it snapped back to the baseline and retyping appended
to it ("0.42" + "0.5" = "0.420.5"). The number branch was fixed earlier;
the text branch -- coFloatOrPercent, coString, the vector types -- was
not, and the regression test used a number input so it never caught it.
Requires a sidecar built from orca-slicer-api 4b664b7 or later. Older
images 404 the endpoint, which is handled as the fallback above.
Slicing from Bambuddy meant taking a process preset as-is; any change
meant a round trip through Bambu Studio. The slice dialog now carries
OrcaSlicer's full process tree -- pages, groups, labels, tooltips,
ranges and defaults extracted from the slicer's own sources.
Enable/disable rules are evaluated from the slicer's own enable_if
expressions via a recursive-descent interpreter (no eval, CSP), with
enum comparisons validated against each option's declared values.
Anything undecidable leaves the field editable rather than greyed.
Overrides apply after the source's support config (#1881) and the
designer's carried tweaks (#2622), so an explicit choice always wins;
an untouched panel sends the same request as before.
Adds slice_engine as a separate setting from preferred_slicer -- where
slicing runs is a different axis from which binary the sidecar drives.
Only the sidecar engine is registered, so no picker renders yet.
An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.
Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.
The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.
Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.
Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.
The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.
Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.
is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.
login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.
Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.
The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.
MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.
The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.
Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
Projects were flat. The parent_id column and the sub-project list existed
but nothing could set a parent outside the API, and a master project's
stats only ever covered its own prints.
The project dialog gets a parent picker, and a project with sub-projects
gets a second card covering the whole tree -- jobs, parts, time, filament,
cost, and progress against every target in the tree added together. That
card is separate from the project's own stats, which keep their existing
meaning; widening them would have restated the figures of anyone who had
already nested projects over the API. Each listed sub-project carries its
own branch's roll-up, so the rows add up to the card above them.
On the Projects page a sub-project is drawn inside its parent's group
rather than as another card in the grid -- two cards columns apart cannot
show that they belong together, whatever the caption says. A sub-project
whose parent the status filter has hidden stays put and names its parent
instead.
compute_project_stats now goes through the same grouped aggregation as the
roll-up rather than its own copy of the SQL, since the two must agree.
Three things the interface made reachable:
- PATCH refused only a project as its own direct parent, so A -> B -> A
was two calls away. A cycle has no root to roll up to, and the walk
keeps its seen-set for databases that already contain one.
- A sub-project's percentage was completed quantities against the plate
target, disagreeing with the page it linked to.
- Deleting a mid-tree project orphaned its children at top level; they
now move up to its own parent.
Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.
Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.
The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.
The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
A German user got a translated modal with "Not present in this backup commit" in
the middle of it. Every tally note and preview caveat was a server-built English
sentence rendered verbatim.
Follows the backup.pathCheck contract already in use one card down in the same
component, deliberately rather than inventing a second convention: the server
sends a `code` plus typed `params` and carries the English along as the
fallback, and the client renders
`t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
what keeps a newer backend's unfamiliar code readable instead of printing the
raw key — covered by its own test.
Shapes:
- notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
but the field is unreleased in this same PR.
- GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
as the English fallback.
- _CategoryTally.note(code, message, **params), deduped on (code, params) rather
than on the rendered text, so two offline printers both keep their names. The
20-note cap is unchanged.
28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
the four per-category "No X data in this backup" strings into one, since the
category heading already renders beside it. Counts use single-form {{count}} in
the existing "N record(s)" style rather than i18next plural suffixes — nothing in
this block uses _one/_other and the parity script has extra rules for them.
Parity holds at 5737 leaves in all 13 locales.
Deliberately out of scope, and worth saying so rather than leaving it to look
like an oversight: result.message, the commit-picker subject lines and the HTTP
error strings stay English. Those also originate in the provider backends, so
code-ifying them widens the diff well past the restore service.
spoolTagKept is added here with the rest of the locale churn but is not emitted
until the next commit, so the 13-locale change lands once.
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.
Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.
The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.
Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.
Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.
Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.
Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.
Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.
Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.
Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.
The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.
Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.
Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.
The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.
The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.
Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.
The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.
completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.
Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.
Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.
The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
The list and update endpoints serialised field by field and never named
cost / energy_kwh / energy_cost, so values Bambuddy had been recording
all along went out as nulls. Both now validate from the ORM row, which
removes the chance to omit a field rather than patching the three that
were missing.
Adds a Filament Used column plus a Columns picker for Cost, Energy,
Energy Cost and Finished, persisted per browser.
Also fixes the log view being unreachable with zero archives: the empty
state ran before the view check, hiding a log that outlives the archives
it refers to.
---
Sort the Print Log by any column (#2636)
Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
column headers. Server-side because paging is: ordering the rows the
client holds would sort one page rather than the log.
Empty values are held last in both directions — Postgres sorts NULLs
high and SQLite low, so the same click would otherwise open on blanks
on one backend and values on the other. id DESC breaks ties so paging
through a low-cardinality sort can't repeat or skip a row.
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
orient / arrange form fields. An unticked box is sent by omission: the
sidecar treats any present value as truthy, so a literal "false" would
have arranged every slice.
Arrange unions with the #1493 cross-class decision rather than replacing
it, and the per-plate slice-all loop is now keyed on the arrange flag
itself — the project-wide collapse belongs to --arrange, not to the
cross-class case. The loop also covers the embedded-settings path, whose
crash-retry is suppressed there since a single --slice 0 retry would
return one consolidated plate.
Selecting several sliced files and pressing Print now creates one queue
item carrying all of them, instead of hiding the Print button the moment
a second file is selected. The printer picker is replaced by the ordered
candidate list, since choosing these files is already the answer to
"which printer" and the only question left is which is preferred.
Per-candidate configuration is the plate only. Model-based assignment
sends no AMS mapping — the printer is unknown until dispatch, where the
scheduler derives it — so a per-candidate mapping editor would collect
choices it then discards. Filament overrides stay shared: "this job
needs PETG" holds for every slice of the same job.
Adds Group as versions for durable grouping, a versions badge counting
the whole group rather than the rows on screen, and a queue card label
naming every model a pending item is waiting on.
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
and returned True, and the printer's extrusion_cali_set answer was
logged at DEBUG and dropped, so a write the printer refused was
reported to the user as saved (#2718, reporter @jmoore-skild).
The reason it could not simply be gated on: the answer itself was
wrong. Single-nozzle firmware returned result:"fail" with
reason:"invalid tray_id" on writes that demonstrably applied.
Measured against an X1C and an H2D over MQTT, the cause is the
tray_id:-1 Bambuddy itself put in the payload. Sending three
otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
succeeds, and cali_idx:-1 is accepted either way, so only that one
field is at fault. The H2D ignores the value entirely; the X1C
validates it, complains, and applies the write anyway. BambuStudio
always sends a real tray_id and defaults it to 0 for a manually
entered profile.
With tray_id:0 the acknowledgement is honest, and the printer echoes
back the sequence_id we sent -- confirmed for extrusion_cali_get,
_set and _del on both printer classes -- so it can be matched to the
write that caused it. Writes now return their sequence_id and the
routes await the verdict, turning a real failure into an error that
carries the printer's own reason. A printer that stays silent is
still treated as success: no answer is not evidence of refusal, and
firmware that never answers must not turn every save into an error.
Raises the ack to INFO. It sat at DEBUG, so the one line that
explains a failed save was absent from every support bundle -- the
same reasoning that put ams_filament_drying at INFO for #1447.
Also fixes extrusion_cali_set building its payload from
str(self._sequence_id) without incrementing first, reusing the
previous command's id. Harmless while nothing correlated on it,
fatal now that the write path does.
Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
which the K-Profiles UI previously showed as "Not reported by
printer" -- not a value anyone can save. Most printers omit the
nozzle identity from their calibration table entirely, and the slicer
treats that as Standard rather than unknown; Bambuddy now does the
same and keeps the choice editable. The field is hidden only where
the model ships a single nozzle variant, using the slicer's own rule
(len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
preset) evaluated across every bundled Bambu profile. That puts only
A1, A1 Mini and A2L on the hidden side -- it is not the single-
versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
are all single-nozzle and all carry two variants. Editing a profile
also no longer writes back an empty nozzle_id.
Wiki records that on printers which omit the field the chosen flow
type is discarded by the firmware and reads back as Standard, in
Bambu Studio as well, so it does not get filed as a bug again.
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.
The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.
It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.
Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.
Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:
cloud_profiles/bambu/user-3/{filament,printer,process}.json
cloud_profiles/orca/user-3/{filament,printer,process}.json
Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.
Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.
Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.
The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.
The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.
One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.
Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
failed after five minutes with "Slicer sidecar unreachable". The sidecar
was reachable the whole time and still slicing when we hung up on it.
SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
bare float so it covered connect, read, write and pool alike. On a single
long request that is not a health check, it is a cap on how long a model is
allowed to take. And because httpx.ReadTimeout subclasses RequestError,
expiry landed in the same handler as a refused connection and was reported
as an unreachable sidecar — so the reporter went and updated their sidecar
container, which was never the problem.
The information to do better was already being collected. _poll_progress
polls /slice/progress/{id} once a second alongside the blocking POST to
drive the live progress toast, so at minute five Bambuddy had fresh
evidence the slicer was working. It killed the request anyway.
So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline moves forward on every progress update, and only
genuine silence ends the wait. A model that keeps reporting runs to
completion however long it takes. Connect and pool keep short timeouts —
a sidecar that will not accept a connection is unreachable and should
still say so quickly.
Only a *changed* progress payload counts as alive. The sidecar re-serves
its last snapshot on every poll, so counting repeats would leave the
watchdog unable to detect a stall at all.
The window is floored at three poll intervals: liveness can only be
observed as fast as the poller ticks, so anything shorter would expire in
the gap between two polls and fail every slice instantly.
New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
default 15, range 1-240, alongside the sidecar URL and gated on
use_slicer_api like its neighbours. Sidecars too old to report progress
have no liveness signal, so for those the same number bounds total elapsed
time — the old behaviour, configurable and no longer 300s flat. The
message says which case applies and where to change it.
SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
answered throughout, we stopped waiting. Connection failures keep
SlicerApiUnavailableError. The preview slice path gets the same treatment.
Obico's ml_api container takes an optional ML_API_TOKEN environment variable.
With it set, ml_api/auth.py answers a bare 401 to any request whose
Authorization header isn't "Bearer <token>"; with it unset it ignores the
header entirely. Bambuddy never sent one, so pointing it at a protected server
meant deleting the token there — which the reporter had set for their Home
Assistant integration and did not want to undo.
Settings -> Failure Detection gains an ML API Token field. When it is empty no
header is sent, so an unconfigured install's request stays byte-identical to
what shipped before the setting existed.
This failed in the worst possible way, and that is the more important half of
the change. Obico decorates /p/ with token_required but leaves /hc/ open. Test
Connection pinged /hc/, so it reported success against a server that was
rejecting every real detection call, the settings looked right, and detection
silently never ran. The only symptom was a generic "ML API call failed" buried
in the status card.
So the test now proves what it claims. After health passes it probes GET /p/
with no img parameter: the auth decorator runs before the handler, so 401 means
the token was rejected and 422 ("Invalid request params") means it was
accepted. No inference work is done either way. A probe that itself errors
reports the token as unknown rather than as working — the UI says it could not
be checked instead of claiming success.
The detection loop checks for 401 before raise_for_status, so a rejected token
is reported as a rejected token, naming the setting and the environment
variable, instead of surfacing "401 Unauthorized" with no hint of what to do.
The message never contains the token; a test pins that.
The setting name carries "token", so the support bundle's keyword redactor
masks it with no new rule. Resolving "field omitted" to the saved token is the
route's job, keeping test_connection a pure outbound call with no database
access.
Second fix, same issue: support bundles misreported which printers Obico
watches. The bundle split obico_enabled_printers on commas and read an empty
value as "no printers". The settings UI writes a JSON array, and empty means
*all* printers — the default — so a working Obico setup showed obico_enabled
false against every printer in its own bundle. That is the reporter's bundle
exactly, and it points anyone reading it at the wrong subsystem. The bundle now
parses the setting the way ObicoDetectionService does, keeps a comma fallback
for any install that stored the legacy shape, and factors in the global switch.
The photo fired the moment layer_num reached total_layer_num. That edge is
where the printer *starts* its final layer, not where it finishes it: the
reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
three minutes and seventeen seconds and one filament change before the print
actually ended, so the frame caught the toolhead mid-print over the model.
The trigger also latched _finish_photo_captured, which locked out both the
stage-22 and FINISH triggers for the rest of the print — so on firmware that
never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
nothing could replace the bad frame.
Remove the last-layer trigger. The photo is now taken at the FINISH-state
trigger, which every model sends and which lands after the toolhead parks.
Since Bambu's end G-code drops the plate ~100mm just before that, restore the
framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
settle, capture, then drop it back so the print is as reachable as the printer
left it. Absolute is the safety argument — that Z is a height the toolhead
occupied seconds earlier, so it is inside the travel limits by construction and
leaves the nozzle above the part, and it is unambiguous across model families
because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.
The height is only trusted when two independent sources agree: the archive is
matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
layer count the printer reported over MQTT. Matching on "most recent archive
for this printer" was not safe — on_print_complete pops the _active_prints
binding concurrently, and a print Bambuddy failed to archive would have
resolved to its predecessor. A wrong height is the one failure that could drive
the nozzle into the model.
The move is additionally skipped when the print height is unknown, when a queue
item is pending for the printer, when the printer has left FINISH, and when the
new finish_photo_restore_plate setting is off.
for every FINISH-state capture — which is what shipped the mid-print photo —
the bank is used only when the dispatcher recorded that it injected End G-code
into this print, since a SwapMod snippet may have ejected the plate. The flag is
handed over in two steps (mark_pending at dispatch, adopt at print start) so it
can never outlive its print: a job started from the slicer or SD card adopts
False rather than inheriting its predecessor's answer. Those prints also skip
the plate move outright, bank or no bank.
The bank now refreshes on mc_percent advances as well as layer changes, via a
new on_print_progress callback. Layer changes stop the instant the final layer
begins, which left the #1867 fallback frame stale by the whole length of that
layer; progress keeps ticking there and freezes before the End G-code runs, so
a swapped plate still cannot reach the bank. The last-layer throttle exemption
is dropped, since it would now fire a grab on every percent tick.
On the timelapse path the moment producer returns early, so the consumer does
the restore itself before its live-grab fallback — the documented usual outcome
on P1-series, where the video has not transferred by the time the notification
goes out and the shipped photo was of an already-dropped plate. The two waits
are now derived from the settle window and the video poll timeout rather than
hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
mid-settle.
extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
It returns None for missing, unparseable, zero and negative values so callers
must treat "don't know" as such rather than defaulting.
Lets a reprint reuse the AMS slot the slicer itself picked, instead of
re-deriving one from the file's static type/color.
When a Print Queue VP has "Save AMS mapping" on, the slicer's own
live-resolved ams_mapping (from the project_file MQTT command) is
persisted onto the archive as extra_data.slicer_ams_mapping. A later
reprint can reuse it via a new "Mapping" button in the filament-mapping
panel — one click snaps every slot to the saved pick, click again
reverts to auto-match. Archive cards and queue rows get an "AMS mapping
saved" badge so it's visible beforehand. add_to_queue also falls back
to the saved mapping automatically when the caller sends no explicit
ams_mapping (e.g. a plain reprint with no per-slot edits).
The queue item's own ams_mapping (used for that dispatch) is still
captured unconditionally whenever the slicer provides it — that part is
a correctness fix, not gated behind the toggle. Only the archive
persistence for future reprints is opt-in.
Split out from the original combined PR per review: this half is
genuinely opt-in and low-risk (#2684). The dispatch-time validation
gate that keeps a stored mapping honest (#1308) changes behaviour for
every existing user and will land as its own PR.
Review fixes applied:
- _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
arm and rejected bool explicitly (isinstance(v, int) accepts bool).
- Translated the Russian docstring text to English.
- save_ams_mapping's model comment moved to a trailing comment on the
column line, matching the file's convention.
- usingArchiveMapping now resets when the plate or archive changes, so
the Mapping button can't read ON against a mapping it never applied.
- Translated "Click to change slot assignment" and "Re-read".
- add_to_queue's fallback is now called out explicitly in code comments
and covered by three new integration tests (fallback fires, explicit
mapping wins, unrelated extra_data doesn't false-trigger).
Closes#2684
Review follow-ups on the external-camera capture coalescing.
The coalescing was transplanted from camera.py, which is keyed by printer IP
and so has nothing to hide in a log line. These keys carry the camera URL, and
an RTSP camera URL routinely embeds user:pass@ - so the five new log lines
printed the password, one of them at warning level, where it reaches support
bundles. All five now go through _log_key(), which redacts before truncating:
slicing first can cut the URL short of the @ the pattern anchors on and leave
the password intact, which is why every other URL log in the module already
does it in that order.
_capture_frame_uncoalesced gained the blanket catch its camera.py counterpart
has. That is load-bearing once captures are shared: the wrapper hands one
task's outcome to every caller waiting on it and can only give a follower its
own turn for an outcome it recognises, so an escaping exception reached all of
them at once and none retried - one caller's failure becoming N. The per-type
helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the
guarantee belongs here rather than resting on their coverage. CancelledError
is re-raised ahead of it, since the wrapper distinguishes a cancelled leader
from a failed one.
test_connection reports whether it shared a capture. It reaches capture_frame
like any other consumer, so a test landing while Obico is polling got that
frame back and answered "connected" for a connection it never made - the one
answer a connection test must not give silently. It still shares rather than
forcing its own capture, because forcing one would open the second handle to a
single-reader device that this whole mechanism exists to prevent. The response
carries `coalesced`, which also gives capture_in_flight() the consumer its
camera.py counterpart has in the Diagnose tool, and the Test button says
"shared with a capture already running" instead of a bare success.
Tests 12 -> 20: an unexpected error reported as a failed capture, a raising
leader whose follower still gets a frame, the three coalesced states, and
redaction on each log line that can carry a URL. The raising-leader test
patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a
stand-in installed in the latter's place sits above the catch and would test
the wrapper against a shape it can no longer be handed.
The filament list is positional from the modal down to the CLI's
filament_N.json parts, but for a source that already carries slice_info the
requirements endpoint returns only the slots the plate consumes. A
MakerWorld model declaring four filaments and painting with slot 4 alone
therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
slot 4 sliced with the profile baked into the source, and the print came out
PLA.
The endpoint now takes full_slots, which widens that answer to every
project slot with used_in_plate flags, and only the slice modal passes it.
Print-time AMS matching shares the endpoint and keeps the used-only list, so
it still asks for exactly the spools the job needs.
The API answers 409 to any write against this provider, so offering edit,
delete and the enable toggle would promise a change that cannot land -- the
operator would click, see nothing happen, and have no way to tell why. The
controls are hidden and a lock badge names the reason instead.
Reuses settings.environmentManagedLabel, the string the Home Assistant
env-managed fields already use: same situation, same wording, and no new key
to keep in parity across eleven locales.
is_env_managed is optional on the client type so a response from an older
backend still type-checks.
Refs #2593
The P2S/X2D have two fans bambuddy didn't fully handle. On the P2S both are
add-on kits; on the X2D they ship from the factory.
1. Left auxiliary part cooling fan — not shown or controllable. It is reported
ONLY as device.airduct part id 10 (raw id 160 >> 4; FAN_REMOTE_COOLING_1 in
Bambu Studio's DevFan::ParseV3_0) and is never mirrored into a flat
big_fanX_speed field, which is why it was invisible. This is the gap
identified in #2576, where the single 'Auxiliary' fan (big_fan1 / M106 P2)
only reaches the right-hand aux fan.
2. Chamber exhaust fan — shown on every P2S labelled 'Chamber Fan'. On P2S/X2D
Bambu's firmware/UI (and Bambu Studio's FAN_CHAMBER_0_IDX) call it 'Exhaust',
and it is a kit on the P2S rather than built in.
Both are now detected from device.airduct.parts, which lists only the fans that
physically exist, so each tile appears only when the hardware is present.
- bambu_mqtt: parse airduct part 10 -> left_aux_fan_speed (None when absent) and
part 3 presence -> exhaust_fan_present; set_fan_speed() accepts index 10 plus a
set_left_aux_fan() helper
- schema / status route / printer_manager broadcast / mqtt_relay expose both fields
- POST /printers/{id}/fan-speed accepts fan=aux2 -> M106 P10, the command Bambu's
official P2S machine profiles use
- frontend: 'Left Auxiliary Fan' tile shown when reported; big_fan2 tile labelled
'Exhaust' and presence-gated on P2S/X2D, unchanged 'Chamber Fan' elsewhere
- i18n: leftAuxiliary + exhaust for all 12 locales
Verified fan -> field map on a live P2S (fw 01.02.00.00), stable across cooling
and heating airduct modes:
Part cooling -> cooling_fan_speed / airduct id 1 (built in)
Aux -> big_fan1_speed / airduct id 2 (built in)
Exhaust -> big_fan2_speed / airduct id 3 (kit)
Left aux -> airduct id 10 only (kit; forced off in heating by mode config)
Tests: airduct id-10 parsing (raw 160 -> id 10, not literal 160), id-3 presence,
base-P2S absence, diff-push survival, clamping, malformed entries, M106 P10
emission, invalid-index rejection; fan-speed API aux2->10 mapping; frontend tile
presence and labelling per model/kit.
When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".
The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.
Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.
A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.
Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract
Translated in all locales; wiki updated. Covered by backend and
frontend tests.
Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.
Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.
Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.
Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.
Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
The live Obico classification was only visible under Settings ->
Failure Detection, so tracking how detection matched an ongoing print
meant flipping between the Printers screen and Settings.
Each printer card's badge row now shows an AI badge whenever detection
is enabled for that printer, like the other health badges: gray Idle
while no print is being watched, then green Safe, amber Warning, or
red Failure while a print is actively monitored. The tooltip carries
the current smoothed score; clicking jumps to the full detection
status and history in Settings. Printers excluded from the monitored
subset show no badge.
Served by a new lightweight /obico/printer-status endpoint readable
with printer permissions alone - it exposes only the enabled flag, the
monitored-printer set, and per-printer classification, keeping ML URL
and other configuration behind the existing settings-gated endpoint.
Bark is the open-source, account-free iOS push app (self-hostable
via bark-server). Configure with just the device key from the app;
the server URL defaults to the official api.day.app relay and
accepts a self-hosted instance. Optional settings: notification
Group, Sound, and iOS Interruption Level - Time Sensitive breaks
through scheduled summaries, Critical bypasses Silent mode and
Focus, Passive delivers silently.
bark-server can wrap failures in an HTTP 200 body ({"code": 400}),
so the sender checks the body code as well as the HTTP status.
Unknown interruption levels are dropped rather than forwarded.
The Most Expensive record on the Statistics page ranked prints by
filament cost alone, ignoring the per-print energy cost Bambuddy
already measures via an attached smart plug. It now ranks by
filament + measured energy cost; prints without smart-plug data
compete on filament cost alone, as before.
Filament Trends gains an Energy Over Time chart: kWh per day (per
hour for ranges of a week or less, per week for long ranges), with
the range's total kWh and energy cost in the header. The chart only
renders when the selected range contains measured energy data, so
setups without smart plugs see no change.
The /archives/slim stats feed now carries each run's energy_kwh /
energy_cost from print_log_entries. Translated in all locales.
Covered by backend and frontend tests.
The folder tree's "sort by recent activity" and the file pane's date sort
put external (mapped/NAS) files in a near-random order instead of ls -t's
newest-first. Nothing captured the files' on-disk mtime: the sort keyed off
the DB updated_at/created_at, which for a bulk external scan is the same
scan instant for every row, so a whole block tied and sorted arbitrarily;
only rows Bambuddy had later touched individually looked "partially right."
The tree also bubbled up only immediate child-file activity, so a file added
deep in a subtree never lifted its parent folders.
- Add nullable fs_modified_at to LibraryFile and LibraryFolder (dialect-
branched migration, mirroring the #2615 dispatching_at pattern).
- External scan records each file's and directory's real os.stat().st_mtime
and refreshes it on every re-scan, so a file edited over the mount
re-sorts and existing installs backfill on the next scan.
- list_folders computes each folder's activity as a recursive newest-
descendant roll-up (post-order), so a fresh deep file lifts every ancestor.
- Folder tree sort and the file pane's date sort now use the real mtime,
falling back to created_at for managed uploads with none.
- New toolbar toggle shows/hides each item's last-modified date in the right
pane (grid + list), with strings in all locales.
Store the mtime as naive UTC to match the other timestamp columns so activity
comparisons never mix naive and aware values on either dialect. Covered by
integration tests (mtime capture, re-scan refresh, deep-file recursive bubble,
folder mtime) and a frontend test proving fs_modified_at is preferred over
created_at.
Follow-up to 0f203ce: force color match now picks the right AMS slot, not
just the right printer. The slot mapper cleared tray_info_idx when applying
an override, so on a printer holding two same-colour PLA spools of different
variants (Basic GFA00 / Matte GFA01 / Silk GFA06) it could map to the wrong
one. It now keeps the variant for force_color_match overrides (both the 3MF
and no-3MF fallback paths) so the matcher pins the matching tray, and falls
back to type+colour when that variant isn't loaded. A manual filament swap
(a preference override) still clears the idx so it matches the swapped-in
spool rather than the old one.
The printer-card queue-compatibility hint applies the same variant rule.