H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
external-spool prints instead of 254 like X1C/P1S/A1 do, so the
single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
0 — UI highlighted AMS SLOT 1 instead of the external spool.
Usage credit was unaffected (#1276 covers that via ams_mapping).
The single-nozzle branch now checks _captured_ams_mapping (slicer-
captured per-filament mapping that the request-topic intercept
already tracks) before the existing P2S multi-AMS resolver. When
every entry is -1, the print uses ONLY the external spool, so
state.tray_now is promoted to 254.
Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
overridden — we have no evidence H2S misreports mid-print swaps, and
trusting the firmware preserves correctness for users with multi-
filament setups. No-mapping prints (printer-screen start) fall
through unchanged.
Single failure on a printer with require_previous_success queue items
permanently skipped every downstream + every new item — the
_check_previous_success lookback always walked back to the original
failed row (skipped is excluded from the lookback), and no code path
could dismiss that failure.
Three pieces:
1. PrintQueueItem.gate_acknowledged Boolean column (default False).
SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.
2. _check_previous_success skips rows where gate_acknowledged=True so
acknowledged failures walk past the lookback. Fresh post-resume
failures still gate independently.
3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
printer AND restores items where
status='skipped' AND error_message='Previous print failed or was
aborted' back to pending in one transaction. Returns
{acknowledged, restored}.
Frontend banner above the active Queue tab surfaces blocked printers,
fires a warning-variant ConfirmModal, and shows a precise toast on
success.
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
settings defaults; nozzle_mapping never made it onto the wire.
Three pieces:
1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
slicers that never send MQTT.
2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
UPDATEs slicer-driven fields on a recently-committed queue item
when the event wait already gave up. Tracked via
_recent_queue_items dict (30 s TTL, evicted on every queue-add).
Gated on status='pending' so we never race the dispatcher.
Multi-plate covered via WHERE id IN (...).
3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
arrive during any await inside _add_to_print_queue (wait_for,
archive_print, db.flush, db.commit), and on_print_command would
stash data with no event consumer AND no _recent_queue_items entry
yet. After populating _recent_queue_items, _add_to_print_queue now
pops _slicer_print_options[file_path.name] one last time and
routes any hit through _restamp inline.
When the printer reports ams_filament_backup=True,
compute_deficit_for_queue_item pools remaining_grams across spools
matching (preset, colour) on the same printer (scoped per extruder on
dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
same slicer_filament preset AND same colour (alpha-normalised). Two
PETG HF spools in different colours are NOT pooled — the firmware would
swap correctly but the print would change colour mid-run. Spoolman side
mirrors the rule via filament.id + color_hex. Backup OFF falls back to
the pre-PR per-slot accounting line-for-line.
8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
pool insufficient, different presets, backup-OFF regression, dual-
extruder side scoping, no-preset never pairs, colour-strict, and
alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
cases stay green.
feat(printers): AMS Filament Backup modal with BS-style ring per pair
Badge click on the Filaments section header (#1766) now opens a
modal: filament-colour ring per backup pair, material name + rotation
count in the centre, slot labels distributed around the colour band on
contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
widget. Lone slots are intentionally not listed. R / L badges per ring
when the extruder map carries two distinct values; collapses to no-
badge rendering for single-nozzle printers misflagged as dual.
Esc keypress closes the modal. Theme-aware via CSS variables matching
AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
defensively dedupes duplicate ams.id entries observed on switch-VP
aggregations.
10 modal render cases pin: Esc closes / unmount nulls the listener /
ring renders for pairs and omits lone slots / R-L badges only when
extruder map has distinct values / empty state / toggle gating.
13 frontend cases pin computeBackupGroups identity rules.
feat(printers): active-print P-N pill on AMS slot tiles during RUNNING
While the printer is mid-print, each AMS slot tile referenced by
status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
right corner, naming which print-slot is mapped to that AMS slot.
Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
staged to a printer with mismatched filament, no way to verify mid-
print. Same wire data (status.ams_mapping is already on the wire) —
the addition is purely surface.
The existing ring-bambu-green highlight for effectiveTrayNow keeps its
meaning (currently extruding RIGHT NOW); the pill is the per-slot
static assignment for the active print.
chore(scheduler): log Print Anyway short-circuit at INFO
_block_on_filament_deficit logs at INFO when it honours
item.skip_filament_check, so a future "Print Anyway didn't work" report
(third commenter on #1762 hit this shape) has actionable evidence in
the standard support bundle without DEBUG. Bundled because the deficit
fix makes the original symptom disappear for users with backup ON.
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
on_finish_photo_moment and on_print_complete back-to-back, so the
moment-producer's RTSP grab and the print-complete consumer's cache
read race — consumer wins the empty pop, then its own RTSP fallback
times out against the producer's in-flight grab (Bambu printers allow
one RTSP client). 394 KB frame captured, notification went text-only.
Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
registers before first await, sets it in finally on every exit;
consumer awaits with a 20s timeout (15s producer grab + headroom)
before the cache pop. Closes the race AND the concurrent-RTSP timeout
in one change. Timelapse path skips the event, so its branch is
unchanged.
First-attempt fix (d196cfc5) was wrong about the cause. Real root,
traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
support bundle:
mqtt_server.py:1296 was passing the slicer's bare subtask_name
(e.g. "Model_Name") into on_print_command, which stashed under
that key. _add_to_print_queue looked up under file_path.name
(the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
two strings never matched. pop returned None, the 2s wait fired
against a key the stash side never signaled, every captured
slicer field silently fell back to settings defaults.
Affected EVERY Bambu Studio "Send" upload across EVERY model —
not just H2C nozzle_mapping. bed_leveling / flow_cali /
vibration_cali / layer_inspect / timelapse from the original
#1403 capture have been silently ignored since BambuStudio
started splitting subtask_name (bare) from file (with extension).
Unit tests passed because fixtures called on_print_command with
file_path.name directly, bypassing the broken caller.
Fix in manager.py::on_print_command: derive
stash_key = data.get("file") or filename and use it for both
_slicer_print_options and the event lookup. filename
(subtask_name) still flows unchanged to _schedule_finish_release
— push_status echoes it back as gcode_file / subtask_name and
the slicer matches against its own subtask_name there, so
re-routing that path was a separate regression I caught and
reverted mid-audit.
Also: nozzles_info field was a wrong guess in d196cfc5 —
BambuStudio never sends it (confirmed via wire capture). Drop
the capture, dispatch, schema, kwarg, and route paths. DB
column stays nullable so old rows still load; nothing reads
or writes it.
Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
options after the 2s wait, including the looked-up key and the
actual cache keys present. Future stash/lookup mismatches will
be obvious from a log line instead of needing a wire capture.
Behaviour change worth flagging: users on Bambu Studio whose
slicer-side bed-leveling / flow-cali / vibration-cali /
layer-inspect / timelapse differ from Bambuddy's
default-workflow settings will see their slicer choices
honored now instead of silently overridden. Restores #1403's
original intent.
Reporter @thenewguy runs an engineering farm with one AMS per material
(PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
threshold (default 60%) was driving both the queue / ambient auto-drying
trigger AND the hourly humidity alarm uniformly, which is wrong for
multi-material setups where Nylon wants <10% and PLA is fine at 60%.
Drying RUN parameters were already per-filament via drying_presets;
this commit adds the missing per-filament TRIGGER.
New setting ams_humidity_thresholds — JSON map of filament-type to
threshold percent with a "default" key for unknown / unmapped types.
Empty / unset → both consumers fall back to ams_humidity_fair so the
upgrade is silent.
Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
thresholds, fallback) — picks the lowest (most-restrictive) threshold
across all loaded tray types, matching the conservative-params strategy
_get_conservative_drying_params already uses for temp / hours. Empty
tray slots contribute no constraint; all-empty AMS falls through to the
"default" key. Filament names normalized to uppercase base (so
"PLA Basic" / "pla basic" both map to PLA).
Two consumer sites rewired through the same resolver so the scheduler
and the alarm path can never disagree about whether an AMS is "too
humid":
- print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
for start / stop / skip decisions.
- main.py AMS sensor / alarm worker — hourly humidity alarm notifier.
UI: new table in Settings → Workflow → Auto-Drying, below the existing
Drying Presets table. Default row + 8 default filament types
(PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
current ams_humidity_fair value so the editor starts sensibly.
Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
state per row). onChange only updates the draft; onBlur (and Enter)
parses + clamps to [5, 95] + commits. Empty value on blur clears the
override and falls back to default. Caught mid-PR via a typing test:
the naive per-keystroke clamp snapped "3" → 5 before the user could
type the second digit of "30".
Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
as drying_presets and ams_humidity_fair — non-sensitive integer map,
no SETTINGS_READ permission required for badge-color rendering).
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
0d4b9d4e gives passive every-visit visibility on one page; this adds
opt-out-able active visibility at moments where the user has just
earned something with Bambuddy.
Five trigger families with a 14-day cross-family cooldown: prints
(100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
energy), archives (50/250/1000), anniversary (1 year), version-update
(re-armable on each major bump). New sponsor_toast_state table with
nullable user_id so auth-disabled installs get the same trigger logic
through one code path (NULL-keyed install-default row).
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
adds a 10x10 LineChart icon on each heater tile - click body opens the existing
target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
gets an interaction. Retention configurable via printer_sensor_history_retention_days
(default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
status/control surface, and the #1766 fix that depends on it.
Added -- AMS Filament Backup status + control
- Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
on every push_status. Verified against OrcaSlicer source
(DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
(None = A1 family / pre-cfg push) preserves today's behaviour.
- Hold-timer guard (3 s) prevents stale frames from flickering the badge
back to the printer's old cfg after a user-initiated toggle.
- POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
method calling _set_print_option("auto_switch_filament", enabled).
- GET /printers/{id}/inventory-remain endpoint exposes the same map the
dispatcher uses (internal and Spoolman modes both work uniformly).
- Small icon badge in the printer card's "Filaments" section header
(placement reads as printer-wide because the cfg bit is printer-wide,
not per-AMS). Click to toggle, success toast.
- 5 i18n keys x 11 locales for the badge UI.
Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
- Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
to False when status.ams_filament_backup is False; log the skip.
- New effectivePreferLowest(setting, backup) helper applied at every
frontend sort entry point: single-printer PrintModal, multi-printer
hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
standalone editor (the last had NO preferLowest awareness at all
before this change).
- New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
key exactly, including the banding tie-break (regular AMS < AMS-HT <
external) so the client-side pre-compute matches the dispatch-time pick.
An earlier draft used a flat `amsId * 4 + trayId` priority which gave
external slots (ams_id = -1) a NEGATIVE priority -- caught in code
review before commit.
- Settings -> Filament -> "Prefer lowest remaining filament" gets an
explanatory note about the printer-side AMS Backup dependency, with
i18n key in all 11 locales.
The 3MF parser sums prediction + weight across every plate (#1593) so the
archive card can headline the whole project — correct for the card, wrong
for the completion notification of a single plate. The queue UI already
re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the
notification path so Discord / Pushover / email show the plate's actual
duration and grams instead of the project sum. Helper fails open on every
error path so a missing or corrupt 3MF can't block the notification.
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
nozzle-rack variant) carries nozzle_mapping (per-filament physical
nozzle position IDs) and nozzles_info (per-extruder rack metadata).
The VP intake was dropping both, so the H2C firmware fell back to
"last matching nozzle type" auto-pick and ignored the user's
slicer choice — every HF print landed on R2, every standard print
landed on R4.
Carry both fields through the VP intake → queue item → MQTT
dispatch path. New nullable TEXT columns on print_queue, non-
branched ALTER (matches ams_mapping / filament_overrides
precedent). Dual-nozzle gate at start_print() keeps the fields
off single-nozzle dispatches. Fail-open on malformed JSON —
firmware auto-picks, never worse than pre-fix.
Stamps both fields on every plate in the multi-plate Send All
loop (#1697 / #1188 precedent).
ams_mapping2 still handles H2D/X2D dual-extruder routing
unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
Bambuddy's archive cards were blank for every print sliced through the
BS or Orca docker sidecars. The "Some recent prints couldn't be archived
with thumbnails" banner pointed at install step 4 which is unrelated —
that flag only fires on FTP-fetch failures, not on missing-thumb in the
sliced 3MF.
Root cause is upstream of Bambuddy: neither slicer CLI renders
Metadata/plate_N.png when invoked headlessly with --slice --export-3mf.
That render is a separate code path triggered by --export-png, which is
mutually exclusive with --export-3mf and additionally needs a working
display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland —
even XDG_SESSION_TYPE=x11 + GDK_BACKEND=x11 + QT_QPA_PLATFORM=xcb don't
switch it back). An Xvfb display in the sidecar wouldn't help even if we
wired the second-pass call. The Orca sidecar has been silently shipping
thumbnail-less 3MFs from STL inputs since launch; nobody noticed.
Fill the gap on the Bambuddy side: new plate_thumbnail.py renders the
missing thumbnails after the slice returns. inject_plate_thumbnails_if_missing
parses the sliced zip, finds every Metadata/plate_N.gcode entry that
doesn't have a matching plate_N.png, loads 3D/3dmodel.model via trimesh,
renders an isometric Bambu-green-on-dark view at 512x512 + 128x128 via
the same matplotlib Agg pipeline as stl_thumbnail.py, and re-packs the
zip with the PNGs injected. Visual style matches Bambuddy's existing
library thumbnails — archive cards stay consistent inside Bambuddy rather
than chasing parity with desktop Studio's plate render. Best-effort:
input bytes are returned unchanged on any failure so the slice flow itself
can never fail because of a missing thumbnail. Idempotent: re-running on
an already-injected 3MF returns the input verbatim.
Wired into both library.py slice paths via result._replace; covers the
cross-class merged-multi-plate path automatically (merged bytes flow into
the same write site). No sidecar Dockerfile change required — an earlier
attempt to install Xvfb in Dockerfile.bambu-studio was a false start and
is not part of this drop.
Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal)
and lxml (model.xml parse) lazily inside the 3MF code path — both added
to requirements.txt because they aren't strict trimesh transitives.
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
as the elevated response shape; PrinterResponse no longer carries the
field. Auth-disabled single-trust mode preserved.
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.
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
external-spool prints instead of 254 like X1C/P1S/A1 do, so the
single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
0 — UI highlighted AMS SLOT 1 instead of the external spool.
Usage credit was unaffected (#1276 covers that via ams_mapping).
The single-nozzle branch now checks _captured_ams_mapping (slicer-
captured per-filament mapping that the request-topic intercept
already tracks) before the existing P2S multi-AMS resolver. When
every entry is -1, the print uses ONLY the external spool, so
state.tray_now is promoted to 254.
Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
overridden — we have no evidence H2S misreports mid-print swaps, and
trusting the firmware preserves correctness for users with multi-
filament setups. No-mapping prints (printer-screen start) fall
through unchanged.
Single failure on a printer with require_previous_success queue items
permanently skipped every downstream + every new item — the
_check_previous_success lookback always walked back to the original
failed row (skipped is excluded from the lookback), and no code path
could dismiss that failure.
Three pieces:
1. PrintQueueItem.gate_acknowledged Boolean column (default False).
SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.
2. _check_previous_success skips rows where gate_acknowledged=True so
acknowledged failures walk past the lookback. Fresh post-resume
failures still gate independently.
3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
printer AND restores items where
status='skipped' AND error_message='Previous print failed or was
aborted' back to pending in one transaction. Returns
{acknowledged, restored}.
Frontend banner above the active Queue tab surfaces blocked printers,
fires a warning-variant ConfirmModal, and shows a precise toast on
success.
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
settings defaults; nozzle_mapping never made it onto the wire.
Three pieces:
1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
slicers that never send MQTT.
2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
UPDATEs slicer-driven fields on a recently-committed queue item
when the event wait already gave up. Tracked via
_recent_queue_items dict (30 s TTL, evicted on every queue-add).
Gated on status='pending' so we never race the dispatcher.
Multi-plate covered via WHERE id IN (...).
3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
arrive during any await inside _add_to_print_queue (wait_for,
archive_print, db.flush, db.commit), and on_print_command would
stash data with no event consumer AND no _recent_queue_items entry
yet. After populating _recent_queue_items, _add_to_print_queue now
pops _slicer_print_options[file_path.name] one last time and
routes any hit through _restamp inline.
Extends the appliance endpoint that landed in the previous commit with a
time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
marker after the 3-minute timeout). The RPi 5 has no battery-backed RTC,
so on a fresh boot the system clock is wrong until NTP catches up -- JWT
expiries and TLS certificate validity windows depend on this being right.
Exposing the gate lets the SPA render a "time not synced" indicator while
that's still true and clear it once "ok" comes through.
backend/app/core/local_config.py
New read_ntp_gate(path) function alongside read_local_toml. Three states:
"ok" chrony reported sync within the 3-minute window
"warning" 3-minute timeout elapsed without sync; user already waited
and the wizard proceeded with a degraded clock
None file absent (non-appliance install), OSError, empty content,
unknown marker, or binary garbage -- "unknown / don't gate"
Defensive read mode (errors="replace") survives non-utf8 content without
crashing. Module docstring broadened from "local.toml reader" to "small
readers for appliance-set state files".
backend/app/api/routes/system.py
/system/appliance now returns:
{hostname, timezone, locale, time_synced}
with the same no-auth posture: bootstrap surfaces (i18n init, time-sync
banner) read this before auth might be set up, and the contents are
non-secret (user-set defaults + a public sync flag). The endpoint
docstring expands to explain the RTC motivation -- otherwise the
time_synced field reads like a leftover.
Closes the cross-repo contract started in bambuddy-appliance: the firstboot
wizard writes /etc/bambuddy/local.toml with the user's hostname / timezone /
locale, but nothing on the main app side read it. Hostname + timezone are
already applied by the appliance's firstboot.sh via hostnamectl /
timedatectl. This PR closes the loop for the third field — locale — so the
language the user picked in the wizard actually shows up on first SPA load.
backend/app/core/local_config.py
New module. read_local_toml(path) returns a LocalConfig TypedDict
({hostname?, timezone?, locale?}) parsed from /etc/bambuddy/local.toml.
Defensive on every failure mode -- missing file returns {}, invalid TOML
returns {} + log warning, non-string values dropped with warning. The
reader never raises; a malformed config never blocks startup.
backend/app/api/routes/system.py
New endpoint GET /system/appliance. Returns {hostname, timezone, locale}
with null for any field not present in the TOML. No auth required: the
frontend i18n bootstrap reads this before auth might be set up, and the
contents are user-set defaults, not secrets. The function calls
read_local_toml() with no args (default path) so tests can monkeypatch
the module's read_local_toml reference to inject fixtures.
frontend/src/i18n/index.ts
One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
bambuddy_appliance_locale_consumed localStorage flag so it runs at most
once per appliance. Fetches /api/v1/system/appliance, validates the
returned locale against supportedLngs, calls i18n.changeLanguage if
valid. Silent .catch() because the endpoint absent / unreachable means
non-appliance install or dev environment -- we leave the LanguageDetector's
choice in place. The consumed flag is set on success; future loads skip
the fetch entirely. Won't override a user's explicit language pick (the
language picker writes to a separate localStorage key, bambutrack_language).
Reporter (email provider, "Reason: unknown" failures) wanted a camera snapshot
in failure emails. The finish-photo capture path shipped in 0.2.5b1 (#1397)
already loads JPEG bytes into archive_data["image_data"] for terminal print
events, and pushover/telegram/discord/ntfy users have been getting them.
Email was the one provider that dropped the bytes on the floor. Reporter
separately flagged that the Message Templates list shows "Print Completed"
and "User Print Completed" with no visual cue they're different dispatches.
Both fixes in one commit because they touch the same UI surface (Message
Templates) and both stem from the same reporter conversation.
1) Inline finish-photo embed in email — template-driven, opt-in.
_send_email now accepts finish_photo_url alongside image_data. Inline
embed fires only when bytes are present AND URL is set AND the rendered
body contains that URL — i.e. the user's template referenced the existing
{finish_photo_url} variable. The multipart/related shape wraps a
multipart/alternative (plain + HTML) plus an inline MIMEImage with
Content-ID: <bambuddy-finish-photo>. The HTML part replaces the escaped
URL in-place with the cid <img>, so the image appears WHERE the user put
the variable in the template, not stapled to the bottom. Plain-text part
keeps the URL as a clickable link for non-HTML clients.
First draft of this fix unconditionally inlined the photo whenever
image_data was present, which bypassed the template system. Reverted to
the template-driven contract: default templates unchanged, opt-in by
editing the template body to include {finish_photo_url}.
2) user_print_* template name disambiguation.
The four user_print_* templates are the per-user SMTP emails sent to the
print's submitter (advanced-auth-only path). They shared the "Print
Completed" / "Print Failed" / etc. short names with the broadcast
provider templates, so the Message Templates list was indistinguishable.
The EVENT_NAMES display map in routes/notification_templates.py already
used the disambiguated "… Email" labels, but the seed wrote the short
name to the DB.
DEFAULT_TEMPLATES now seeds the four user_print_* rows with " Email"
suffix so fresh installs are correctly labelled. New
_migrate_rename_user_print_template_names runs on startup and updates
existing rows where the name still matches the old default. Admin-edited
names are preserved. Standard SQL UPDATE works on both SQLite and
Postgres without dialect branching.
When the printer reports ams_filament_backup=True,
compute_deficit_for_queue_item pools remaining_grams across spools
matching (preset, colour) on the same printer (scoped per extruder on
dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
same slicer_filament preset AND same colour (alpha-normalised). Two
PETG HF spools in different colours are NOT pooled — the firmware would
swap correctly but the print would change colour mid-run. Spoolman side
mirrors the rule via filament.id + color_hex. Backup OFF falls back to
the pre-PR per-slot accounting line-for-line.
8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
pool insufficient, different presets, backup-OFF regression, dual-
extruder side scoping, no-preset never pairs, colour-strict, and
alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
cases stay green.
feat(printers): AMS Filament Backup modal with BS-style ring per pair
Badge click on the Filaments section header (#1766) now opens a
modal: filament-colour ring per backup pair, material name + rotation
count in the centre, slot labels distributed around the colour band on
contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
widget. Lone slots are intentionally not listed. R / L badges per ring
when the extruder map carries two distinct values; collapses to no-
badge rendering for single-nozzle printers misflagged as dual.
Esc keypress closes the modal. Theme-aware via CSS variables matching
AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
defensively dedupes duplicate ams.id entries observed on switch-VP
aggregations.
10 modal render cases pin: Esc closes / unmount nulls the listener /
ring renders for pairs and omits lone slots / R-L badges only when
extruder map has distinct values / empty state / toggle gating.
13 frontend cases pin computeBackupGroups identity rules.
feat(printers): active-print P-N pill on AMS slot tiles during RUNNING
While the printer is mid-print, each AMS slot tile referenced by
status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
right corner, naming which print-slot is mapped to that AMS slot.
Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
staged to a printer with mismatched filament, no way to verify mid-
print. Same wire data (status.ams_mapping is already on the wire) —
the addition is purely surface.
The existing ring-bambu-green highlight for effectiveTrayNow keeps its
meaning (currently extruding RIGHT NOW); the pill is the per-slot
static assignment for the active print.
chore(scheduler): log Print Anyway short-circuit at INFO
_block_on_filament_deficit logs at INFO when it honours
item.skip_filament_check, so a future "Print Anyway didn't work" report
(third commenter on #1762 hit this shape) has actionable evidence in
the standard support bundle without DEBUG. Bundled because the deficit
fix makes the original symptom disappear for users with backup ON.
Split Obico failure-detection dispatch out of the multiplexed
on_printer_error event onto its own on_ai_failure_detection event so
users can subscribe to AI alerts without also enabling HMS hardware-
error pages, and so the discoverable label "AI Failure Detection" is
what subscribes them rather than the unrelated "Printer Error" toggle.
New column on notification_providers (default False, branched
SQLite/Postgres migration), new notification_service.on_ai_failure_detection
method, new ai_failure_detection template, obico_actions._notify swap.
Frontend gets a summary badge, a toggle row with description, and ntfy
priority surfacing. 14 new tests pin the routing + the regression guard
("Printer Error" alone must NOT receive AI notifications now). 11 locales
covered.
Existing providers keep working: HMS hardware errors continue to ride
on_printer_error unchanged; users who want spaghetti alerts opt in via
the new toggle.
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
on_finish_photo_moment and on_print_complete back-to-back, so the
moment-producer's RTSP grab and the print-complete consumer's cache
read race — consumer wins the empty pop, then its own RTSP fallback
times out against the producer's in-flight grab (Bambu printers allow
one RTSP client). 394 KB frame captured, notification went text-only.
Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
registers before first await, sets it in finally on every exit;
consumer awaits with a 20s timeout (15s producer grab + headroom)
before the cache pop. Closes the race AND the concurrent-RTSP timeout
in one change. Timelapse path skips the event, so its branch is
unchanged.
First-attempt fix (d196cfc5) was wrong about the cause. Real root,
traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
support bundle:
mqtt_server.py:1296 was passing the slicer's bare subtask_name
(e.g. "Model_Name") into on_print_command, which stashed under
that key. _add_to_print_queue looked up under file_path.name
(the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
two strings never matched. pop returned None, the 2s wait fired
against a key the stash side never signaled, every captured
slicer field silently fell back to settings defaults.
Affected EVERY Bambu Studio "Send" upload across EVERY model —
not just H2C nozzle_mapping. bed_leveling / flow_cali /
vibration_cali / layer_inspect / timelapse from the original
#1403 capture have been silently ignored since BambuStudio
started splitting subtask_name (bare) from file (with extension).
Unit tests passed because fixtures called on_print_command with
file_path.name directly, bypassing the broken caller.
Fix in manager.py::on_print_command: derive
stash_key = data.get("file") or filename and use it for both
_slicer_print_options and the event lookup. filename
(subtask_name) still flows unchanged to _schedule_finish_release
— push_status echoes it back as gcode_file / subtask_name and
the slicer matches against its own subtask_name there, so
re-routing that path was a separate regression I caught and
reverted mid-audit.
Also: nozzles_info field was a wrong guess in d196cfc5 —
BambuStudio never sends it (confirmed via wire capture). Drop
the capture, dispatch, schema, kwarg, and route paths. DB
column stays nullable so old rows still load; nothing reads
or writes it.
Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
options after the 2s wait, including the looked-up key and the
actual cache keys present. Future stash/lookup mismatches will
be obvious from a log line instead of needing a wire capture.
Behaviour change worth flagging: users on Bambu Studio whose
slicer-side bed-leveling / flow-cali / vibration-cali /
layer-inspect / timelapse differ from Bambuddy's
default-workflow settings will see their slicer choices
honored now instead of silently overridden. Restores #1403's
original intent.
Reporter @thenewguy runs an engineering farm with one AMS per material
(PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
threshold (default 60%) was driving both the queue / ambient auto-drying
trigger AND the hourly humidity alarm uniformly, which is wrong for
multi-material setups where Nylon wants <10% and PLA is fine at 60%.
Drying RUN parameters were already per-filament via drying_presets;
this commit adds the missing per-filament TRIGGER.
New setting ams_humidity_thresholds — JSON map of filament-type to
threshold percent with a "default" key for unknown / unmapped types.
Empty / unset → both consumers fall back to ams_humidity_fair so the
upgrade is silent.
Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
thresholds, fallback) — picks the lowest (most-restrictive) threshold
across all loaded tray types, matching the conservative-params strategy
_get_conservative_drying_params already uses for temp / hours. Empty
tray slots contribute no constraint; all-empty AMS falls through to the
"default" key. Filament names normalized to uppercase base (so
"PLA Basic" / "pla basic" both map to PLA).
Two consumer sites rewired through the same resolver so the scheduler
and the alarm path can never disagree about whether an AMS is "too
humid":
- print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
for start / stop / skip decisions.
- main.py AMS sensor / alarm worker — hourly humidity alarm notifier.
UI: new table in Settings → Workflow → Auto-Drying, below the existing
Drying Presets table. Default row + 8 default filament types
(PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
current ams_humidity_fair value so the editor starts sensibly.
Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
state per row). onChange only updates the draft; onBlur (and Enter)
parses + clamps to [5, 95] + commits. Empty value on blur clears the
override and falls back to default. Caught mid-PR via a typing test:
the naive per-keystroke clamp snapped "3" → 5 before the user could
type the second digit of "30".
Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
as drying_presets and ams_humidity_fair — non-sensitive integer map,
no SETTINGS_READ permission required for badge-color rendering).
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
0d4b9d4e gives passive every-visit visibility on one page; this adds
opt-out-able active visibility at moments where the user has just
earned something with Bambuddy.
Five trigger families with a 14-day cross-family cooldown: prints
(100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
energy), archives (50/250/1000), anniversary (1 year), version-update
(re-armable on each major bump). New sponsor_toast_state table with
nullable user_id so auth-disabled installs get the same trigger logic
through one code path (NULL-keyed install-default row).
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
adds a 10x10 LineChart icon on each heater tile - click body opens the existing
target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
gets an interaction. Retention configurable via printer_sensor_history_retention_days
(default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
status/control surface, and the #1766 fix that depends on it.
Added -- AMS Filament Backup status + control
- Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
on every push_status. Verified against OrcaSlicer source
(DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
(None = A1 family / pre-cfg push) preserves today's behaviour.
- Hold-timer guard (3 s) prevents stale frames from flickering the badge
back to the printer's old cfg after a user-initiated toggle.
- POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
method calling _set_print_option("auto_switch_filament", enabled).
- GET /printers/{id}/inventory-remain endpoint exposes the same map the
dispatcher uses (internal and Spoolman modes both work uniformly).
- Small icon badge in the printer card's "Filaments" section header
(placement reads as printer-wide because the cfg bit is printer-wide,
not per-AMS). Click to toggle, success toast.
- 5 i18n keys x 11 locales for the badge UI.
Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
- Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
to False when status.ams_filament_backup is False; log the skip.
- New effectivePreferLowest(setting, backup) helper applied at every
frontend sort entry point: single-printer PrintModal, multi-printer
hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
standalone editor (the last had NO preferLowest awareness at all
before this change).
- New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
key exactly, including the banding tie-break (regular AMS < AMS-HT <
external) so the client-side pre-compute matches the dispatch-time pick.
An earlier draft used a flat `amsId * 4 + trayId` priority which gave
external slots (ams_id = -1) a NEGATIVE priority -- caught in code
review before commit.
- Settings -> Filament -> "Prefer lowest remaining filament" gets an
explanatory note about the printer-side AMS Backup dependency, with
i18n key in all 11 locales.
The 3MF parser sums prediction + weight across every plate (#1593) so the
archive card can headline the whole project — correct for the card, wrong
for the completion notification of a single plate. The queue UI already
re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the
notification path so Discord / Pushover / email show the plate's actual
duration and grams instead of the project sum. Helper fails open on every
error path so a missing or corrupt 3MF can't block the notification.
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
nozzle-rack variant) carries nozzle_mapping (per-filament physical
nozzle position IDs) and nozzles_info (per-extruder rack metadata).
The VP intake was dropping both, so the H2C firmware fell back to
"last matching nozzle type" auto-pick and ignored the user's
slicer choice — every HF print landed on R2, every standard print
landed on R4.
Carry both fields through the VP intake → queue item → MQTT
dispatch path. New nullable TEXT columns on print_queue, non-
branched ALTER (matches ams_mapping / filament_overrides
precedent). Dual-nozzle gate at start_print() keeps the fields
off single-nozzle dispatches. Fail-open on malformed JSON —
firmware auto-picks, never worse than pre-fix.
Stamps both fields on every plate in the multi-plate Send All
loop (#1697 / #1188 precedent).
ams_mapping2 still handles H2D/X2D dual-extruder routing
unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
Bambuddy's archive cards were blank for every print sliced through the
BS or Orca docker sidecars. The "Some recent prints couldn't be archived
with thumbnails" banner pointed at install step 4 which is unrelated —
that flag only fires on FTP-fetch failures, not on missing-thumb in the
sliced 3MF.
Root cause is upstream of Bambuddy: neither slicer CLI renders
Metadata/plate_N.png when invoked headlessly with --slice --export-3mf.
That render is a separate code path triggered by --export-png, which is
mutually exclusive with --export-3mf and additionally needs a working
display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland —
even XDG_SESSION_TYPE=x11 + GDK_BACKEND=x11 + QT_QPA_PLATFORM=xcb don't
switch it back). An Xvfb display in the sidecar wouldn't help even if we
wired the second-pass call. The Orca sidecar has been silently shipping
thumbnail-less 3MFs from STL inputs since launch; nobody noticed.
Fill the gap on the Bambuddy side: new plate_thumbnail.py renders the
missing thumbnails after the slice returns. inject_plate_thumbnails_if_missing
parses the sliced zip, finds every Metadata/plate_N.gcode entry that
doesn't have a matching plate_N.png, loads 3D/3dmodel.model via trimesh,
renders an isometric Bambu-green-on-dark view at 512x512 + 128x128 via
the same matplotlib Agg pipeline as stl_thumbnail.py, and re-packs the
zip with the PNGs injected. Visual style matches Bambuddy's existing
library thumbnails — archive cards stay consistent inside Bambuddy rather
than chasing parity with desktop Studio's plate render. Best-effort:
input bytes are returned unchanged on any failure so the slice flow itself
can never fail because of a missing thumbnail. Idempotent: re-running on
an already-injected 3MF returns the input verbatim.
Wired into both library.py slice paths via result._replace; covers the
cross-class merged-multi-plate path automatically (merged bytes flow into
the same write site). No sidecar Dockerfile change required — an earlier
attempt to install Xvfb in Dockerfile.bambu-studio was a false start and
is not part of this drop.
Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal)
and lxml (model.xml parse) lazily inside the 3MF code path — both added
to requirements.txt because they aren't strict trimesh transitives.
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.
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.
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.
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.
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.