On a printer with an external camera, watching the live view while a print
ran meant the layer timelapse recorded almost nothing and the finish photo
went out with no image. The reporter measured 0 of 87 layer captures on one
print and 0 of 105 on another, both watched throughout. A USB camera allows
one V4L2 handle, so a capture during a live view fails outright.
The built-in camera has had this rule since #1348 and #1271: reuse the
viewer's buffered frame rather than opening a second connection. It was
never extended to the external paths, and it could not have been -- the
buffer it depends on was only ever populated by the built-in paths.
generate_mjpeg_stream yields multipart-wrapped chunks, so the route layer
could not recover the JPEG, and a guarded caller would have found an empty
buffer and skipped every time.
So the stream now publishes each raw frame through a new on_frame callback
(parallel to on_process from #2675), and the six one-shot consumers reuse
it: layer timelapse, the finish-photo moment and its background fallback,
the notification snapshot, Obico polling, and the plate check. A viewer
attached with nothing buffered yet skips that one attempt rather than
competing -- kicking the viewer off is worse than missing a frame.
on_frame exceptions are logged and swallowed, like iter_subscriber's
on_unsubscribe: buffering is a side effect and must never be able to take
the live stream down with it. The external stream's teardown now releases
the buffered frame too, ownership-checked so a concurrent viewer of the
same printer keeps its own.
Two side effects on paths not touched here, both improvements: the snapshot
endpoint and the finish-photo fallback chain consult get_buffered_frame and
can now serve an external camera's live frame. plate_detection's docstring
already claimed this behaviour while implementing it only for the built-in
fallback; that drift is resolved.
ffmpeg is spawned with stderr=PIPE and it was only read on the error
paths, so for the life of a working stream nobody read that pipe. ffmpeg
writes its banner, the input analysis, then a progress line at a steady
rate; a 64 KiB pipe fills eventually, ffmpeg blocks writing to it, frames
stop, and the stream's own 30s timeout fires -- logged as "RTSP read
timeout" with no hint that we starved it ourselves.
How long that takes is unmeasured and evidently long: one H2D upstream
ran 21m36s without stalling, and an earlier 512 B/s extrapolation of mine
was mostly the one-off startup banner. So this is a bounded resource being
treated as unbounded, not a fault anyone has reported.
_FfmpegStderrTail drains the pipe continuously and keeps a 16 KiB rolling
tail. That tail is what the error paths now report, which is better
material than before: it holds what ffmpeg said as things went wrong,
where the on-demand read returned whatever was printed first -- usually
the banner, which the summariser strips anyway.
Three readers wanted this one pipe, and asyncio rejects concurrent reads
on a StreamReader, so the collector is authoritative: it registers by pid,
_read_ffmpeg_stderr returns its tail when present and otherwise reads the
pipe unchanged, and _terminate_ffmpeg skips its own stderr drain when the
collector owns it (the collector keeps draining through teardown, which is
all wait() needs). The generator starts it after the immediate-failure
check, which reads the pipe directly because the process is already dead,
and releases it after _terminate_ffmpeg.
text() goes through _summarize_ffmpeg_stderr like every other stderr log
here, so the access code ffmpeg echoes in its input URL stays masked.
aclose() awaits the cancelled pump rather than firing and forgetting, so
no pending task survives into loop teardown.
Closing a camera view and reopening it immediately could leave the new
stream unregistered while it was running and delivering frames. The
damage was all indirect: is_stream_active() reported no viewer, so Obico
polling and snapshots opened a second camera connection against the live
view (the thing #1348 and #1271 exist to prevent); the janitor's /proc
scan found an ffmpeg missing from _active_streams and killed the live
stream as an orphan; and /camera/stop reported "Stopped 0" with a stream
running.
The fan-out stream id was f"{printer_id}-fanout" -- constant per printer,
so every successive stream shared one registry key, and the departing
generator's finally popped whatever was under it, including its
successor's entry. The same finally also cleared the per-printer frame
buffer unconditionally, discarding the new stream's frame. It needed the
two streams to overlap, which the 4s teardown made easy.
Each stream now gets its own key via _new_fanout_stream_id(), so a
generator can only clean up after itself -- the external-camera path
already does this (#2675) and this brings the fan-out path in line. The
per-printer dicts are released through _release_printer_frame_state(),
which checks that no other stream for the printer is still running; both
the RTSP and chamber-image cleanups had the same unconditional pop.
Also hoisted time and uuid to module level and dropped four
function-local `import time` statements. A local import shadows the name
for the whole function, so any use on a branch that doesn't reach the
import raises UnboundLocalError -- a real hazard in camera_stream, whose
external-camera branch imported both while the RTSP path needs them too.
A test pins camera_stream as free of function-local imports.
Closing a camera view logged "ffmpeg didn't terminate gracefully,
killing" followed by "ffmpeg did not exit within 2.0s of SIGKILL;
abandoning wait", on every single close. Both waits expired every time,
so teardown took a fixed 4.00s -- and since the firmware allows one
camera connection, that was 4s in which nothing else could use it.
ffmpeg is spawned with stdout and stderr as pipes and the teardown paths
have stopped reading them, so it sits blocked in write() on a full 64 KiB
pipe. SIGTERM cannot be acted on there: the handler only sets a flag that
the main loop polls, and the loop never gets back to the check. SIGKILL
does kill it, but asyncio resolves Process.wait()'s waiter through
_try_finish(), which requires every pipe transport to report
disconnected; paused, unread pipes never reach EOF, so wait() blocks with
returncode already set. A negative-control test shows returncode=-9 at
the instant the abandon fires.
Draining both pipes while stopping the process fixes both halves: 4.00s
becomes ~0.15s. The signal ladder and its bounds stay as backstops, so a
genuinely wedged process still cannot hang a stream, a Stop request or
the janitor.
This corrects _FFMPEG_KILL_TIMEOUT's premise and #2580's conclusion. That
12-hour hang was the unbounded form of this same self-inflicted stall, not
an ffmpeg stuck in uninterruptible I/O -- the process observed doing it
was in state S, which cannot survive a delivered SIGKILL. Bounding the
wait capped the symptom without removing the cause.
Bambu firmware allows exactly one camera connection. The existing guards
(is_stream_active / try_get_active_buffered_frame, #1271 and #1348) only
stop a one-shot capturer from competing with the fan-out broadcaster.
Nothing coordinated the capturers with each other, so with no viewer
attached every consumer correctly concluded it was not competing with a
viewer and then collided with the others. On the reporter's P2S an Obico
poll and a snapshot opened two RTSP sockets 207 ms apart, which knocked
over the fan-out stream feeding the camera wall; it was then reaped for
having received no frames for 58s.
capture_camera_frame_bytes() now coalesces: the first caller opens the
connection, callers arriving while it is in flight await the same result.
Eight paths reach that function independently - Obico polling, the
snapshot route, the finish-photo moment and its disk-writing sibling,
plate detection, the camera test and the diagnose tool - so the
single-flight sits at the bottom of the stack and no call site changes.
Keyed by IP, since that is what the firmware's limit applies to and the
function never sees a printer_id. The key excludes the timeout on
purpose: the call sites disagree about it, from 10s to 30s, so keying on
it would mean the Obico-vs-snapshot pair from the report never coalesced
at all.
It coalesces, it does not cache. A call arriving after the previous
capture finished still captures fresh, because plate detection and the
finish-photo path judge a running print from these frames and a stale one
there is worse than a slow one - #1397 was a finish photo taken seconds
late showing the bed already lowered.
Each caller waits on its own deadline rather than inheriting whichever
one happened to open the connection, and shield() means giving up leaves
the capture running for whoever else is still waiting. A follower whose
leader fails takes a turn of its own instead of inheriting a failure it
never had a chance to avoid; the leader has finished by then, so there is
nothing left to compete with. Bounded at two rounds. That also covers the
follower whose timeout is longer than the leader's, which coalescing
alone cannot. Cancellation is disambiguated via leader.cancelled(), so a
follower's own cancellation propagates while a cancelled leader is
treated as a failed one.
The leader is deliberately not wrapped in a second wait_for: the
implementation already enforces the timeout internally, where it can also
kill the ffmpeg process, and an outer deadline would abandon the
subprocess instead of killing it.
The diagnose tool now marks a stage whose frame came from a capture
already in flight as coalesced_capture. The pass is real evidence the
camera works, but duration_ms is then mostly time spent queueing, and a
diagnostic must not report a connection it never opened - the same reason
that file declares its live_stream_active shortcut instead of quietly
passing. Failures are not annotated, since a follower whose leader fails
goes on to capture on its own.
The Filament Mapping panel reported "(Ready)" with a green tick for a slot
where the slice wanted dark red and the auto-matched tray held dark green.
Manually picking that same tray reported the mismatch correctly, which is
what made it obvious something was inconsistent.
Auto-match ranks candidates by tray_info_idx first, and a uniquely-matching
preset was accepted as definitive on the premise "same preset = same spool =
same colour". The preset names the variant, not the spool: GFA00 is PLA
Basic, GFA01 PLA Matte, GFA17 PLA Translucent, in every colour Bambu sells.
The reporter's own bundle has eight GFA00 trays in eight colours. With one
Matte spool loaded, every Matte requirement idx-matched it and the colour
comparison was never reached - which is why this surfaced on PLA Matte and
not on Basic, where several spools are usually loaded and the match falls
through to the branch that does compare colours.
The verdict now comes from the tray that was selected rather than from which
rule selected it, and both branches share one comparison so they cannot
drift apart again. Selection is unchanged - the right variant still wins per
mismatch and the slot stays selected.
A requirement with no colour at all is treated as satisfied rather than
mismatched; 3MFs that omit it parse to "" and there is nothing to disagree
with. That also affects the manual branch, which used to flag it.
No dispatch change: _get_missing_force_color_slots already required an exact
colour, so force colour match was gated correctly throughout.
Follow-up to #2680: the calendar toggle only put dates on the file pane, so
the folder tree had no way to show the timestamp it was already sorting on.
FolderTreeItem now takes showModified and renders latest_activity_at under
the folder name, threaded through the recursive call so nested folders get it
too. No backend change - the field was already on the wire from the sort fix.
Folders are labelled "last activity", not "last modified", and get their own
i18n key. The value is the newest timestamp among the folder, its files and
everything below it, so a folder can read as newer than its own directory
mtime - calling that "modified" would look like a fresh instance of the
ls -lt mismatch the issue was originally about. Folders with no activity
render nothing rather than an Invalid Date placeholder.
The name span moved into a flex column so the second line does not disturb
the row's link badge, file count or kebab menu. That broke a folder-delete
test that reached the row via parentElement, now fixed to use closest().
Separately, the #996 collapse describe left an implementation on the
module-global localStorage.getItem mock, which silently collapsed the folder
tree for every describe after it. It resets in afterEach now; without that,
any later test asserting on nested folders fails for reasons unrelated to
what it is testing.
mqtt_relay reads state.left_aux_fan_speed, but the SimpleNamespace fixture in
test_plate_clear_mqtt_notification enumerates its fields explicitly, so the two
status-payload tests raised AttributeError. I updated the equivalent fixture in
test_printer_manager_status_broadcast and missed this one — running only the
touched suites is what hid it.
Also addresses the round-2 review notes:
- exhaust_fan_present: documented that the H2 series reports part 3 too, so the
flag is not model-specific despite the name.
- Mask the part id after shifting, matching get_flag_bits(id, 4, 8), for
consistency with the state decode. No behaviour change for any observed id.
- Noted the unmapped H2 id 6 beside the id branches.
- Reject fan=aux2 when the printer reports no left_aux_fan_speed, so a POST
against an A1 no longer sends M106 P10 for absent hardware. The UI already
hid the badge; this closes the same hole on the API.
- Added a test asserting EXHAUST_FAN_LABEL_MODELS and the frontend's
MODELS_WITH_EXHAUST_LABEL cannot drift apart.
The hook is mounted globally in WebSocketProvider, so refetchInterval on its
per-printer status queries added one request per printer every 30s on every
page. The Printers page already runs that fallback on the same query key, and
useWebSocket writes ['printerStatus', id] straight into the cache, so the poll
bought nothing outside the Printers page and cost a request per printer per
tab everywhere else.
Also captures document.title at mount instead of restoring to a hardcoded
'Bambuddy', so the default no longer has to be kept in sync with index.html.
jsdom has no canvas backend, so getContext('2d') logged a "Not implemented"
jsdomError with a full React stack on every run of the hook's tests, and the
favicon branch bailed on the null context and went untested. Stubbing
getContext/toDataURL removes the noise and lets the ring code run, so the
favicon swap and the restore-on-toggle-off path are now asserted.
Telegram groups with Topics enabled always received notifications in the
General topic, since only Bot Token and Chat ID were configurable. Splitting
notifications per printer meant running a separate chat for each one.
The Telegram provider now takes an optional Forum Topic ID - the last number
in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left
empty, nothing changes.
The value is coerced to an int once in _send_telegram and attached to both the
sendMessage JSON body and the sendPhoto form data. That ordering matters:
Telegram rejects a string message_thread_id in the JSON body while accepting
one in the multipart call, so passing the raw form value through would have
worked for thumbnail notifications and 400'd for plain-text ones. A
non-numeric value is rejected in the form and again server-side before any
request goes out.
No migration - provider config is a JSON blob.
Adds Forum Topic ID plus help text to the Telegram section of the provider
dialog, translated in all 13 locales. Backend tests cover omitted / blank /
int-typed / non-numeric values and both send paths; frontend tests cover the
field being optional, absent for other providers, round-tripping on save, and
blocking save on a bad value.
Optional and off by default, toggled under Settings -> Appearance. When enabled, the browser tab shows the soonest-finishing print's percentage plus a green progress-
ring favicon, updated live over the existing WebSocket. The preference is stored per-browser in localStorage.
Adds the usePrintProgressTitle hook (with tests), a ThemeContext preference, the Settings toggle, i18n strings for all locales, and a README entry.
The uk locale was inserted before ru in i18n/index.ts - in the import block,
the resources map and availableLanguages. Locales are appended to those lists
as they land (tr, then ru on 2026-07-19, then uk on 2026-07-28), and
SettingsPage renders availableLanguages in array order, so the picker showed
Ukrainian above Russian while every other entry followed the order it was
added.
Moves uk to the end of all three lists. SUPPORTED_LNGS is sorted
alphabetically and already had uk in the correct position, so it is unchanged.
Frontend-only, no behaviour change beyond the picker's row order.
Subprocess output and user-supplied URLs are scrubbed of credentials
before they reach the application log. Adds a shared redaction helper in
core/logging_filters and routes the existing support-bundle sanitizer
through the same pattern.
Timelapse was on, the video never reached the archive, and Scan for Timelapse
found nothing afterwards. Across 247 support bundles this was the norm, not an
edge case: 457 automatic scans scheduled, 262 attached.
The scan looked four times over ~65s. The attempt that found the video was #1
272 times, then 17 / 13 / 13 — flat against the cutoff, not decaying, i.e.
files were still arriving when we stopped. What ran afterwards searched for the
print name inside the filename; Bambu only writes "video_<timestamp>", so it
fired 159 times and matched zero.
The manual Scan had no baseline at all and matched on filename timestamp, FTP
mtime, or "there is only one video" — all reading a clock a LAN-only printer
cannot sync. The reporter's P1S was six and a half days out.
- Poll for minutes instead of ~65s; drop the name-match fallback.
- Persist the print-start baseline on the archive, so the diff survives a
restart mid-print and the manual Scan runs the same comparison. With a
baseline present the clock-based strategies are skipped entirely — they can
only turn an honest "pick one" into a confident wrong answer.
- When several files are new (a previous print's video landing late), exclude
the ones already attached to another archive instead of ordering the
candidates. Ordering could only be done on the printer's clock.
- Delete the video from the printer once archived. Keeps /timelapse to
unclaimed files, which is what makes the diff unambiguous, and stops P1S
cards filling with AVIs.
- Gate that delete on a verified transfer: download_file now compares against
the size from the listing. An FTPS connection closing early does not always
raise, so a partial buffer was being attached as a complete video — which
would also have been the one case where deleting the source lost data.
Bounded twice on purpose: wall-clock deadline plus a derived round cap, since
the deadline stops bounding the loop as soon as the sleeps are shortened.
Per-round logging only speaks when the listing changed — 31 rounds of full
listings would bury the interesting line in the support bundle.
Migration adds print_archives.timelapse_baseline as JSON, spelled the same on
both dialects so a migrated database matches a fresh one.
-----------
fix(finish-photo): add the timelapse frame to the archive after the notification (#2704)
When a print records a timelapse, its last frame is the better finish photo:
the firmware stops recording with the toolhead parked and before the end
G-code drops the bed, where a live grab at that moment catches a lowered
plate. Bambuddy waited 60s for the video and then gave up, because the
print-complete notification blocks on that photo and holding a notification
for minutes is worse than sending it with the live grab.
P1-series printers write MJPEG AVI rather than H.264 MP4 and serve it slowly.
Measured over 261 attaches in the support bundles: P1S median 33s, p90 167s,
worst 546s, while every other model finished inside 26s. So the printers that
most needed the better framing were the ones that never got it.
Keep the notification on the same bound, and keep waiting off to the side.
_capture_finish_photo_from_timelapse now reports whether it ran out of time or
concluded — a video that landed and failed extraction is not worth retrying,
one that never arrived is. On the first, schedule a background task that waits
up to 15 minutes and inserts the extracted frame at the front of the archive's
photo list, where the gallery opens.
The live grab stays on disk: the notification already links to that exact
file, so removing it would leave a broken image in Discord or Telegram.
The length check proves we received what the listing said, not that the file
was finished. The first look happens ~5s after the print ends, while the
printer may still be writing, so a growing file can be listed short, served
short, and pass. Re-list after the download and only accept the video once its
size has stopped changing — a failed re-list counts as not settled, since
"could not check" must not mean "safe to delete".
The finish photo needs a "printing done, toolhead parked, filament unload
not started" moment. stg_cur=22 was meant to be it (#1721) and fires on no
model in the field: across 247 support bundles there is not one
FINISH PHOTO MOMENT (stage-22), including the 2026-06-13..07-08 window where
it was the only pre-FINISH trigger in the code — 104 captures on A1, A1 Mini,
H2C, H2D, P1S, P2S, X1C and X2D, all of them the FINISH fallback.
A replacement can't be designed from the bundles we have. Out of that window
Bambuddy parses only stg_cur and mc_print_sub_stage; every other stage/action
field arrives and is dropped unread. The candidates that sound right
(print_real_action, mc_action, mc_stage) are absent from A1/A1 Mini/P1S
payloads, so none of them can be the universal answer alone.
Dump the raw fields for the window between the last object layer and
gcode_state=FINISH at DEBUG. Opens on the first end-of-print signal (last
layer, progress >= 99, or no remaining time) so a dropped layer_num packet
doesn't lose it, logs only what changed frame to frame, closes on the
transition out of RUNNING, and arms once per print.
Instrumentation only: gated on DEBUG being enabled, read-only against printer
state, wrapped so it cannot break ingest, and capped at 400 frames per print.
The probed fields are stage codes, counters and bitfields — nothing
identifying, and no access code.
A printer with a wrong access code gave no explanation anywhere. The connect
callback's failure branch was a bare `state.connected = False`, discarding the
CONNACK reason code the printer had just sent, so the only trace was paho's
follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
which is exactly what a powered-off printer produces. In the report behind this
fix one of three printers had been in that loop for the whole capture, and
neither the log nor the support bundle could say why.
Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
reason codes 134 and 135. Both are now logged with the printer's own reason
string and, for those two, the remedy: the access code is regenerated whenever
LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
The access code itself is never logged -- it would land in every bundle.
The reason is kept on the client as a stable slug and plumbed through
test_connection into the connection diagnostic, which now distinguishes two
cases it previously conflated. "The printer refused our credentials" is
asserted only when the printer said so; when all Bambuddy knows is that there
is no session, the text hedges and names the alternatives (rebooting, or
already at its limit of simultaneous connections). The old wording claimed the
access code was most likely wrong in both cases.
Frontend needed no change -- ConnectionDiagnostic already renders
`<status>_<reason>` variants with fallback to the plain per-status text, so an
unrecognised slug degrades to today's wording rather than a missing key.
The scheduler only writes waiting_reason on the model-based assignment
path, so a job pinned to a specific printer sits behind a running print
with no marker at all. Every such job rendered an identical "starts now"
ETA that was wrong by the length of everything ahead of it.
Decide eligibility on the page instead: an item gets an ETA only when its
printer is idle and it is the item the scheduler would dispatch next,
following the same ordering the scheduler uses. Staged and future-
scheduled items do not block the item behind them, matching the
scheduler, and items conditional on a previous print are excluded.
The value also froze at first render, since react-query's structural
sharing keeps the queue reference stable and nothing re-rendered the row.
formatETA now accepts a base instant and the page drives it from a 30s
clock shared by every visible row.
Retire the borrowed printers.estimatedCompletion tooltip for a queue key
that says what the number means, translated into all 13 locales.
A Kubernetes Secret written as a block scalar carries a trailing newline, and
the schema bounds the four required variables by max_length only, so an
unstripped issuer_url was stored and enabled and then raised httpx.InvalidURL
on the first click of the SSO button -- the authorize-time failure the
all-or-nothing rule exists to prevent. Whitespace-only values got through the
same way, contradicting the reader's own "an empty required var counts as
unset". The optional variables have always treated blank as unset; the
required ones now do too.
Also registers BAMBUDDY_LOCAL_LOGIN (#1589) in the typo guard, which logged
"possible typo" for it on every boot while listing every BAMBUDDY_OIDC_*
variable as legitimate.
Promoting env_bool to strict rejection made BAMBUDDY_LOCAL_LOGIN=on raise
EnvOIDCConfigError uncaught on the login/forgot-password path -- a 500 on
the exact recovery endpoint the bypass exists to keep open. env_bool gains
a strict flag (default True for the startup OIDC reader); the local-login
caller opts out so an unrecognized value falls back to "off" instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q8EAf9Rj7ZHL92sPnXYxy
_env_bool returned the default for anything outside {true,1,yes}, so
BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
BAMBUDDY_OIDC_ENABLED=on silently disabled the provider -- the exact
opposite of what .env.example claimed. Unrecognized values now raise
EnvOIDCConfigError, caught in _apply_env_oidc_provider the same way a
bad DEFAULT_GROUP or a ValidationError already is: logged and left
running, never released on a typo.
Also promotes _env_bool to env_bool now that it has a call site in
auth.py, and corrects the boolean-parsing sentence in .env.example.
BAMBUDDY_OIDC_SCOPES, _EMAIL_CLAIM and _ICON_URL fell back to their
default only when the key was absent, so `BAMBUDDY_OIDC_ICON_URL=` in
a compose file (as .env.example ships it, commented) reached the
schema validator as an empty string and got the whole provider
refused. default_group already treated blank as unset; these three
now follow the same rule.
While the Settings page was mounted it held its own copy of every
setting and synced it from the server exactly once, on first load
(:887-900). A debounced effect then diffed the live ['settings']
cache against that copy and PUT all 77 keys it manages on any
difference, with no way to tell a user edit from a value that had
changed on the server. Anything written server-side while the page
sat open was silently reverted ~500ms later (#2716, reporter
@jmoore-skild).
No interaction was needed to trigger it. The query inherits a 60s
staleTime and react-query's default refetchOnWindowFocus, and ~30
other observers share the key, so a window refocus or a refetch from
any of them moved the cache and the page wrote its page-load snapshot
back over all 77 keys -- showing "Settings saved" while doing it.
The page now tracks the last server snapshot it reconciled with. A
field still equal to that baseline has not been touched since, so a
newer server value is adopted; a field the user has edited keeps
their value and is saved over the top, so the newer of the two writes
wins either way. Typing into a text field while a refetch lands stays
safe, which is what the previous behaviour was protecting -- an
in-progress edit is by definition different from the baseline.
The baseline is seeded from the raw server row rather than from the
copy the page patches a browser-detected external_url into, so that
detection still reads as a local change and is still persisted.
The payload builder and the comparison key lists are unchanged. The
diff simply measures against the baseline instead of the live cache,
so no field can silently stop saving.
Removing the adoption step was verified to reintroduce the revert,
and removing the post-save baseline advance to reintroduce a resend
loop; both are covered by frontend tests asserting on the request
bodies rather than on rendered values.
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.
Add K-Profile built its Filament dropdown from the profiles already on
the printer, so on a printer with none the field was empty, required
and unsatisfiable (#2719, reporter @jmoore-skild). The modal's own
hint described the dead end: create the profile in Bambu Studio first.
The dropdown now uses the app-wide lookup order -- local imported,
Orca Cloud, Bambu Cloud, hardcoded built-in table -- same as the AMS
slot picker and the SliceModal tier groups. The built-in table is
compiled into the backend, so the list can never be empty: a new
printer with no cloud account and nothing imported still gets a first
profile.
Not fixed the way the report suggested. Seeding from
/printers/available-filaments would have offered only what happens to
be in an AMS right now, which on the reported printer is nothing; its
tray_info_idx is empty or a cloud user preset rather than a filament
id; it aggregates across every printer of the same model; and it is
gated on QUEUE_CREATE, which the K-Profiles page does not hold.
The printer indexes its calibration table by filament_id, so the
picked preset is reduced to one before anything is sent. Built-in
entries and Bambu official cloud presets carry one; a cloud user
preset needs its detail fetched (never base_id -- that collapses a
custom preset onto its inherited generic, #1053); imported and Orca
presets have no Bambu id at all and take the closest generic for
their material, via the same table the AMS slot configure flow uses
so the two agree. A filament that resolves to nothing is refused with
a named error rather than written under a wrong id.
Collapses duplicates from two separate causes. A cloud account
carries one copy of each filament per printer model, and with the
"@BBL <model>" suffix stripped for display those rows are
indistinguishable -- deduped within each tier by resolved filament id,
by display name for user presets that have none. Cloud setting_ids
also carry a "_NN" variant suffix, so the built-in tier's
already-covered check never matched and listed the same filament
again; the bare id is now recorded alongside.
Groups the options by source with an optgroup per tier, styled in
index.css: browsers render optgroup labels small, grey and italic,
which buries the one thing distinguishing a "Bambu PLA Basic" you
imported from the one the built-in table ships.
Drops the second getKProfiles(printer, "0.4") query that existed only
to seed the old dropdown. It ran concurrently with the main fetch
whenever a non-0.4mm nozzle was selected -- the two-requests-in-flight
case that made K-profile fetches time out.
---
fix(ui): cancel a dialog's deferred close when it unmounts
The AMS slot configure and K-Profile dialogs hold a success state
briefly and then close themselves -- 1.5s to 4s after the command
goes out, so the printer has time to process it before the list
refetches. Each did that with a bare setTimeout closing over setState
and the parent's onClose, and nothing cancelled it.
The timer therefore ran whether or not the dialog was still there.
Dismissing it inside that window, or the printer card re-rendering
underneath it, left a pending close that fired later and dismissed
whatever dialog was open by then. It also threw outright when the
surrounding environment was gone first: a test tearing down its DOM
before the 1.5s elapsed produced "ReferenceError: window is not
defined" out of react-dom's resolveUpdatePriority, reported as an
unhandled error against a suite that otherwise passed.
Routes all five through a useCancellableTimeout hook -- two in
ConfigureAmsSlotModal, three in KProfileModal, the latter with the
longest windows and so the widest exposure. Scheduling replaces any
pending timer and unmounting clears it.
Every K-profile came back as 0.4mm on printers running any other
nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer
puts nozzle_diameter on the extrusion_cali_get envelope only; the
per-filament entries carry setting_id, filament_id, name, k_value,
n_coef and cali_idx, and nothing else. The parser read the field per
entry with a hardcoded "0.4" fallback, so the fallback fired on every
profile of every response. The envelope value was already in scope,
read into response_nozzle and used only to match the request.
This never reproduced on H2D because that firmware does include the
field per entry. Both construction sites are in the same handler, so
the code path is shared; what differs is the payload, and every
single-nozzle model omits it.
The display was the least of it. Editing is delete-and-re-add on
single-nozzle printers, and the dialog rebuilt nozzle_id and
nozzle_diameter from its own greyed-out selects, so saving an
untouched 0.6mm profile rewrote it on the printer as HH00-0.4.
Deleting aimed extrusion_cali_del at the wrong nozzle the same way.
Both now pass through what the printer reported. The cali_idx cascade
in inventory.py, spoolman_inventory.py and spoolman.py matches on
nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the
printer-side entry and the assignment silently failed to stick --
that is the "cannot auto-map a K-profile" half of the report, fixed
at the source without touching those three call sites.
nozzle_id has no source in the payload at all, and state.nozzles
carries material (hardened_steel), not flow, so it cannot honestly
produce HH/HS. Rather than keep inventing one, the UI now says the
printer did not report it: the card shows the diameter alone, the
dialog shows "Not reported by printer", and the High Flow / Standard
filter is hidden instead of being offered as a control that can only
ever empty the list. Import stops stamping HH00 on profiles whose
source reported none.
Also correlates K-profile requests by sequence_id. Responses were
matched by nozzle diameter through a single shared expectation slot,
so a second request overwrote the first's and the first's valid
answer was discarded as a mismatch -- the "Failed to get K-profiles
after 3 attempts" in the same logs, with the printer having answered
correctly both times. Pending state is now one entry per request,
keyed by the id we already send, with the nozzle match kept as a
fallback for firmware that does not echo it back.
Fixes the flow-type select naming a new profile with the opposite
label, which contradicted the identical expression 44 lines above it.
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.