Reporter on X2D set a 1h AMS dry; the printer powered off seconds in.
Support log: every "Sent drying command duration=1" was followed 3-9s
later by "AMS 0 drying complete (dry_time 60 -> 0)" -- the completion
callback fired right after drying started, arming smart-plug auto-off.
Root cause: the tray-bearing branch of the AMS partial-update merge
rebuilt the unit as {**ams_unit, "tray": merged_trays}, never spreading
existing_unit. Tray-bearing partials carry no drying fields, so dry_time
(and info) was dropped; the falling-edge detector read the absent field
as 0 and saw a false 60->0 edge.
- Merge: tray branch now spreads existing_unit first, preserving
dry_time / info / humidity / temp across tray-only partials. Matches
the no-tray branch. Also fixes dry_status/dry_sub_status UI flapping.
- Detector: only evaluate the falling edge when dry_time is explicitly
present and parseable; skip otherwise without updating state.
The pre-insert MQTT probe added in 0.2.4.2 (b51598ea) had two bugs that
compounded on P1S firmware specifically:
1. Fixed 2-second sleep was too short. P1S broker + TLS handshake
routinely needs 3-5s to surface CONNACK on a cold MQTT session (same
firmware family with the documented "broker stops publishing but TCP
stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected
a printer that would have connected fine. H2C's broker is snappier and
cleared the 2s window without trouble — which is why the reporter's
H2C added without issue and only the P1S misbehaved.
2. client.disconnect() ran synchronously on the asyncio thread.
BambuMQTTClient.disconnect() ends in paho's loop_stop() which joins
the network thread; if that thread was still mid-TLS-handshake to the
slow P1S socket when teardown ran, the join blocked the asyncio thread
for as long as the handshake took to complete or fail. POST /printers
wedged, every other HTTP request queued behind it, Docker healthcheck
timed out — user-visible symptom: "the container hangs."
Fix:
- Replace the fixed sleep with a polling loop (8s budget, 200ms tick,
early-returns the moment state.connected flips True). Slow brokers
get the headroom they need; happy-path connects still finish in
~1-2s. Constants exposed as PROBE_TIMEOUT_SECONDS / PROBE_POLL_
INTERVAL_SECONDS class attributes so tests can dial them down.
- Move client.disconnect() to await asyncio.to_thread(...) so paho's
thread-join can never block the event loop.
The empty-card-report-prevention goal of the original probe stays
intact: a genuinely wrong access code still results in connected=False
after the 8s budget, the 400 with code=printer_connection_failed
still fires, the row is still never persisted.
Python 3.13 negotiates TLS 1.3 by default. The P2S firmware 01.02.00.00
vsFTPd build doesn't tolerate TLS 1.3's async session-ticket model on
the FTPS data channel — session resumption races, the data channel gets
torn down mid-stream, uploads land truncated at a chunk boundary, and
the printer replies 426 instead of 226. Visible to the user as "unable
to parse 3mf file" 30 s into the print.
Capping the SSL context's maximum_version to TLS 1.2 makes session
resumption synchronous and uploads complete normally.
Follow the per-model pattern established by camera_profiles.py in the
#1395 follow-up: add backend/app/services/ftp_profiles.py with a frozen
FTPProfile dataclass and a per-model registry. Only P2S (display name
+ N7 SSDP code) gets the cap today. X1C, H2D, P1S, A1 stay on negotiated
TLS 1.3 — the maintainer's dogfooded printers see zero behaviour change.
PR #1434 CI flagged 5 B402 (ftplib import) in test_bambu_ftp.py and 2
B108 (hardcoded /tmp) in test_print_start_assigns_printer_id_to_vp_archive.py.
Both are intentional in tests: the FTP client tests need real ftplib
exception classes to construct mock 426 responses, and the /tmp path is
a MagicMock attribute never written to. Marked with `# nosec B402` /
`# nosec B108` plus a one-line justification each, matching the
convention from c2630399.
Reporter — the same person who originally requested the labels
feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
dimension didn't actually fit any variant of the MakerWorld AMS
Filament Label Holder (model 752566) it advertised. Two new
presets replace it:
- ams_holder_74x33 (74 x 33 mm) matches the printable label STL
bundled in the MakerWorld project
- ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
the reporter validated on bench
Both cross the 20 mm height threshold so they land in the roomy
layout branch — swatch on the left, QR on the right, multi-line
text (brand, material, hex code, spool ID) in the middle. The
old 30x15 mm preset couldn't fit a QR code; the new ones do.
No DB migration: the preset name was never persisted. Callers
scripting the old ams_30x15 value get a clean 422 at the route's
Literal validator with the new valid values listed.
i18n: replaced inventory.labels.templates.ams.{label,hint} with
amsHolderSmall and amsHolderLarge across all 8 locales with real
translations; parity guard cleaned of the stale English-fallback
cognate entries. Parity holds at 4856 leaves per locale.
Tests: backend label renderer + integration tests cover both new
presets; LabelTemplatePickerModal test updated for the 6-button
grid and the new template value in the API-call assertion.
Previous daily build (1fac0276) tightened the post-STOR voidresp
handler to fail on any ftplib.Error, stopping Bambuddy from
sending a print command for a truncated 3MF. Reporter
(@enjoylifenow on a P2S) then confirmed — after a clean SD-card
filesystem check, reformat, and power cycle — that v0.2.4.1
worked on the same hardware. That proves the 426 returned by
this firmware revision is noise: the TLS data-channel close
races the 226 confirmation, server reports failure, file is in
fact on the SD card.
Reverting wholesale would re-introduce the silent-truncation
bug from the original fix. Narrow the rule instead: after an
ftplib.Error from voidresp, run an FTP SIZE against the upload
path. SIZE matches the local file size → warn and proceed
(the reporter's case). SIZE mismatch, or SIZE itself raises →
fail loudly with full diagnostics (the original tightened
behavior — preserved).
Applied identically to upload_file() and upload_bytes() so the
A1-compatibility manual-transfer path is covered.
Tests: two regressions from the previous round renamed and
split into intact / truncated / size-check-fails. Intact-file
tests inject SIZE explicitly because pyftpdlib only flushes on
a clean voidresp — which can't happen when we monkeypatch
voidresp to raise. Docstring spells that out. 87 FTP unit tests
green; 118 FTP-touching tests across unit+integration green;
ruff clean.
The View-Timelapse-greyed-out behavior #1417 was originally
about stays untouched; once the reporter confirms upload
reliability is back, that diagnosis continues on a healthy
install.
Two-part fix for the #1322 follow-up by @RosdasHH.
Data layer.
The previous narrow heuristic in printer_manager.py only caught
the bare {"id": N} payload firmware sends right after a printer
restart. In steady-state operation — and on the more common
post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
populated payload and signals emptiness via the tray_exist_bits
bitmask. We already parse that bitmask and use it to wipe stale
tray_type / tray_color / tag_uid fields, but never touched the
state field, so downstream readers (printers.py API serializer,
inventory.py's tray_state in {9, 10} short-circuit, AMS card)
saw state: null and had to guess from absent payload fields.
Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
uses == not `in {...}` so a string would silently miss and the
reporter's deadlock would come back) to the outer `if not
slot_exists` branch, so the bitmask path now writes the
canonical "no spool" code for every empty slot regardless of
stale fields. The narrow heuristic in printer_manager.py:797
stays as belt-and-suspenders for any MQTT path that doesn't
flow through _handle_ams_data.
UI layer.
With the data flow now consistent, the AMS slot card renders
physically-empty slots distinctly from reset slots, per
reporter's mockup. New helper getEmptySlotKind(tray) returns
"physical" (state ∈ {9, 10}), "reset" (any other empty state),
or null (loaded). The inline label below the slot circle reads
"Empty" for physical and "Reset" for reset; pre-fix both showed
an em-dash. FilamentSlotCircle gains an emptyKind prop that
picks a quieter dashed border colour for reset slots so the
visual hierarchy reads loaded > reset > physically empty.
EmptySlotHoverCard gains a kind prop and switches between
"Empty slot" and "Slot reset — no spool assigned".
Step 2 of the camera architecture overhaul agreed after #1395. When
the camera viewer hits its error state OR before a print at any
time, a Diagnose button runs a staged check against the printer and
renders the result inline: which stage failed, how long it took,
and a translated remediation hint. Cuts off the "user opens a
'camera broken' ticket → ask for support bundle → triage" loop at
the user's screen.
Backend
- New `backend/app/services/camera_diagnose.py` orchestrator with
CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
- New POST /printers/{id}/camera/diagnose route in camera.py.
- Stages:
tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
with 3 s timeout. Distinguishes timeout, refused, and host-
unreachable into distinct summary codes so the frontend can
show a precise remediation (firewall vs LAN-only off vs
wrong IP).
first_frame — captures one JPEG end-to-end via the existing
capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
first keyframe collapse into one stage; the user-facing
answer is the same regardless of which sub-layer failed.
- Live-stream shortcut: when a viewer is currently watching the
camera with a buffered frame < 10 s old, the diagnostic skips
the real test and returns live_stream_active_healthy. Opening a
fresh socket would kick the live viewer off on single-camera-
connection firmwares (the #1348 reconnect-storm trigger), so we
trust the real-world evidence instead.
- Response surfaces protocol, port, and profile name for support
triage — lets us ask "what does your modal say?" instead of
"send the support bundle".
Frontend
- New CameraDiagnoseModal renders one row per stage with green-
check / red-X / grey-skipped icons, the per-stage duration in
ms, a remediation banner styled by overall status, and a Run
again button.
- Two entry points:
1. The viewer's error overlay grows a Diagnose button next to
Retry. Retry stays the primary action; Diagnose is the
escape hatch for users who can't see what's wrong.
2. A stethoscope icon in the viewer's always-visible control
bar, between Refresh and Fullscreen. Pre-flight testing
("did my firmware update break the camera?", "is the
camera up before I send a print?") doesn't require waiting
for the stream to fail first.
- Also lifted the previously-hard-coded "Camera unavailable" /
"Retry" strings into camera.unavailable / camera.retry so the
error UI is fully translated alongside the new keys.
Reporter on a P2S running firmware 01.02.00.00 saw the camera connect
for a few seconds then time out, repeating. P1S on the same install
worked fine — different protocol (chamber-image port 6000 vs RTSP via
ffmpeg).
The P2S RTSP path was running ffmpeg with `-probesize 32
-analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower
keyframe pacing means ffmpeg can't lock onto the stream within 32
bytes — its own stderr says "consider increasing probesize" before
giving up after ~2s. Bambuddy reconnects, cycle repeats.
Instead of bumping the globals (which would regress every other RTSP
model's startup latency), this lifts the per-model tuning into a new
`camera_profiles` registry. CameraProfile dataclass holds the
previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max,
rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future
per-model flags). get_camera_profile(model) returns the model's profile
or DEFAULT_PROFILE.
Default profile preserves the historical X1/H2 fast-startup values
verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no
behaviour change. P2S is the only override:
P2S: probesize=1_000_000, analyzeduration=500_000
SSDP internal codes (N7→P2S) resolve via an alias map so the camera
path works during the early-connect window before the display name
is settled.
This is the first step of the camera-architecture overhaul agreed
after #1395. Adding the next quirky model is a config entry, not
another module-level constant.
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
queue, started from the queue, and got no timelapse video. Their
dispatch chain itself was correct (queue item -> scheduler -> MQTT
command honors `timelapse`); the gap was at queue-add time.
The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
other print-option settings) from the workflow settings card. That was
introduced in #1235 to stop column-level defaults from winning. But it
also discarded the slicer's actual choice carried on the MQTT
`project_file` command, which all the slicers (Studio / Handy / Orca)
ship as `timelapse: true|1`. Result: a user with the new-install value
`default_timelapse=false` had to either flip the global setting or
edit every queue item by hand, even though their slicer's "Print
options" UI clearly said "record timelapse".
Investigation went wider than #1403 because Martin's hypothesis was
"the print options modal isn't respected either." Cross-checking
86 captured P1S `project_file` commands across the support packages
shows 46 from the queue scheduler and 33 from background_dispatch
emitting `"timelapse": true` correctly to real printers - the modal +
re-print path is intact end-to-end. The slicer-side gap was the only
real bug. Two unrelated dead-code issues turned up in the same dig and
are folded in below.
Fix (VP queue inheritance)
- `on_print_command` in the VP manager now stashes the slicer's
project_file dict keyed by filename, then signals an asyncio.Event.
- `_add_to_print_queue` checks the dict first; if empty, creates the
event and waits up to 2 s for it before reading the settings
fallback. Each option flows through per-field - slicer value wins
if present, else the existing settings default (so users who
explicitly set `default_timelapse=true` in their VP workflow card
still get that on slicers that don't send a print command).
- MQTT field naming preserved exactly: `bed_leveling` (single L) on
the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
column. Integer 0/1 from H-family slicers and bool true/false from
P1/X1 slicers both coerce via `bool()`.
- Capture is gated on `mode == "print_queue"` so immediate / review /
proxy modes keep their pre-fix no-op `on_print_command` and don't
accumulate stashed entries over the VP's uptime.
- Wait is also skipped when there's no MQTT server attached
(`self._mqtt is None`), so unit tests that invoke
`_add_to_print_queue` directly don't pay the 2 s tax.
- Capture is consumed on use so the dict stays bounded.
- `printer_manager.get_status(...).get(...)` against a `PrinterState`
dataclass that has no `.get()` method.
- Every print option discarded (timelapse, bed_levelling, AMS mapping).
The route 500'd before ever reaching the printer. Rewritten to mirror
`POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
next pending queue item and let the scheduler dispatch with the
queue's stored options intact. Response shape preserved.
Side-bug b: vibration_cali default drift in background_dispatch
- `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
both default to `True` (matches Bambu Studio behavior for X1/P1).
- Both `_process_job` call sites read
`job.options.get("vibration_cali", False)`.
Cosmetic today because the frontend always sends the field, but a
latent landmine for any future caller that bypasses the schema. Both
sites flipped to `True`.
bambu_ftp.upload_file (and upload_bytes) wrapped the voidresp() call in a
broad "except Exception: log warning and proceed" because H2D printers
can take 30+ seconds to send the 226 and we don't want to fail on that.
But the same handler was swallowing ftplib.error_temp (e.g. 426 "Failure
reading network stream") from buggy printer firmware, which explicitly
means the data stream was cut mid-transfer and the file on the SD card
is partial.
Bambuddy then sent the print command anyway, and the printer surfaced a
generic "unable to parse 3mf file" error 30 seconds into the print
attempt -- with nothing in the log on the user side to suggest the
upload had actually failed.
Split the catch: ftplib.Error subclasses (server-reported failure)
re-raise so the outer handler returns False; everything else (socket
timeout etc.) keeps the existing proceed-with-warning behaviour so the
H2D 226 tolerance survives.
Two regression tests patch _ftp.voidresp to raise error_temp("426 ...")
and assert both upload_file() and upload_bytes() return False.
The underlying P2S firmware / TLS-data-channel issue that triggers the
426 for the reporter is separate -- this change just stops Bambuddy from
hiding it.
Reporter Kyobinoyo asked for the equivalent of the existing
print-finish auto-off but triggered when AMS drying ends.
Two new SmartPlug columns: auto_off_after_drying (default false),
off_delay_after_drying_minutes (default 10 — AMS chamber is hot
post-cycle so longer cooldown than the print-finish default of 5).
SQLite + Postgres migrations both idempotent.
Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
tracks the dry_time > 0 → 0 falling edge and fires a new
on_drying_complete(ams_id) callback. Plumbed through
PrinterManager.set_drying_complete_callback to
SmartPlugManager.on_drying_complete(printer_id, db), which walks
linked plugs and respects the per-plug toggle. Catches queue,
ambient and manual drying identically because it observes firmware
state, not scheduler intent.
Frontend: single "Auto Off After Drying" toggle + delay input on
the smart plug card, next to the existing print-finish auto-off
section.
Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
on dual-AMS printers) deferred — Bambuddy's plug model is
plug→printer, so the trigger fires whenever any AMS on the linked
printer finishes a cycle.
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
a search prefix matching a multi-entity device (one switch.* plus
several sensor.*/binary_sensor.* siblings under the same friendly
name), clicked one of the non-switch siblings, and got a 422 on Save:
String should match pattern
'^(switch|light|input_boolean|script)\.[a-z0-9_]+$'
The screenshot confirms the bug shape — the X button next to the
"empty-looking" Select Entity field only renders when haEntityId is
truthy. So haEntityId was set, but selectedEntity (haEntities.find by
that id) returned undefined, so the input rendered the placeholder
text instead of the friendly-name display. That can only happen when
the user had earlier picked an entity whose domain is NOT in the
schema's allowed list, then the search cleared, the entity-list
refetched without a search param, and the refreshed list (filtered to
the default domains) no longer contained the user's pick.
Root cause was in HomeAssistantService.list_entities: when a search
query was present, the function bypassed the domain filter entirely
and returned matches across every HA domain. Offering a clickable
choice the schema can't accept is broken UX, and the cryptic Pydantic
pattern echo on save made it look like a backend/schema problem
rather than a search-permissiveness problem. Confirmed via git diff
that the smart-plug code path is unchanged between v0.2.4 and
0.2.4.1 — this has been latent since the script-domain commit in
February 2026, only noticed now because the reporter hadn't reopened
the modal in months.
Fix: always apply the allowed-domains filter ({switch, light,
input_boolean, script} — kept in sync with the regex in
backend/app/schemas/smart_plug.py:17). Search composes on top as a
substring match against entity_id or friendly_name, instead of
replacing the domain filter. Whitespace-only search strings now
fall back to the no-search behavior.
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
Both the queue-side _watchdog_print_start and the direct-dispatch
_verify_print_response used `status.state != pre_state` to decide
whether a project_file command had been accepted. When a printer was
in FINISH at dispatch time (un-dismissed post-print prompt from a
prior job), the firmware silently rejected the new command; if the
user then dismissed the screen prompt, the printer moved FINISH ->
IDLE and the watchdog returned early as "command landed" — leaving
the queue row stuck at status='printing' indefinitely and the
scheduler permanently marking the printer as busy.
Narrow the "command landed" check in both verifiers to an allow-list
of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
the revert. The subtask_id-advance signal stays in place for H2D's
slow FINISH -> PREPARE transition (#1078).
Also wrap _watchdog_print_start's revert commit and
printer_manager._persist_awaiting_plate_clear in run_with_retry so
SQLite single-writer contention can't silently drop these writes.
The revert path returns a tristate sentinel so the post-revert MQTT
session-recovery logic only runs when we actually reverted (or the
commit failed) — not when on_print_complete had already cleared the
row, where a forced reconnect could break a healthy concurrent print.
The #765 guard against shutdown-time data wipes skipped any AMS update
with power_on_flag=False, but some X1C firmware emits power_on_flag=False
while idle with tray_exist_bits still reflecting the real slot inventory.
Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
the bitfield path is the only signal — muting it left spool removals
undetected until a manual reconnect.
Narrow the skip to the exact shutdown pattern: zero bits AND
power_on_flag=False. Non-zero bits with power_on_flag=False are now
applied. The #765 shutdown protection is preserved (its regression test
uses tray_exist_bits='0' and still passes); newer firmwares are
unaffected because their per-tray state path catches the removal first.
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
hostnames serve the same webhooks. The validation now accepts either
prefix while keeping the check itself in place to catch the
paste-the-wrong-thing error.
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.
Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.
Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
payload and found that P1S and A1 Mini send only {"id": N} for a
physically empty slot — no state, no tray_type, no other fields. Without
that signal, the assign-spool path was firing one wasted MQTT publish per
click on a truly-empty slot (firmware dropped it silently, but still).
The AMS parser in printer_manager.py now detects the bare-tray shape and
promotes it to state=9 — the firmware's explicit "no spool" code — which
lets the existing state in {9, 10} short-circuit in the inventory route
apply automatically.
The detection is intentionally narrow:
len(tray) == 1 and "id" in tray and state is None
so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
and tray_type="") has more than one key and stays unaffected — the #1322
root fix is preserved.
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
Bambuddy's external SpoolmanDB lookup in `_find_or_create_filament` matched
on material+color only, with no manufacturer filter. Because SpoolmanDB is a
multi-vendor catalog and entries are roughly ID-sorted, the first hit for
any common combination is almost always a competitor — `bambulab_pla_black_1000_175_n`
is the 15th entry for PLA + `#000000`. Bambu Lab RFID spools were being
labeled with competitor product names (`3DJAKE Black`, `3DXTECH™ Black`, etc).
Restrict the external-library loop to entries whose manufacturer is
`"Bambu Lab"` (with `id.startswith("bambulab_")` as a defensive fallback
for schema drift). When multiple Bambu Lab candidates exist, prefer the
entry whose `name` equals the AMS `tray_sub_brands` so `"PLA Basic"` wins
over generic `"Black"` when both are present. Forward `density` from the
chosen external entry so it is no longer overwritten by the PLA-default
1.24 in `create_filament`.
Six unit tests added: internal short-circuit preserved, non-Bambu external
entries skipped, PLA Basic > generic PLA tiebreaker, no-match fallback,
id-prefix defensive fallback, density propagation.
Fixes#1309
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
control sent the nozzle straight into the build plate. Reporter
triggered it with the 50 mm step and crashed their nozzle.
Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
gap). The frontend maps "Up" to negative distance with that convention
in mind.
A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
firmware uses standard cartesian Z (Z+ = toolhead up). On those models
G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
classification at the bed-jog code path, so every printer got the same
X1-convention G-code.
Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
already-defined A1_MODELS frozenset which covers display names and
internal codes N1 / N2S). The bed-jog route now inverts the signed
distance before emitting G-code when the printer model is in that set,
so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
regardless of which physical part moves. Frontend untouched, single
source of truth lives in the backend, keyed off the Printer.model
column. Route Query description and docstring updated to spell out the
new contract: distance is the gap adjustment, not the raw Z value.
Restarting Bambuddy mid-print misfired the plate-check + archive flow.
The is_new_print guard treated _previous_gcode_state=None → RUNNING as
a transition, but None just means we haven't seen any prior state yet —
catch-up from a printer that was already running, not a fresh start.
Add `_previous_gcode_state is not None` to the guard. _was_running still
flips on unconditionally, so completion detection is unchanged. 3 tests
that asserted the buggy behavior now seed an explicit prior state; new
regression test pins the contract for the reporter's exact scenario.
External scan hung on a 1200-subdir NAS because (1) every STL crashed
with TypeError ('str / str') inside generate_stl_thumbnail and (2)
thumbnail generation ran synchronously per file, so the FE timed out
before db.commit() and nothing was persisted.
stl_thumbnail.py now coerces inputs to Path defensively, and
scan_external_folder defers STL thumbnail generation to a background
asyncio task that opens its own session and processes each file
post-commit. Subdirs appear in the sidebar immediately; thumbnails
backfill over the next seconds/minutes.
Real-printer prints broadcast archive_created from the MQTT print_start
handler, which the Archives page listens for to invalidate its query
cache. The VP file-receive paths created the archive in the DB but
never emitted the event, so the new card only appeared after a tab
switch triggered refetch-on-focus.
Added a small _broadcast_archive_created helper on VirtualPrinterInstance
and called it from _archive_file (immediate mode) and _add_to_print_queue
(queue mode). Review mode is unaffected — it creates a PendingUpload,
not a PrintArchive. Broadcast errors are swallowed at debug level so a
transient WebSocket issue can't break the file-receive flow.
Bambuddy's VP supports two slicer flows: Send (file upload only — what
queue/immediate/review modes are designed for) and Print (file upload
+ start-print, intended for proxy mode). When a user clicks Print
against a non-proxy mode the VP must still respond gracefully — the
file is fine to receive, just the start-print never happens. Instead
the slicer wedged at "Downloading...(0%)" and blocked the next
dispatch with "The printer is busy with another print job".
Cause: on_file_received transitioned gcode_state PREPARE -> IDLE
directly. Print-flow slicers watch the state cycle and only release
their in-flight-job lock on PREPARE -> ... -> FINISH (or FAILED).
PREPARE -> IDLE looks like "printer abandoned my job" and keeps the
prior job pinned in the slicer's memory.
Fix: transition PREPARE -> FINISH with prepare_percent=100. The 1-Hz
periodic status push broadcasts the new state to every connected
slicer within a second. Send-flow slicers don't watch this state so
the change is a no-op for them; Print-flow slicers see the FINISH
they were waiting for and unwedge.
ams_set_filament_setting and reset_ams_slot encoded the single-external
case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0"
comment was a misread of the printer's response (which echoes the local
slot position), not the request semantics.
Captured BambuStudio -> X1C exchange shows the request encoding is
{ams_id: 255, tray_id: 254, slot_id: 0} (global tray index in tray_id).
The previous code's tray_id: 0 is what the P1S in #1279 rejects with
result: "fail", which silently broke external-spool filament selection
on every Bambu printer with no AMS or external spool in active use.
Dual-external (H2D) branch was not in the captured exchange and is
explicitly pinned at the legacy encoding pending a Studio -> H2D capture.
BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat
ams_mapping array — a convention already documented in
bambu_mqtt.py:start_print(). The spoolman tracking helper was treating
-1 as "unmapped, use position-based default", which mapped slot_id=1
to AMS tray 0 and credited external-spool prints to whatever Spoolman
spool happened to be linked to AMS slot 0. The reporter's TPU prints
on an H2S were credited to a PLA spool for ~49g over 4 prints before
being noticed (regression of #853).
When slot_to_tray[slot_id-1] == -1 and ams_trays contains 254/255,
return the external tray ID directly. Prefers 254 over 255 (matches
single-nozzle tray_now reporting + the vir_slot id=255->254 remap in
bambu_mqtt.py:864). Legacy fall-through preserved for callers that
don't pass ams_trays.
Root cause investigation and patch by @ojimpo.
Prints sent from a slicer to a VP in print_queue mode arrived in the
queue with bed_levelling / flow_cali / vibration_cali / layer_inspect /
timelapse set to the SQLAlchemy column defaults, ignoring the user's
workflow page settings entirely. The manual POST /print-queue endpoint
reads these from the request body (frontend pulls them from settings
before submitting), but manager._add_to_print_queue constructed the
PrintQueueItem without touching any of those fields.
Read default_bed_levelling and the other four settings via get_setting
and pass them explicitly. _bool_setting helper handles the None ->
AppSettings default fallback.
Spoolman had two mutually-exclusive weight paths gated on the
`disable_weight_sync` flag. The default (False) used AMS remain%
x tray_weight auto-sync, which silently dropped non-BL spools
because the AMS doesn't report tray_weight without RFID. The
inventory_remaining fallback would have covered it, but the
spool_assignment table it reads from is wiped on Spoolman
activation, so non-BL spools got no weight updates at all.
Match the internal Filament Inventory: per-print tracking always
runs, AMS auto-sync no longer writes remaining_weight (it still
maintains spool metadata and slot assignments). The setting
becomes a no-op; left in the schema and UI for backwards compat.
- store_print_data: drop the disable_weight_sync early return
- sync_ams_tray callsites in main.py + routes/spoolman.py: force
disable_weight_sync=True so weight is never written by AMS sync
- new regression test confirming tracking runs with flag=false
The AMS remain% delta path charged every tray with a delta, not just
trays involved in the print. Swapping a spool in an UNUSED slot mid-
print made the slot report remain=0 (fresh spool, no tag), versus a
print-start snapshot of 100%, so the originally-assigned spool got
charged the full 1000g.
Build print_used_keys from ams_mapping, tray_change_log, and
tray_now_at_start, and skip fallback for trays not in that set.
Legacy "scan every tray" behavior preserved when none of the three
signals are present.
Three enhancements requested by @oliboehm after the V1 label-printing
ship in #809:
- New box_40x30 single-label template (common DK/Brother roll size,
good for filament-bag and storage-bin labels). Routes through the
existing roomy layout since height >= 20 mm.
- Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on
every label - useful when several near-identical material/colour
spools sit next to each other and the swatch alone isn't enough to
tell them apart. Skipped silently when rgba is None or malformed.
- Brand line bumped to Helvetica-Bold (was regular) and a couple of
points larger on both layouts so it reads cleanly at arm's length.
Wired through the SpoolLabelTemplate union, the modal's
TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n
key in all 8 locales (native translations for de/fr/it/ja/pt-BR/
zh-CN/zh-TW). Modal regression test widened from 4 to 5 template
buttons. Three new renderer tests pin the hex-code render, the
hex-code skip on invalid rgba, and the bold-brand font reference.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
Three Bambu Lab catalog rows share #FFFFFF — Jade White (PLA Basic),
Ivory White (PLA Matte), White (PLA Silk). The catalog lookup in
create_spool_from_tray filtered by manufacturer + hex only with no
ORDER BY, so SQLite returned rows in rowid order and the first-inserted
entry (Jade White) won every RFID-driven spool creation regardless of
the actual material the AMS reported. Inserting an Ivory White PLA
Matte roll always produced a spool named "Jade White".
Same class of bug bites any other shared-hex pair across PLA Basic /
Matte / Silk; the whites were just the most visible.
Fix: add a material filter using tray_sub_brands (the printer-reported
material variant — "PLA Matte" / "PLA Basic" / "PLA Silk"), which
matches the catalog's `material` column directly. Use the raw
tray_sub_brands value (captured before the gradient/dual/tri-color
subtype upgrade) because the catalog stores "PLA Basic" for gradient
rolls too — the upgraded subtype lives on the spool, not the catalog.
Also add ORDER BY id to the query so the fallback path (empty
tray_sub_brands — third-party spools / OpenTag tags) is deterministic
across SQLite + PostgreSQL instead of DB-implementation-defined.
Tests: 4 new in test_spool_tag_matcher.py — Ivory White PLA Matte
resolves to Ivory not Jade (the regression pin), PLA Silk White
resolves to White, Jade White PLA Basic still works with all three
#FFFFFF entries seeded, and the empty-sub_brands fallback stays
deterministic via the new ORDER BY.
Existing spools already mis-named in the database don't auto-correct
on next AMS read — the matcher only fires on new RFID-driven creation.
Affected users need a manual rename in Inventory after upgrading.
When one spool ran out and the AMS transparently switched to a sibling
slot of the same material, the usage tracker credited the originally-
mapped spool with the full 3MF estimate AND added the fallback spool's
remain%-delta on top — so a 78g print could record as 138g across two
spools, leaving the empty spool's recorded weight beyond its label.
Two interacting bugs:
1. bambu_mqtt.py: the tray-change recorder gated on
`state in ("RUNNING", "PAUSE")`, but P2S firmware briefly transitions
out of RUNNING during the AMS swap (into LOADING etc.), so the
literal-string gate missed the switch entirely and tray_change_log
stayed empty. Re-key on the print-lifecycle flags
(_was_running and not _completion_triggered) so any tray change
between print start and completion is captured regardless of the
momentary gcode_state.
2. usage_tracker.py: the splitting branch was gated on
`not slot_to_tray`, so the splitting code only ran for prints where
the slicer mapping hadn't been captured — i.e. never on the actual
fallback case (slot_to_tray is populated by every print_cmd). Drop
the gate: when tray_change_log has > 1 entries, splitting takes
over and per-segment per-layer gcode usage replaces the stale
mapping. Path 2 (AMS remain%-delta) then naturally skips both trays
because they're already in handled_trays after splitting,
eliminating the double-credit.
The SliceModal's preview slice runs against unsliced project files to
discover per-plate AMS slot consumption. Until now it always used
slice_without_profiles — accurate slot mapping (a model property) but
gram numbers were derived from the file's embedded process settings,
which can drift from the triplet the real print will use.
When the caller provides a bundle id + printer/process/filament preset
names, get_preview_filaments now routes through slice_with_bundle so
the preview's gram numbers match what the real print will produce.
Cache key picks up a bundle-context fingerprint so different bundle
picks on the same file occupy distinct entries.
Backend:
- slice_preview.get_preview_filaments: optional bundle_* params
- library.py + archives.py: forward params via /filament-requirements
Frontend (forward-compat for the upcoming SliceModal Bundle tier):
- api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
accept an optional 4th-arg bundle context object
Wires Bambuddy to the orca-slicer-api fork's bundle endpoints (shipped
in bambuddy/bundle-import). Users will eventually upload a BambuStudio
"Printer Preset Bundle" (.bbscfg) once per printer; subsequent slices
pick from the bundle by preset name instead of re-uploading the JSON
triplet every time.
Service layer:
- BundleSummary / BundleNotFoundError types
- import_bundle / list_bundles / get_bundle / delete_bundle methods
- slice_with_bundle: POST /slice with bundle id + per-category names
instead of attached profile JSONs
Routes (LIBRARY_UPLOAD perm gate):
- POST /api/v1/slicer/bundles
- GET /api/v1/slicer/bundles
- GET /api/v1/slicer/bundles/:id
- DELETE /api/v1/slicer/bundles/:id
All routes proxy via _resolve_slicer_api_url so they follow the user's
preferred_slicer setting (bambu_studio vs orcaslicer). Status-code
mapping treats sidecar 4xx as 400, BundleNotFoundError as 404,
unreachable as 503, and sidecar 5xx as 502.
Closes the longest-standing inventory gap — finding a specific spool
in a closet of 50 partials. Per-spool icon button on every inventory
card and table row, plus a "Print labels..." header action that opens
a multi-select picker pre-loaded with the currently filtered spools.
Four pre-built templates: AMS holder (30 x 15 mm) for the popular
Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
the colour swatch (with multi-colour gradient stripes for spools
with extra_colors set), brand, material, name, the *spool ID*
(bsaunder's articulated user-need: telling 8 spools of "PLA White"
apart, especially partials), and a QR code that deep-links to
/inventory?spool=<id> for phone-scan round-trips. Box-label adds
storage location; AMS-holder drops the QR — at 30 x 15 mm there is
no room for swatch + text + QR without truncating away the spool ID,
and AMS-bay identification is at arm's length where the swatch and
ID are enough.
Server-side rendering via ReportLab + qrcode (already a dep). Pure
Python, no headless browser, no system libs. Output is byte-identical
across browsers, Avery sheets align to <0.1 mm, and bulk export is
one click for one PDF. Two endpoints — POST /inventory/labels (local
DB) and POST /spoolman/labels (Spoolman-backed) — gated on
INVENTORY_READ, capped at 500 spools per request, returning
application/pdf via StreamingResponse. The renderer is decoupled
from the SQLAlchemy model via a LabelData dataclass so the same code
path serves both modes.
Modal picker scales to large libraries: search (substring match
across name / brand / #ID), material filter chips derived from the
visible spools, additive Select-all-visible / Deselect-visible /
Clear-all actions so selections survive filter changes. Restyled
twice in development — first cut used generic Tailwind which clashed
with the inventory's bambu-dark palette; second cut switched to
bambu-dark-secondary / bambu-green / bambu-gray to match.
Two render bugs found during visual inspection of generated PDFs and
fixed before commit:
1. AMS-30x15 template originally produced labels with only swatch
+ QR and no text at all — the side-by-side layout left <5 mm
for the text column, so the renderer bailed without drawing
anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
regimes; tight regime drops the QR and gives the right column
to brand + material + a 13pt-bold spool ID.
2. Box-62x29 template aggressively truncated text — swatch + QR
each at ~14 mm on a 26mm-tall label squeezed the text column
to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
"Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
leaving the text column ~30 mm — full names render without
truncation.
Both bugs pinned by regression tests in test_label_renderer.py that
render with pageCompression=0 so the resulting PDF bytes contain the
text as ASCII and `assert b"Polymaker" in pdf` works.
Daily builds since 889c8bd8 (Apr 29) silently destroyed archive copies
and library file bytes on every print completion. Reprint / View G-code
later returned 404 with no log line explaining why; the DB row was
intact and the archive grid kept showing the entry, but the file
behind archive.file_path no longer existed on disk.
Root cause: #1166 added three dispatch sites that cache the live
archive copy (and library file bytes for Direct-Print) in the shared
3MF download cache, so /cover could skip a redundant FTP transfer
mid-print. The cache was originally designed for transient downloads
under archive_dir/temp/, and clear_3mf_cache(printer_id) — called
from on_print_complete to keep that temp dir from accumulating —
happily unlink()'d every cached path. Path.exists() guarded the
unlink, so no exception, no warning, just silent destruction. Listing
didn't change; only acting on the archive surfaced the 404.
Fix: clear_3mf_cache._maybe_unlink refuses to unlink any path outside
archive_dir/temp. Cache dict is still cleared (so re-cache continues
to work and /cover hits a fresh path next print), only the on-disk
delete is gated. Persistent locations — archive/<printer_id>/...,
archive/unassigned/... (VP-archived prints with printer_id=None),
library_files/..., is_external library mounts — all survive.
Regression test test_clear_does_not_delete_persistent_files pins the
contract end-to-end: archive 3mf, library 3mf, and temp 3mf all
cached for the same printer; after clear, all three cache entries
are dropped from the dict, but only the temp file is unlinked from
disk. Two existing tests updated to put fixtures under
archive_dir/temp.
Two consecutive plates of the same model would create the second print's
archive with the first plate's metadata: subtask_name lags across the
boundary while gcode_file is fresh, so the FTP candidate list (built
from subtask_name first) lands on the previous plate's still-resident
upload. The 3MF parser then locks the wrong _plate_index, name, time
estimate, and per-slot filament data into the archive at creation.
Fix peeks the downloaded 3MF's slice_info plate index, compares against
parse_plate_id(filename) (the plate parsed from /Metadata/plate_N.gcode,
which always reflects what's running), and on mismatch retries FTP with
swap_plate_suffix(subtask_name, expected_plate) — handling both the
spaced "Plate N" and underscored "_plate_N" suffix forms seen in real
subtask_names. If the retry finds a matching 3MF, the wrong file is
dropped and the corrected one feeds the archive; if no match is found
(or no swap is possible) the wrong file is dropped and the existing
no-3MF fallback creates an archive whose name reflects the right plate.
The validation only runs when parse_plate_id() returns a value, so
single-plate / cloud-named / non-Bambu jobs are unaffected.
17 new unit tests in test_archive_plate_validation.py cover both helpers:
plate-index peek across malformed / missing / non-integer / non-zip
inputs, and the suffix swap across both casings, the underscored form,
case-insensitive matching, and rejection of names without a recognised
suffix.
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:
- Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
dialog (IP-only).
- Their printer-MQTT trust path validates only against the bundled BBL CA
store (`printer.cer`), NOT the system trust store. Confirmed against
ClusterM/open-bambu-networking's clean-room reimplementation:
`mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
chain validation against BBL CA only, hostname check intentionally
skipped (because Bambu's printer cert CN is the device serial).
- LE certs don't chain to BBL CA, so the slicer rejects with the
well-known "-1" before any hostname/IP logic runs.
The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:
- Toggle stays as an informational marker — when ON, the VP card surfaces
the host's Tailscale IP + MagicDNS hostname so users know what to paste
into the slicer.
- Cert is always self-signed (signed by `bbl_ca`).
- Tailscale exposure is via the existing bind_ip dropdown, which already
includes `tailscale0` IPs.
- Tailscale's role is strictly network reach — same trust burden as LAN.
Backend cuts:
- `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
`_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
`cryptography` import. Keep `get_status` and `TailscaleStatus`.
- `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
- `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
`_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
`_cancel_renewal_task`, `_cancel_restart_task`. Simplify
`_resolve_cert_and_advertise` to a sync method that just generates the
self-signed cert. Drop `tailscale_disabled` from the change-detection
diff (toggle is informational — no service restart needed).
- `routes/virtual_printers.py` + `routes/settings.py`: drop the
`tailscale_not_available` 409 guard on toggle-enable.
Frontend cuts:
- `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
`multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
no longer populated). Drop the `tailscale_not_available` toast handler.
- `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
- i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.
Docs:
- Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
— remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
steps, document the toggle as informational, keep the Docker socket
mount + LXC TUN troubleshooting (those still apply for daemon
reachability).
- README: drop "the Tailscale benefit here is the tunnel, not cert-import
elimination" framing in favour of "surfaces the IP for paste into
slicer; CA import unchanged because BBL CA store, not system trust
store, is what gets validated".
Tests:
- `test_tailscale.py`: reduced to surviving `get_status` cases (binary
missing, command fails, success, empty DNSName, malformed JSON).
- `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
→ `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
informational; `remove_instance` must NOT be called).
- `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
`TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
directions succeed and daemon is never consulted).
- `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
FQDN-copy block drives data through that query.
DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.
Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
Pre-fix, _background_notifications in main.py:3434 built archive_data
with print_time_seconds (the slicer's pre-print estimate parsed from
the 3MF at archive creation), and notification_service.py:909 formatted
that field straight into the {{duration}} template variable. A print
cancelled 2 minutes into a 3-hour estimate notified "duration: 3h".
Compute actual_time_seconds from started_at/completed_at in main.py and
add it to archive_data. notification_service.py prefers it, falls back
to print_time_seconds when the actual can't be derived.
Also add "cancelled" to the list of statuses that get completed_at set
in update_archive_status — pre-fix only completed/failed/aborted got a
timestamp, so queue-UI cancellations had no actual elapsed to compute
from. Audited every completed_at consumer; none depend on NULL to mean
"cancelled" (status field already carries that signal), and the
statistics-totals aggregation gets more accurate too as a side effect.
3 new regression tests in TestNotificationVariableFallbacks pin the
{{duration}} variable contract (actual wins over estimate; estimate
falls in when actual is missing; "Unknown" when both absent).
go2rtc and several IP cameras still emit a warm-up / black frame on every
fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
slipped through intermittently for @nkm8's setup. His own bisect named
the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
single-frame endpoint that never returns the encoder's stale keyframe.
Adds an optional external_camera_snapshot_url column on printers. When
set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
on the override URL via plain HTTP GET, bypassing the warm-up dance.
Live view stays on the configured stream URL — only single-frame
captures use the override. Override is camera-type-agnostic. SSRF guard
applies (existing _sanitize_camera_url allowlist). Empty string treated
as unset.
Settings UI: new "Snapshot URL (optional)" input + Test button under
External Cameras, hidden for camera_type=snapshot since the live URL is
already a single-frame source. en + de fully translated; 6 other locales
seeded with English copy.
5 backend tests pin the routing contract; 3 frontend tests pin the
input + debounced PATCH. Documented in
bambuddy-wiki/docs/features/camera.md with the go2rtc example.
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
filament requirements from the 3MF and writes
required_filament_types + filament_overrides + ams_mapping onto the
queue item, but the VP queue-mode write path skipped all of that.
Net effect: scheduler reached its model-only-matching fallback and
auto-dispatched onto whatever printer was free regardless of loaded
colour.
Extract the scheduler's existing _get_filament_requirements 3MF
parser into a shared helper so the VP path can reuse it. VP's
_add_to_print_queue now populates required_filament_types
unconditionally (cheap; helps the scheduler reject obvious type
mismatches) and writes filament_overrides with force_color_match:
true per consumed slot when a new per-VP queue_force_color_match
toggle is on. Default off to preserve current behaviour for
upgraders.
UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
mirroring the existing auto-dispatch toggle. i18n: en + de
translated, other 6 locales seeded with English copy.
Schema: one nullable column on virtual_printers
(queue_force_color_match BOOLEAN, default 0/FALSE).
11 new backend tests (8 for the extracted parser, 3 for the VP
write path) + 6 new frontend tests (toggle render gating, default
state, click posts queue_force_color_match in update body).
Existing scheduler tests pass against the refactored helper.
README, CHANGELOG, website features page, and wiki virtual-printer
page all updated.
@smandon retested the original #1152 fix on the latest daily and surfaced
two distinct holes:
1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.
2. The pending-uploads review card always showed the raw FTP filename,
while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
embedded title (or, with the toggle on ``filename``, the stripped stem).
Net effect: same upload showed two different names depending on which
view you were looking at, with no way for the toggle to flip both
views in lockstep.
Three changes:
- ``resolve_display_stem`` helper in ``services/archive.py`` strips
``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
every flow that produces a ``PrintArchive`` row.
- ``PendingUpload.metadata_print_name`` (new nullable column) is
populated at FTP-receive time by peeking at the 3MF's embedded title
via the existing ``ThreeMFParser``. Read happens once per upload —
the list endpoint then doesn't have to reopen each 3MF on every
render. Parser failures are swallowed and the column stays NULL;
the response model gracefully falls back to the stripped filename.
- ``PendingUploadResponse.display_name`` is a computed field that
mirrors ``archive_print``'s exact precedence — ``filename`` toggle
→ stripped stem; ``metadata`` toggle (default) → cached title or
stripped stem. The frontend's review card reads it (with
``upload.filename`` as a defensive fallback) and surfaces the raw
FTP filename via tooltip so users can still inspect what arrived.
Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
rows have NULL and degrade to filename-stem behaviour without any
operator action.
Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
canonical normalisation rules (Bambu Studio default name, mixed case,
dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
inputs); 6 integration tests in ``test_pending_upload_display_name.py``
pinning the response contract (default toggle uses metadata title when
present, falls back to stripped stem when absent, ``filename`` toggle
overrides metadata, ``filename`` toggle still strips the double suffix,
``GET /{id}`` exposes the same field, whitespace-only metadata behaves
like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
pinning the review card's render path (resolved name shown, fallback
to filename when display_name is empty, raw filename available via
tooltip). Full backend suite: 3598 passed; frontend build clean; no
regressions in any flow that previously processed ``.3mf`` /
``.gcode`` / non-3D filenames.