navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
Default Operators group is seeded with *_own only (and the migration map flips
legacy → _own on existing groups), so non-admin users never held the legacy
permission and the sidebar hid Archives / Queue / Files even though the
underlying API accepted their requests. Reporter only spotted Files; same bug
shape applied to Archives and Queue.
Fix: navPermissions accepts Permission | Permission[]; the three affected
resources list all three tiers. isHidden checks .some(hasPermission) for
arrays. Permission type extended with the matching *_own / *_all variants —
backend already shipped them, the TS type just didn't declare them.
The provider toggle, schema, template, and NotificationService.on_printer_offline
all shipped, but no caller invoked the dispatcher — the offline event was an
orphan toggle. Edge detection in on_printer_status_change now schedules a
debounced (60s) background task on the connected→disconnected transition;
reconnect before the window elapses cancels it. Covers both upstream paths
(smart-plug power-off via mark_printer_offline, and MQTT staleness via
check_staleness), both of which already route through the status callback.
The "back online" channel is the existing print-failure notification on
firmware FAILED report — no symmetric on_printer_online needed.
ProtectedRoute and PermissionRoute now pass the requested location as
router state when redirecting to /login. LoginPage stashes it in
sessionStorage before the OIDC provider redirect (since window.location
kills React state) and consumes it on all three post-login navigations
(credentials, 2FA, OIDC token exchange). Targets are sanitized to
same-origin internal paths only — protocol-relative and /login itself
are rejected to prevent open-redirect.
QR labels (https://host/inventory?spool=N) now land on the scanned
spool instead of the printer page after authentik / any OIDC SSO login.
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
cross-DB restore path) can have created_at = NULL because the column was
originally created without a DEFAULT clause — server_default=func.now()
only fires at table creation, not for existing rows or raw cross-DB
inserts. The list_archives response model required a datetime, so a
single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.
- Boot-time backfill: COALESCE(completed_at, started_at, now()) for
any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
vs Postgres NOW()).
- Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
and ArchiveSlim so a future NULL-leaking path doesn't break the list
endpoint again.
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
Default Operators group is seeded with *_own only (and the migration map flips
legacy → _own on existing groups), so non-admin users never held the legacy
permission and the sidebar hid Archives / Queue / Files even though the
underlying API accepted their requests. Reporter only spotted Files; same bug
shape applied to Archives and Queue.
Fix: navPermissions accepts Permission | Permission[]; the three affected
resources list all three tiers. isHidden checks .some(hasPermission) for
arrays. Permission type extended with the matching *_own / *_all variants —
backend already shipped them, the TS type just didn't declare them.
The provider toggle, schema, template, and NotificationService.on_printer_offline
all shipped, but no caller invoked the dispatcher — the offline event was an
orphan toggle. Edge detection in on_printer_status_change now schedules a
debounced (60s) background task on the connected→disconnected transition;
reconnect before the window elapses cancels it. Covers both upstream paths
(smart-plug power-off via mark_printer_offline, and MQTT staleness via
check_staleness), both of which already route through the status callback.
The "back online" channel is the existing print-failure notification on
firmware FAILED report — no symmetric on_printer_online needed.
ProtectedRoute and PermissionRoute now pass the requested location as
router state when redirecting to /login. LoginPage stashes it in
sessionStorage before the OIDC provider redirect (since window.location
kills React state) and consumes it on all three post-login navigations
(credentials, 2FA, OIDC token exchange). Targets are sanitized to
same-origin internal paths only — protocol-relative and /login itself
are rejected to prevent open-redirect.
QR labels (https://host/inventory?spool=N) now land on the scanned
spool instead of the printer page after authentik / any OIDC SSO login.
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
cross-DB restore path) can have created_at = NULL because the column was
originally created without a DEFAULT clause — server_default=func.now()
only fires at table creation, not for existing rows or raw cross-DB
inserts. The list_archives response model required a datetime, so a
single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.
- Boot-time backfill: COALESCE(completed_at, started_at, now()) for
any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
vs Postgres NOW()).
- Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
and ArchiveSlim so a future NULL-leaking path doesn't break the list
endpoint again.
The two "0.0.0.0" comparisons in test_support_helpers verify the
support-bundle net.info[*].ip redaction sentinel (mirrors the
support.py:1193 annotation), not a socket bind. Annotate inline.
The "0.0.0.0" written into the support bundle is a JSON sentinel that
scrubs the printer's local IP plus the gateway/peers it sees — not a
socket bind address. Annotate inline so bandit stops flagging it.
The "0.0.0.0" written into the support bundle is a JSON sentinel that
scrubs the printer's local IP plus the gateway/peers it sees — not a
socket bind address. Annotate inline so bandit stops flagging it.
VP queue-mode multi-plate Send All
==========================================
BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
3MF containing every plate (one FTP STOR, single filename) — slice_info.config
inside the file lists N <plate> blocks with their own index metadata and
their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
called _extract_plate_id which returned only the FIRST plate index, and
_add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
silently dropped on the floor. From the user's perspective: Send All of a
3-plate project produced 1 queue item, indistinguishable from a regular
single-plate Send, with no log line to explain the discrepancy.
The wire was confirmed against the live H2D-1 Proxy VP: the same file
ships whether the user clicked Send or Send All; the only intent signal
is the count of <plate> blocks inside slice_info.config.
Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
(-> list[int]). The list contains every <plate> block's index in order;
falls back to [1] when slice_info.config is missing / unparseable so the
single-plate case is preserved. _add_to_print_queue now loops over the
list and creates one PrintQueueItem per plate, with:
- plate-specific position = MAX(position) + iteration_number, so the
items inherit consecutive positions and the slicer's plate order
becomes the queue execution order.
- per-plate required_filament_types / filament_overrides via
extract_filament_requirements(file_path, plate_id) — the plate-aware
filter shipped with #1697 — so the scheduler's per-printer "Any X"
matching dispatches each plate onto a printer with the right
colours loaded for THAT plate, not for plate 1's filament set.
- shared archive_id across all plates (one upload = one archive row).
- the VP's auto_dispatch + manual_start posture inherited unchanged.
Net behaviour: single-plate Send hits the loop once → exactly today's
result (one queue item, plate_id from the slicer, one archive). Multi-
plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
consecutive positions, all referencing the same backing archive.
Archive delete cascades to queue rows
=============================================
Previously the soft-delete path (the default the trash-can button uses)
called _cancel_pending_queue_items which only flipped queue rows with
status='pending' to status='cancelled' while leaving every other status
alone AND leaving every row in the DB. The Send All multi-plate work
above made this much more visible: deleting an archive backed by N
queue items now had to clean up N rows, and what users saw instead was
N "cancelled" rows lingering in the queue history.
Backend:
- Replaced _cancel_pending_queue_items with _delete_related_queue_items
(db, archive_id) -> int. DELETEs every queue row where
archive_id = X regardless of status. Matches what the hard-delete
path already did via the ON DELETE CASCADE FK on
print_queue.archive_id — both paths now produce the same end state.
- Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
untouched; Quick Stats / accuracy bands are preserved across both
delete paths.
- 409 guard on archives.py::delete_archive when any related queue
item is currently status='printing'. Both soft and hard delete are
gated; deleting the archive while a print is live would strip the
dispatcher's metadata trail (filament / plate / ams_mapping) out
from under the running print.
- New GET /archives/{id}/delete-impact endpoint returns
{related_queue_items: N, currently_printing: M}. Cheap, single
endpoint, deliberately NOT folded into the archive list response
so the much larger list endpoint isn't forced to run the same
query per row.
Frontend:
- ArchivesPage delete-confirm modal queries the new endpoint when the
modal opens (useQuery with enabled: showDeleteConfirm) and renders
an amber "N queue items linked to this archive will also be removed"
line when total > 0 AND printing = 0, OR a red "Cannot delete —
M queue items are currently printing" line when printing > 0
(confirm button disabled in that case so the user can't bonk the
409 on submit).
- ConfirmModal gained an optional confirmDisabled?: boolean prop —
isLoading was the only disable knob before; this adds the external-
precondition path.
- 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
translated across all 11 locales per feedback_translate_dont_fallback —
no English fallbacks.
No DB migration — the CASCADE FK was already in place; only the helper's
semantics changed.
The per-archive Print Log table cell at ArchivesPage.tsx:3882 rendered
filament_color as a single swatch with
`backgroundColor: entry.filament_color.startsWith('#') ? ... : undefined`.
For multi-color prints, the backend writes filament_color as a comma-joined
string ("#FFFFFF,#000000,#FF0000"). The whole string trivially passed the
startsWith('#') check but isn't a valid CSS color — the browser silently
drops the declaration and the swatch falls back to its black/20% border,
which on the dark theme reads as a barely-visible grey dot. Reporter's
screenshots showed "PLA" text with no visible swatch at all. DB column
was correct; render dropped the colors.
The Archive Card view at ArchivesPage.tsx:1072-1083 and :2114-2125 already
splits on comma and renders one swatch per color — only the Print Log
table cell had been missed when multi-color support landed elsewhere.
Fix mirrors the card pattern: wrap swatches in a flex container, split
on comma, trim, render one w-3 h-3 swatch per color with
backgroundColor and title={trimmed}. Single-color prints render one
swatch (no behaviour change). Empty / non-hex entries fall through to
no backgroundColor rather than poisoning the CSS for siblings.
Does NOT cover the reporter's second symptom — new multi-color prints
missing from filament usage history. That's usage_tracker._track_from_3mf
and the slot-to-tray mapping chain; needs a support bundle (PRINT START
+ PRINT COMPLETE [UsageTracker] log lines + captured ams_mapping) before
shape can be confirmed. Tracking as #1731 part 2.
capture_finish_photo (default-on) was forcing the timelapse MQTT field to
true on every print, even when the user explicitly unchecked Timelapse in
the slicer send dialog. On profiles with Timelapse Type = Smooth, that
flipped the printer's timelapse_record_flag and un-gated the per-layer
M622 J1 wipe blocks the slicer had baked in — toolhead parked off the
part every layer, on prints the user opted out of recording.
Root cause: #1397 implemented the finish-photo feature as a side channel
of "force the printer into timelapse-recording mode at dispatch" so the
last-frame extractor had a video to pull from. That conflated recording a
timelapse with snapping a finish photo, and the per-layer side effects
were decided at slice time by the user's timelapse_type, which Bambuddy
has no visibility into post-slice.
Fix: replace the force-on with a clean MQTT-state-driven trigger.
bambu_mqtt.py fires a new on_finish_photo_moment callback when
stg_cur transitions INTO 22 ("Filament unloading") while
_was_running AND end-of-print gate matches (progress >= 99 OR
layer_num >= total_layers OR remaining_time <= 0). The gate
disambiguates from mid-print color swaps (which also transit
stage 22 but at progress < 99). FINISH-state fallback in the same
handler fires the callback at the existing transition if stage 22
never arrived (cancel, external-spool-only, HMS halt, firmware
variants).
main.py registers on_finish_photo_moment as a top-level handler.
It pre-captures one camera frame at the trigger edge (external cam
→ buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and
caches the JPEG bytes in _stage22_finish_frames[printer_id].
_background_finish_photo consumes the cached bytes before its
existing live-grab chain, so the saved photo has the better
framing (toolhead parked, before bed drop) without restructuring
the archive-resolution / fallback / notification wiring.
When a timelapse IS actively recording (user explicitly opted in),
pre-capture is skipped — _capture_finish_photo_from_timelapse
still extracts the last frame, which is still the best framing
and now has no force-on side effects because the user wanted the
video.
Removed: resolve_effective_timelapse, _resolve_effective_timelapse
wrapper, both background_dispatch call sites, the print_scheduler call
site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse
(~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video,
/record, /recording) and its call site. All paths now read
bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly.
The archive.bambuddy_forced_timelapse DB column stays defined (default
False) for back-compat with existing rows — no consumer reads it anymore.
The Filament Profile picker in the Configure AMS Slot modal listed
profiles for every printer the user had ever imported / cloud-synced.
On H2D the reporter saw local "Custom" PETG/PLA for A1 mini and P1S
alongside Bambu Cloud and Orca Cloud profiles named "X1C eSUN PETG-
Basic Filament" - none of them usable on the slot they were configuring.
Three filter gaps in the same picker:
- Local "Custom" imported profiles were unconditionally listed. Now
parse their compatible_printers JSON and run presetCompatibility()
against the slot's full slicer preset name ("Bambu Lab H2D 0.4
nozzle") derived from the printer-model registry + slot nozzle.
Hide on 'mismatch'; 'match' and 'unknown' keep showing (back-compat
for hand-edited imports without compatible_printers).
- Cloud presets with the "@Bambu Lab <long-name>" suffix form (user-
renamed Bambu Cloud presets, most Orca Cloud profiles) slipped
through the existing "@BBL <code>" matcher. extractPresetModel now
handles both, with case-insensitive registry reverse-lookup so
"A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini
profiles (#1649 alias match preserved).
- Cloud presets with the model in the BODY of the name and no @ suffix
("X1C eSUN PETG-Basic Filament") returned null from the extractor.
Added a body-text scan against every known model token from the
registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro"
aren't eaten by their shorter siblings, word-boundary regex so
"PA1" doesn't false-match "A1".
Fail-open posture preserved: registry not loaded or printerModel empty
no-ops every filter; saved preset bypass keeps the active selection
visible; built-in filaments stay unfiltered (generic fallback); free-
form names with no recognisable token still show.
VP bridges bound to a target printer (Proxy mode, Queue mode with
specific target) forwarded the printer's raw AMS push_status to the
slicer untouched. bambu_mqtt.py::_handle_ams_data applies a
tray_exist_bits-driven cleanup to Bambuddy's internal state
(promote empty slots to state=9, wipe stale tray_type / tray_color /
tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders
empty slots as Empty, but the VP bridge cache never ran the same
cleanup. Net result on real hardware: a printer with 3 loaded
filaments and several previously-loaded-now-empty slots had Bambuddy's
AMS card render those slots correctly as Empty, but BambuStudio after
Sync painted them as phantom loaded filaments with stale color and
material from before the slot went empty.
Root cause: two consumers of the same payload, only one wired to the
cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py::
_on_printer_raw merged the ams blob via _merge_ams_dict but copied
tray_exist_bits through as an opaque scalar without acting on it.
Fix: factored the bit-clear logic out of _handle_ams_data into a
module-level helper apply_tray_exist_bits(units, tray_exist_bits_str,
*, power_on_flag, log_label). Internal path replaced with a single
call. Bridge calls it after _merge_ams_dict on the merged ams dict,
before the merged state is stored as the 1 Hz cached-as-base source.
Shared shutdown guard kept on both sides: all-zero bits +
power_on_flag=False is the printer-off pattern (#765, would
propagate phantom empties on every reconnect); nonzero bits +
power-off is valid idle-printer state (#1365, X1C between prints)
and still applies. AMS-HT units (id >= 128) skipped on both sides.
Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper
contract directly. 3 new bridge regression tests reproduce the
#1726 wire shape, the shutdown guard, and the AMS-HT skip on the
cached slicer-facing state. Existing internal-state tests for the
bit-clear logic (covers state=9 promotion, loaded-slot preserve,
genuine-removal-with-power-on) continue to pass against the
refactored path.
One pre-existing bridge fixture had an inconsistent tray_exist_bits
('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The
shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match
real-printer wire shape.
Reported by @needo37 with full code-level analysis including the
suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to
verify on a live system.
The Windows installer's embedded Python doesn't carry an IANA tz
database, and the stdlib zoneinfo has no system DB to read on Windows.
ZoneInfo("UTC") raises ZoneInfoNotFoundError on those installs, and
the new /api/local-backup/status endpoint 500s on the resulting
uncaught exception. Surfaced via a Windows traceback from a user's log:
File "...\backend\app\services\local_backup.py", line 32, in _local_zone
return ZoneInfo("UTC")
zoneinfo._common.ZoneInfoNotFoundError: 'No time zone found with key UTC'
_local_zone()'s try/except only covered the TZ-env branch — both
fallbacks unconditionally called ZoneInfo("UTC") and re-raised.
Fix (two parts):
1. services/local_backup.py — return type widened from ZoneInfo to
tzinfo, the UTC fallback is wrapped in its own try, and the
last-resort fallback returns datetime.timezone.utc (stdlib, no
IANA DB needed). str(timezone.utc) == "UTC" so the response shape
on /api/local-backup/status is unchanged. The astimezone call in
_calculate_next_run accepts any tzinfo — no other call sites
affected.
2. requirements.txt — pin tzdata>=2024.1; sys_platform == "win32" so
the next Windows installer build ships the IANA DB, and any non-
UTC TZ value (e.g. Europe/Berlin) resolves correctly. The stdlib
fallback can only ever give UTC. Linux/macOS unaffected by the
platform marker — they already have the system tz database.
The Re-print and Schedule modal toggles for Flow Calibration and Nozzle
Offset Calibration accepted "off" correctly and flowed it through to the
project_file MQTT publish — Bambuddy sent extrude_cali_flag: 2 and
nozzle_offset_cali: 2 per the "1 = run, 2 = skip" reading inherited from
the #1478 / #1682 work. Live test on H2D 01.x: with both toggles off,
the stg queue still scheduled stage 8 ("Calibrating dynamic flow") and
stage 39 ("Nozzle offset calibration"), and the printer ran both at
print start.
Root cause: 2 means "skip the explicit pass but still apply / verify
stored PA via the calibration stage" — close to a no-op K-factor wise
but the per-print physical sequence still runs. 0 is the encoding that
actually drops the stage from stg. A BambuStudio Send-dialog capture
on the same firmware showed 0 for both fields when the user unchecked
the calibrations — contradicting the #1478 commit's read of "BambuStudio
never sends 0."
Fix:
- extrude_cali_flag = 1 if flow_cali else 0 (was: else 2)
- nozzle_offset_cali = 1 if (nozzle_offset_cali and is_dual_nozzle) else 0
(was: else 2)
Dual-nozzle gate stays; single-nozzle prints continue to force-skip the
nozzle-offset cali their head doesn't support (#1682). The 1 (run)
branch is unchanged.
Verified live on the same H2D after the patch: stg dropped to
[29, 13, 4, 14, 3] (cooling, homing, filament change, nozzle cleaning,
vibration comp). Stages 8 and 39 gone.
Vibration compensation is NOT fixed by this commit: vibration_cali is a
bool in our and BambuStudio's wire format, and the H2D firmware queues
stage 3 regardless of the false value. Firmware-side, not solvable at
the dispatch layer with the current field. Filed as a follow-up.
Two warnings polluting every A1 support bundle on healthy prints, both
unrelated to the timelapse-default behaviour the issue actually reports.
1. mqtt_bridge.py's post-bind nudge calls request_status_update on the
real printer's MQTT client to populate the bridge cache without
waiting for the next periodic pushall. The bind frequently races the
TLS handshake, especially on A1 firmware. Skip the nudge when
state.connected is False — the periodic pushall fills the cache
anyway. The WARNING in bambu_mqtt.py stays for the genuinely-
actionable callers (refresh-status API, bug reporter).
2. Post-finish SD-card cleanup (and the symmetric forced-timelapse dir
walk) used delete_file_async's bool return to drive a WARNING when
all candidates failed. A1 firmware self-cleans the SD card before
our cleanup runs — every candidate FTP-DELE returns 550, we burn
the retry budget, then WARN on a successful print. Introduce
DeleteResult.{DELETED,NOT_FOUND,FAILED} so the helpers only WARN
on real network/auth/transient failures. NOT_FOUND advances to the
next candidate without consuming the 2s backoff. User-facing delete
endpoint returns 404 on NOT_FOUND.
The support bundle shipped support-info.json + bambuddy.log, but the raw
shape of the printer's MQTT push_status — the field that blocks per-model
work like AMS Backup detection (deferred in 85fbd7fc) and every vt_tray /
vir_slot / mapping shape regression — was never captured.
Each connected printer now contributes push-status/printer-{i}.json with
{model, firmware_version, captured_at, raw_data}, indexed against
support-info.json["printers"]. Two-pass redaction: a structural walk
drops user-private keys (subtask_name, gcode_file, subtask_id, task_id,
project_id, design_id, profile_id, model_id, gcode_state,
gcode_file_prepare_percent) and rewrites net.info[*].ip to 0.0.0.0
(matches the #1429 VP bridge fix); then the JSON runs through the same
DB-derived sensitive_strings sanitizer the log path uses, catching any
printer name / serial / access code / cloud email that leaked into a
nested string field.
print.cfg, print.option, ams, vt_tray, vir_slot, mapping,
ams_extruder_map, and hardware fields are all preserved — those are the
fields per-model work needs.
Always-on inside the existing debug-logging-required gate; no opt-in
toggle (the bundle is already user-initiated and downloads locally
before the user chooses to send).
Right after the slicer picks a filament for the external spool (vt_tray, ams_id=255),
Bambu firmware pushes a partial vt_tray carrying just {tray_info_idx, tray_color} -
~18 fields shorter than the pushall shape the slicer expects. The #1622 round-4
per-field accumulate (da799447) only carried over prev keys NOT in new, so the
cached vt_tray was replaced wholesale with the 2-field partial. The next 1 Hz
cached-as-base push delivered the stripped dict and BambuStudio rendered the
external slot as invalid (color only, no tray_type / state / k / n / cali_idx /
nozzle_temp_*). Reload restored it because the reconnect-triggered pushall
re-seeded vt_tray, then the cycle repeated. AMS slots didn't suffer because
_merge_ams_dict deep-merged them.
Fix: for every top-level push_status key whose prev AND new are both dicts,
overlay incoming keys onto prev rather than replace. ams is excluded (already
deep-merged). The same shape protects device / online / upgrade_state / ipcam /
upload / net against future firmware partials. net.info IP rewrite is unaffected -
_rewrite_net_info_ips runs before caching and overlay lets the freshly-rewritten
list win over prev when present.
GitGuardian still flagged the file after the previous round even though
every call site used a constant — the constant itself was a static
string built by concatenation, which the generic-password detector still
matched on. Generate the test credential per process via secrets.token_urlsafe
so no password literal lives in the source, and mark the single line where
the variable is bound with the standard `pragma: allowlist secret` marker
ggshield / detect-secrets honour.
GitGuardian flagged the seven hard-coded passwords used by the
privilege-escalation regression suite as potential secrets. They are
test-only credentials whose value is irrelevant — the suite asserts
the admin authorization gate, not password handling — but the pattern
matches the high-confidence detector.
Replace each call-site literal with a single _FIXTURE_PW module
constant, built from string concatenation so it doesn't hash to a
recognisable token, with a comment explaining the purpose and the
complexity rule it satisfies. No behavioural change; all 11 tests
still pass.
Before this change, the Re-print and Schedule modals' per-filament
slot dropdown hid every slot whose extruder didn't match the filament's
slicer-assigned nozzle. On H2D with AMS A+C on the left and B on the
right, an L-assigned filament could only pick A or C and an R-assigned
one could only pick B — locking the user out of cross-extruder picks
even when they'd intentionally loaded the required filament into the
other AMS.
The slicer wasn't the source of the asymmetry: BambuStudio Desktop,
OrcaSlicer Desktop, and the Bambuddy sidecar all produced identical
filament_map values for the same source 3MF. Bambuddy's UI filter was.
Drop the f.extruderId === item.nozzle_id clause in FilamentMapping's
loadedFilaments filter. Single-nozzle and FTS short-circuits stay; the
L/R row badge stays as a hint to the slicer's intent. Printer firmware
decides at start-print whether the cross-extruder ams_mapping is valid.
slice_and_persist writes a .gcode.3mf ZIP container but persisted the row
with file_type="gcode". The G-code preview endpoint short-circuits on
file_type == "gcode" and returns the bytes as text/plain, so the embedded
viewer received the raw ZIP body instead of the embedded toolpath.
- Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type
and external-scan rows).
- get_gcode also routes to the unzip branch when the filename ends with
.gcode.3mf, so rows already written under the bug self-heal on first
preview without a DB migration.
- Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge
to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do.
- Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy
row preview must be text/plain, contain G28, and NOT start with PK.
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
strips system processes/filaments, so importing a bundle left users without
process presets and slicing fell back to embedded settings on STL. Bundle
mode also hid the standard tier behind a constrained dropdown, the actual
trap reported here.
Removed end-to-end:
- backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
SliceBundleSpec, dispatch fork in library.py, bundle-context params on
the filament-requirements endpoints, bundle-fingerprint cache key in
slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
slice_with_bundle, BundleSummary / BundleNotFoundError.
- frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
loses its bundle path; presetCompatibility keeps compatible_printers
plus the @BBL fallback.
- SlicerBundlesPanel turns into a permanent static notice explaining the
removal, alternative import paths, and the new slice-time lookup order
(Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
- i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
translated across all 11 locales; slice.bundle*, slicerBundles.* keys
removed.
Fixed (surfaced by removing bundle mode):
- _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
from: "system" on the payload before json.dumps. Bambu Cloud ships
type as "printer"/"print" and routinely empty `from`; the BS CLI's
--load-settings parser rejects both with return -5 / "input preset
file invalid". Standard tier already did this; cloud paths now match.
Bridge cache replaced prev state wholesale on each incremental, re-merging
only a 14-key allowlist. Capability/lifecycle fields (cali_version,
print_type, mc_print_stage, device, ...) drained out within one 1Hz tick,
greying out BambuStudio's Device-tab UIs (manage-calibration, AMS-slot
dropdown) once the cache thinned. Most P1S users miss it by timing — they
click Device tab while the cache is still fat from the connect pushall.
Switch to per-field accumulate matching bambu_mqtt.py's internal state
handler: prev keys carry over verbatim when not present in the incoming
push, new values overwrite when present. _merge_ams_dict for partial AMS
blobs unchanged (#1387 / #1371 regression guards stay green).
_SLICER_VISIBLE_STICKY_KEYS removed — new logic is a strict superset.
Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
responses correctly: ams_filament_setting round-trips with result=success on P1S,
the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
intact, and the visible "unload" symptom comes from the slicer's choice of
extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
(select by id, both H2D and P1S accept). The open question is what makes the
slicer pick _set vs _sel — likely the info.get_version response Bambuddy
synthesises or the first cached pushall reply the slicer reads at connect.
Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
but no direction for the bridge's own synthesised replies.
Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
every bridge-synthesised reply (info.get_version answer, project_file ack,
on-demand pushall response). Capture is in _publish_to_report — the single
chokepoint — gated on a new log_event param; the 1Hz periodic push threads
log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
dump_wire already covers cache shape per tick.
Diagnostic-only, no data-path change. Default param preserves every existing
call site's behaviour.
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
sub-brand, plus a material-disambiguated colour name from a new
/inventory/colors/by-material endpoint. The printer-mode panel that
renders the same 3MF (FilamentMapping) was reading the same raw
fields — item.type for the required label, getColorName(item.color)
for the swatch tooltip — and was not touched, so picking "Specific
Printer" still showed "Required: PLA - Black" for a slice the
"Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".
Extract the three-query resolution machinery from FilamentOverride
into a shared hook useFilamentLabels (returns positional
{resolvedName, colorLabel} per slot). Both panels call it; both
read the same labels. The hook also owns extractMaterialHint so the
"strip leading brand token" rule has one source of truth.
FilamentMapping required-side now reads {resolvedName} instead of
{item.type}; swatch tooltip reads `Required: {resolvedName} -
{colorLabel}` instead of `Required: {item.type} -
getColorName(item.color)`.
The Print Queue's schedule dialog hid the per-slot "Force color match"
checkbox when a specific printer was picked, even though the scheduler
in print_scheduler.py:535 honours force_color_match regardless of how
the queue item was created. Model-mode ("Any A1") rendered
FilamentOverride which carries the checkbox; printer-mode rendered
FilamentMapping which had no force-match UI at all. Pure UI gap.
Extend FilamentMapping to accept optional forceColorMatch +
onForceColorMatchChange props, mirroring FilamentOverride's signature,
and render the same <Palette>-iconed checkbox under each filament row
when a handler is wired. PrintModal/index.tsx passes the existing
forceColorMatch state through — same object both modes write into so
toggling between modes preserves the user's selection. No new i18n
keys (printModal.forceColorMatch already ships in all 11 locales).
The shape-of-payload dump shipped earlier rules out cache wipes —
shaddowlink's round-1 captures show AMS data reaches the slicer
byte-identical to what the printer sent. The remaining symptom
(picking a generic filament in archive mode "unloads" the slot) lives
on the command path, which the snapshot dump doesn't see: it writes
only the cached _latest_print_state and the periodic 1Hz push.
Add append_event() in _debug.py — same env flag, separate file at
<log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
topic, <channel>.<command> grep handle, and parsed payload. Wired at
two points: mqtt_server._handle_publish for slicer publishes (after
JSON decode so the trace matches what the bridge actually parsed) and
mqtt_bridge._on_printer_raw "everything else" branch for printer
responses (after serial rewrite so the trace matches what the slicer
sees on the wire). Pushall / get_version stay out — both are handled
locally and never round-trip through the bridge.
Bytes payloads get the same \x00-tolerance fix from #927 so
OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
bytes fall back to {"raw": "..."} so every line stays valid JSON.
Native installs that follow the systemd template
WorkingDirectory=/opt/bambuddy
Environment="DATA_DIR=/srv/bambuddy/data"
(or any layout where DATA_DIR is not a subdirectory of the install)
could not apply in-app updates. Every git subprocess in _perform_update
used cwd=settings.base_dir and safe.directory={base_dir}. On standard
installs (DATA_DIR=INSTALL_PATH/data) this happened to work by accident
because git walks up from a subdirectory of the repo to find .git; on
separate-mount layouts the walk has nowhere to go and every call
returns "fatal: not a git repository." safe.directory was also wrong
even on the standard install -- it must equal the repo root git
discovers, not the data dir.
Resolve app_dir = settings.app_dir at the top of _perform_update and
route all four git subprocesses (remote get-url, remote set-url, fetch,
reset --hard) and the embedded safe.directory through it. Rename the
base_dir parameter on _origin_points_at_repo to app_dir so the
signature documents the contract.
Four #1712 issues from the 2026-06-04 Orca Cloud integration:
(1) Tier order put Orca Cloud above everything across SliceModal,
auto-pick scoring, dropdown groups, the AMS slot picker, and the
backend precedence. Bambu-Cloud-only users saw their profiles
deprioritised behind an empty Orca tier.
(2) Cross-tier dedup hid a same-named preset in all but the highest-
priority tier. A user with both a local-imported and an Orca-synced
"Bambu PLA Basic" couldn't see the Orca copy as a picker option.
(3) CloudStatusBanner nagged signed-out users with a permanent
"Sign in to Orca Cloud" line at the top of every slice -- even after
explicit logout. Bambu Cloud had the symmetric problem.
(4) ConfigureAmsSlotModal source badges were inconsistent: Orca rows
showed only "Custom" (no source identity), Bambu Cloud built-in rows
had no badge at all, and the orthogonal isUser-driven "Custom" badge
collided with the source badge for cloud user presets.
Order is local > orca_cloud > cloud > standard everywhere it lives
(SliceModal SLICE_MODAL_TIER_ORDER + TIER_BONUS + dropdown tier list,
ConfigureAmsSlotModal sourceOrder, and backend precedence). The order
drives auto-pick + visual group rendering; it does NOT hide profiles.
_dedupe_by_name replaced with _enrich_cloud_metadata: every tier
returns its full list across all three slots (printer / process /
filament). The function still backfills Bambu Cloud filament metadata
from same-named local / orca_cloud / standard entries so cloud
filaments score in pickFilamentForSlot.
CloudStatusBanner silently no-ops on not_authenticated for both clouds;
expired / unreachable still surface. The not_authenticated i18n keys
stay in the locale files dormant.
ConfigureAmsSlotModal: one source badge per row, one colour per source
(green Local / purple Orca Cloud / bambu-blue Bambu Cloud / amber
Built-in). The legacy isUser-driven "Custom" badge is gone; every row
identifies its tier consistently.
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's
cached push_status (in) and the 1Hz slicer-facing copy (out) to
<log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick.
#1622's symptom — empty filament dropdown in slicer's AMS slot details
for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into
the actual wire bytes flowing through the bridge to bisect between
"cache is missing fields" and "_send_status_report strips them on copy."
The existing logs prove the bridge is bound and pushing at 1Hz, but
not what's in the payload.
Off by default, single env flag, single file per VP per direction
(bounded disk footprint), failures swallowed at debug so a broken
dump can never break the 1Hz loop. 21 tests pin the helper contract:
disabled-by-default, atomic writes, sanitized vp_name (no path
escape), per-call env check so toggling without restart works.
Reprints reuse the source archive row via the expected-print promotion
branch, but that branch never reset archive.timelapse_path. The stale
path made _scan_for_timelapse_with_retries early-return (so the new
run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse
then extracted the *original* run's last frame and shipped it to Telegram
as the new run's finish photo. Surface was specific to the
timelapse-prefer path (data.timelapse_was_active=true and no external
camera) — fallback live-camera paths were unaffected, which is why this
took a P2S reporter to surface.
Clear archive.timelapse_path at promotion and unlink the stale on-disk
MP4 (best-effort; missing files are logged and skipped). The orphan
unlink also kills a long-standing file-leak: every reprint used to leave
its predecessor's timelapse on disk forever. archive.photos is
deliberately left alone — accumulating one finish photo per run is
correct.