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.
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).
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.
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.
The matcher's tray_info_idx vs color vs type_only bucket choice was
debug-only, so a bug report's bundle never showed which path won. Emit
the sorted candidate trays and the picked bucket per filament req when
prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
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.
When the user clicked Print Anyway on a filament-deficit warning, the
acknowledgement was one-shot. The route cleared manual_start and
filament_short, then the next scheduler tick re-ran
compute_deficit_for_queue_item against identical spool state, found
the same deficit, and re-set both flags. The item bounced between
"user said anyway" and "scheduler re-blocked" — every Play click
returned 409, every confirm got rolled back on the next tick.
Add a persistent acknowledgement flag on the queue item:
- New column `skip_filament_check` on print_queue. SQLite + Postgres
migration branched on is_sqlite() so Postgres doesn't reject
DEFAULT 0 on BOOLEAN.
- PrintQueueItemCreate + PrintQueueItemResponse schemas + the
TypeScript types carry the field.
- POST /print-queue/{id}/start with skip_filament_check=true now
ALSO sets item.skip_filament_check = True (not just clearing
manual_start / filament_short).
- PrintScheduler._block_on_filament_deficit short-circuits to
False — no compute, no flag-setting, no notification — when
item.skip_filament_check is True. We trust the operator's
decision and stop fighting them.
- PrintModal at queue-creation time threads
skip_filament_check=true into the create payload when the user
clicks Print Anyway on the frontend deficit warning, so a print
that was warned-then-acknowledged at add-to-queue time goes in
pre-acknowledged — scheduler never blocks it on first tick.
Flag is not auto-cleared on spool swap by design: if remaining is
now sufficient, the check returns no deficit anyway, so the flag
is moot. Auto-clearing would add lifecycle complexity without
changing behaviour.
AMS Backup awareness (the other half of the discussion) intentionally
NOT included — verified the H2D's bit-26 of print.cfg toggles with
the printer-side AMS Backup setting, but the X1C's cfg has a
different shape entirely and verifying every model family isn't
realistic. Silently under-warning would be worse than always
per-slot. The check stays single-slot for now.
When a print targets a single plate from a multi-plate 3MF, both the
internal Filament Inventory tracker and the Spoolman-mode tracker parsed
the 3MF without a plate filter and summed every plate's filament — so a
single lid print debited the spool the entire file's grey + black totals.
The 3MF parser already supports plate_id (queue pre-flight uses it at
print_queue.py:254/:286). Plumbed it through both dispatch paths:
Queue path:
- PrintSession gains a plate_id field; on_print_start queries the
printer's currently-printing queue row and records queue_item.plate_id
onto the session.
- _track_from_3mf accepts plate_id and passes it to the extractor.
- store_print_data moves its existing queue-item lookup above the
extract and uses queue_item.plate_id as the plate filter.
Direct-Print path (reprintArchive / printLibraryFile — never goes
through the queue):
- _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
- register_expected_print accepts plate_id and stores it; the 2 sites in
background_dispatch.py and the 1 site in print_scheduler.py now pass
it (resolve was already happening, just needed reordering before the
register call so the value is available).
- Expected-print promotion in main.py injects _print_plate_ids[archive_id]
into the session, guarded so a queue capture wins over the dict.
- _get_start_plate_id helper feeds plate_id into all 3
_store_spoolman_print_data call sites; spoolman_tracking.store_print_data
takes the caller value first, falls back to queue_item.plate_id.
PrintArchive.filament_used_grams stays file-level summed by design
(#1593's contract — the archive describes the file, not the run); only
the per-run usage attribution becomes plate-aware. Single-plate direct
prints resolve to plate_id=1 → plate 1 = whole file, identical to the
prior no-filter behaviour.
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
calibration off.
start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
always send 2 even if a stale flag arrives. The kwarg threads through
printer_manager, both background_dispatch sites, and print_scheduler so
every dispatch path respects the per-item setting.
print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
library FilePrintRequest, archive ReprintRequest, settings.
PrintModal renders the new toggle only when the selected printer is dual-
nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
hide unless any registered printer is dual-nozzle. Labels reuse the existing
settings.default* keys so the only new i18n strings are
settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
- real translations in all 11 locales.
_watchdog_print_start now treats subtask_id-advance as Phase A
"command landed", not as final success. Phase B (180s) keeps watching
for the active-state transition; if it never arrives — printer
accepted the file but stalled (cloud+LAN re-auth after a power cycle
on old firmware was the reported trigger) — revert the queue item to
'pending' instead of leaving it stuck in 'printing' until container
restart. Phase B skips force_reconnect because subtask_id-advance
proves the project_file landed; a reconnect mid-parse would trigger
0500_4003 (#1150). Phase A's H2D 50s FINISH→PREPARE tolerance (#1078)
is preserved by Phase B's 180s headroom.
The print log's User column came from printer_manager.get_current_print_user,
but only background_dispatch (Archive Print, Library Print) ever populated
that dict. The Queue manual-start path went straight from
POST /queue/{id}/start into PrintScheduler._start_print, neither end
recording the clicker — so any print started from the queue landed in
PrintLogEntry with created_by_username NULL even with auth enabled.
Two-sided fix: /start now writes user.id to item.created_by_id when no
prior owner is set (preserves UI-added items' original uploader), and
PrintScheduler gains _propagate_owner_to_printer_manager called from
_start_print to hand the owner into set_current_print_user before the
print command goes out.
Two bugs in PrintScheduler._check_previous_success:
- Lookback excluded 'cancelled' so user cancellations were walked past
- Lookback included 'skipped', so one skip cascaded indefinitely
Swap to ['completed', 'failed', 'cancelled', 'aborted'] and accept
both 'completed' and 'cancelled' as predecessor success. Real
'failed' / 'aborted' still gate.
One-shot migration in run_migrations resets only the skipped items
whose true predecessor was cancelled — surgical reversal of the exact
bug fingerprint, leaves genuine failure-gated skips alone. Portable
across SQLite and Postgres, idempotent on re-run.
asyncio holds only a weak reference to tasks returned by
``create_task``. Fire-and-forget callers that discard the return value
let the event loop GC the task before it finishes, logging
``Task was destroyed but it is pending!`` with no traceback. The #1648
support-bundle review surfaced 94 such warnings in 8 days of v0.2.4.5
-- the silently-vanished exceptions reach support bundles as opaque
GC notices instead of actionable errors.
New backend/app/core/tasks.py::spawn_background_task(coro, *, name=None)
is the one place in the codebase that calls asyncio.create_task. It
stores the task in a module-level set, attaches a done-callback that
auto-removes on completion AND surfaces any uncaught exception via the
logger with the originating traceback, and accepts name= so a leak
source is traceable through /tracebacks and the log line. Cancelled
tasks don't log (a shutting-down service is not an error).
Migrated the 16 truly-orphan create_task call sites to the helper:
main.py (8):
reconcile-stale, cooldown-poweroff, energy calc, smart-plug,
maintenance-check, photo-then-notify, layer-timelapse,
scan-timelapse, print-scheduler, notify-no-archive (the last one
was hand-rolling the same pattern with task + no-op done_callback)
printers.py:3123 apply-pa-after-refresh
print_queue.py:1034 queue cooldown-poweroff
firmware_update.py:261 firmware upload
archive.py:1514 timelapse mp4 convert
print_scheduler.py:2199 watchdog print-start
library.py:1614 STL backfill
smart_plugs.py:259 tasmota scan
discovery.py:159 subnet scan
smart_plug_manager.py x3 plug auto-off-pending
background_dispatch.py x2 (lambda-wrapped inside
loop.call_soon_threadsafe) upload progress
Sites that already kept strong refs are unchanged:
self._tasks.append(asyncio.create_task(...)) -- VP manager,
tcp_proxy, mqtt_server
self._x_task = asyncio.create_task(...) on service instances --
mqtt_bridge, obico_detection, github_backup, archive_purge,
local_backup, library_trash, discovery service
Locally assigned + awaited/gathered -- tcp_proxy bidirectional
pumps, camera_fanout, slice_dispatch, slicer_api progress_task,
manager._finish_release_task, main.py module-level cleanup loops
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
live-camera grab captured the bed already dropped, ruining the photo
framing. Source the photo from a brief Bambu timelapse instead —
firmware stops timelapse recording AFTER toolhead parks but BEFORE
bed-drop runs, so the last frame frames the finished print correctly.
When capture_finish_photo is on AND the user did not opt in to
timelapse for this print, force timelapse=True at dispatch + mark the
new PrintArchive.bambuddy_forced_timelapse column. After extraction
(success or failure), cleanup deletes the locally-attached file,
clears archive.timelapse_path, and walks the four scanner directories
(/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
against the original filename. User-opted-in timelapses pass through
unchanged.
Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
(module-level so the print queue can reuse it). Both dispatch paths
wired: background_dispatch.py (Print Now / Reprint) AND
print_scheduler.py:_start_print (the queue). Field testing caught the
scheduler gap on the first round — AST regression test now asserts
start_print(timelapse=...) references effective_timelapse, not the raw
item.timelapse, so a future refactor can't silently drop it.
Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
frames overwrite the same output file, so the file left on disk is the
literal last frame regardless of duration. Bambu records one frame per
layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
original -sseof -1.0 approach seeked before the start of the file and
returned frame 0 (empty bed). Decoding every frame is fine; Bambu
timelapses are short by construction even on hours-long prints.
Migration adds bambuddy_forced_timelapse branched on is_sqlite()
(DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
Verified live on postgres:16-alpine.
Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
so the notification carries the bed-up photo instead of falling back
to the live-cam grab on slow links.
Scope limit, documented in the camera wiki: prints started directly
on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
both dispatch paths, so the override doesn't fire there. Future
option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.
Setting description rewritten in all 11 locales to drop the "only
works when timelapse enabled" caveat (Bambuddy now forces it) and
explain the kept-or-deleted behaviour.
A library row with archive.filename "Cube (1).gcode.3mf.gcode.3mf" uploaded
to /Cube_(1).gcode.3mf.3mf (single-iteration strip + append). Post-print
cleanup looked at /Cube_(1).3mf and /Cube_(1).gcode (subtask_name + ext),
missed the on-card file, and A1 firmware re-ran it on next power-on.
- New derive_remote_filename() helper in backend/app/utils/filename.py:
iterative strip of .gcode.3mf/.3mf suffixes, append single .3mf,
space->underscore. isinstance() guard raises TypeError on non-str
input rather than entering the strip loop with a duck-typed
object that returns truthy sentinels from endswith.
- Three previously-duplicated upload sites (background_dispatch reprint +
library, print_scheduler queue) now share the helper.
- SD cleanup fetches archive.filename and tries the derived path first,
with the legacy subtask_name + ext paths kept as fallbacks.
- 10 new unit tests pin the reproducer + edge cases (doubled .gcode.3mf,
doubled .3mf, raw .gcode preserved, idempotence, Unicode, plus the
type guard against MagicMock / None / int inputs).
Reporter has a P1S with an inventory spool cloned to slot 1 and the
original (much further used) in slot 4, the preference enabled, and
the dispatch picked slot 1 every time. The sort's been blind to
Bambuddy inventory weights — it reads MQTT `tray.remain`, the
printer firmware's RFID-decremented value, which has two limitations:
- Bambu RFID only. Non-RFID spools report -1 and get clamped to a
sentinel; multiple non-RFID trays then tie in the sort and Python's
stable sort collapses to AMS-slot insertion order, so slot 1 wins.
- Even when set, it's the printer's counter, not Bambuddy's
`label_weight - weight_used` (internal) or Spoolman's
`remaining_weight`. The two diverge whenever the user re-spools,
swaps cardboard, or runs a print outside Bambuddy.
The reporter is on internal-inventory mode with non-RFID spools — both
failure modes apply, hence slot 1 every time.
Fix: when a slot is bound to an inventory spool the inventory record's
remaining weight becomes the sort signal. New async helper
`_build_inventory_remain_overrides(db, printer_id, loaded)` returns
`{global_tray_id: remaining_grams}` for bound slots — internal mode
joins SpoolAssignment → Spool once per dispatch; Spoolman mode joins
SpoolmanSlotAssignment then reuses `_spoolman_remaining_grams` from
filament_deficit.py for parity.
New `_prefer_lowest_sort_key` does a two-tier comparison: inventory-
tracked spools sort BEFORE MQTT-only spools, then ascending by
remaining within each tier, then ascending by ams_id*4+tray_id as the
deterministic slot tie-breaker. The tier flag dominates so grams
(inventory) and percent (MQTT) never get cross-compared — no unit
conversion needed.
MQTT-only behaviour is preserved exactly: remain=-1 still maps to the
101 sentinel and slot order still decides on ties. Users who haven't
bound any inventory spool see no change. The DB lookup runs only when
prefer_lowest_filament is enabled.
External / VT slots are skipped (tracked separately from AMS bindings).
The pre-print deficit warning from #720 only ran inside the PrintModal
submit flow. Both the green ▶ button on a staged queue row (POST
/queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
it — auto_dispatch=True VP intakes would dispatch unsupervised onto
spools that physically can't complete the print.
Extracted the deficit check into backend/app/services/filament_deficit.py
(single source of truth, both internal inventory and Spoolman modes).
POST /queue/{id}/start returns 409 with a structured deficit payload
unless ?skip_filament_check=true. The dispatch scheduler runs the same
check before each _start_print; a deficit promotes the item to
manual_start + sets a new filament_short flag (idempotent migration on
print_queue). The flag clears automatically on the next tick when the
operator swaps a spool to one with enough material.
Frontend ▶ catches the 409 and opens a confirm modal showing each
shorted slot's required vs remaining grams; the row now renders a
yellow "Insufficient filament" badge when filament_short is set.
Translated across all 9 locales.
Both the queue-side _watchdog_print_start and the direct-dispatch
_verify_print_response used `status.state != pre_state` to decide
whether a project_file command had been accepted. When a printer was
in FINISH at dispatch time (un-dismissed post-print prompt from a
prior job), the firmware silently rejected the new command; if the
user then dismissed the screen prompt, the printer moved FINISH ->
IDLE and the watchdog returned early as "command landed" — leaving
the queue row stuck at status='printing' indefinitely and the
scheduler permanently marking the printer as busy.
Narrow the "command landed" check in both verifiers to an allow-list
of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
the revert. The subtask_id-advance signal stays in place for H2D's
slow FINISH -> PREPARE transition (#1078).
Also wrap _watchdog_print_start's revert commit and
printer_manager._persist_awaiting_plate_clear in run_with_retry so
SQLite single-writer contention can't silently drop these writes.
The revert path returns a tristate sentinel so the post-revert MQTT
session-recovery logic only runs when we actually reverted (or the
commit failed) — not when on_print_complete had already cleared the
row, where a forced reconnect could break a healthy concurrent print.
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
filament requirements from the 3MF and writes
required_filament_types + filament_overrides + ams_mapping onto the
queue item, but the VP queue-mode write path skipped all of that.
Net effect: scheduler reached its model-only-matching fallback and
auto-dispatched onto whatever printer was free regardless of loaded
colour.
Extract the scheduler's existing _get_filament_requirements 3MF
parser into a shared helper so the VP path can reuse it. VP's
_add_to_print_queue now populates required_filament_types
unconditionally (cheap; helps the scheduler reject obvious type
mismatches) and writes filament_overrides with force_color_match:
true per consumed slot when a new per-VP queue_force_color_match
toggle is on. Default off to preserve current behaviour for
upgraders.
UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
mirroring the existing auto-dispatch toggle. i18n: en + de
translated, other 6 locales seeded with English copy.
Schema: one nullable column on virtual_printers
(queue_force_color_match BOOLEAN, default 0/FALSE).
11 new backend tests (8 for the extracted parser, 3 for the VP
write path) + 6 new frontend tests (toggle render gating, default
state, click posts queue_force_color_match in update body).
Existing scheduler tests pass against the refactored helper.
README, CHANGELOG, website features page, and wiki virtual-printer
page all updated.
P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
The /cover route's regex falls back to plate 1 — and the printer card
shows the wrong plate's thumbnail on multi-plate prints.
Resolution order in the new resolve_plate_id() helper (used by both
the status route's current_plate_id and /cover):
1. The plate Bambuddy dispatched. start_print() now records
(dispatched_plate_id, dispatched_subtask) on PrinterState; the
subtask check rejects stale records from a previous Bambuddy
dispatch bleeding into a Studio-direct print on the same project.
2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
for firmware that does include the path).
3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
covers per-plate archives sliced separately in Studio without a
Bambuddy dispatch record.
4. Default to plate 1.
Cover-byte cache key simplified to (subtask_name, view_key) now that
plate resolution is late-bound. clear_cover_cache() already fires on
every print start, so re-dispatches with a different plate always
fetch a fresh thumbnail.
Bambuddy-dispatched prints additionally register the local archive
3MF in the cover cache at dispatch time, so /cover reads straight
from the archive directory and doesn't refetch the file over FTP
from a printer whose FTP server is busy serving the active print.
Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
dispatch record on start_print, 2 integration tests for the cover
route (dispatch wins over plate-1 default; 3MF-scan fallback for
per-plate archive without dispatch record).
Multi-plate batches scheduled to the same H2D Pro were triple-dispatched
within ~60 s — observed in user logs as queue items 139/140/141 all
flipping to status='printing' even though the printer was still
digesting the first project_file (FINISH for 80-210 s before flipping
to PREPARE). The DB busy_printers seed at print_scheduler.py:145 was
empirically missing the in-flight items in this window; without
database access I cannot pin the exact why, but the guard is unreliable.
Add a defensive in-memory dispatch hold:
- _start_print captures (dispatched_at, pre_state, pre_subtask_id) per
printer
- check_queue augments busy_printers with any printer still inside its
hold window (60 s minimum cooldown, 180 s hard timeout)
- _watchdog_print_start releases the hold once it observes a state or
subtask_id transition (success path), or on the existing 90 s revert
(unhappy path), or on disconnect
Pure additive — alongside the existing seed query and _is_printer_idle.
Doesn't depend on DB row visibility or on_print_complete firing
correctly. Per-printer isolated. Watchdog kept as @staticmethod so the
existing 12 watchdog tests pass unchanged; hold-release calls go
through the module-level scheduler instance.
Background: P1P firmware can take ~135 s after a project_file MQTT publish
to actually start parsing the uploaded .3mf — gcode_state stays IDLE and
subtask_id doesn't advance until parse completes. The dispatch watchdogs
treated the missed transition as a #887/#936 half-broken session and called
force_reconnect_stale_session, which interrupts the printer's in-progress
parse and triggers 0500_4003 ("can't parse print file") on the printer side.
Both #1150 (slow parse) and #887/#936 (zombie session) look identical from
state and subtask_id alone — both have stale state and stale subtask_id with
fresh telemetry. The distinguishing signal is the printer's gcode_file
field: it updates in push_status when the project_file command actually
lands on the printer, but stays unchanged when the publish was silently
swallowed.
Both watchdogs (_verify_print_response in background_dispatch and
_watchdog_print_start in print_scheduler) now capture pre_gcode_file from
printer_manager.get_status() before sending the publish, then on timeout
compare it against the last good status seen during the poll loop. If the
file changed, the command landed → log a #1150 warning, skip the forced
reconnect to avoid 0500_4003 mid-parse. If unchanged, fall through to the
original force_reconnect_stale_session call so the half-broken-session
recovery is preserved exactly.
Caveat documented in code: in a retry-same-file slow-parse scenario the
gcode_file looks identical pre/post-publish, so the watchdog falls through
to the reconnect path and the user still hits 0500_4003 on that retry.
Accepted to avoid breaking the half-broken-session recovery, which is the
more impactful regression of the two.
The new pre_gcode_file kwarg has a default of None on both watchdog
functions, so any caller that doesn't pass it keeps the original
reconnect-on-timeout behavior verbatim.
4 new unit tests cover both watchdogs: skip on gcode_file change (#1150
fix), reconnect when unchanged (#936 protection preserved), skip when
pre=None and current is non-None (printer just connected), reconnect when
pre_gcode_file arg is omitted (backward-compat). All 439 existing
dispatch / scheduler / mqtt tests pass unchanged.
Library files now move to a configurable-retention trash bin on delete
instead of being hard-deleted from disk (default 30 days). Admins get a
"Purge old" bulk action on the File Manager with a live preview, plus an
optional auto-purge setting in Settings → File Manager that runs the same
operation once per 24h when enabled (default off). Regular users see and
manage their own trashed files; admins see everyone's. External (linked)
files bypass trash since their bytes aren't under Bambuddy's control.
- New `library:purge` permission (admin-only by default)
- Nullable indexed `deleted_at` column on library_files; dialect-aware
ALTER TABLE so the column actually gets added on PostgreSQL (raw
DATETIME is SQLite-only syntax)
- New `LibraryFile.active()` classmethod; every query site routed through
it so trashed rows don't leak into listings, print dispatch, MakerWorld
dedupe, or stats
- Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
layout so datetime columns don't clip
- Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
the 15-minute sweeper cadence still runs the purge at most once per day
- Save toast wired into every trash/auto-purge setting change
- 17 new backend integration tests (service + routes + auto-purge throttle),
8 new frontend tests, localised across all 8 UI languages
- Wiki + website feature entries updated
Two related queue issues surfaced when scheduling an ASAP print with
quantity > 1 on an H2D:
1. Double-dispatch — both items in the batch ended up in 'printing'
status on the same printer, logged as "BUG: Multiple queue items in
'printing' status for printer N". The scheduler seeded its busy
set empty each tick and relied on _is_printer_idle() reading live
MQTT state, but H2D / P1 series lag several seconds between the
print command and IDLE → RUNNING, so the next check_queue() tick
saw IDLE and dispatched the second batch item onto the already-
running printer. check_queue() now seeds busy_printers with every
printer_id that has a row in 'printing' status before iterating,
so any printer with an outstanding dispatched job is excluded
regardless of what MQTT currently reports.
2. Progress bar flashed 100% — immediately after dispatch the queue
item's per-row progress bar showed the prior print's final mc_percent
for a few seconds, then snapped back to 0% when the new print
started ticking. QueuePage.tsx now gates progress / remaining_time /
layer fields on status.state being RUNNING or PAUSE; in any other
state (FINISH from the prior print, IDLE, PREPARE while heating)
the bar renders at 0% with no stale ETA or layer count.
Regression coverage added in test_phantom_print_hardening.py
(TestBusyPrinterSeedingFromPrintingItems, 3 tests): seeding query
returns only printers with 'printing' rows, empty when none exist,
and end-to-end check_queue() does not call _start_print for a pending
item whose printer already has a 'printing' row even when
_is_printer_idle() is forced True.
_watchdog_print_start reverted queue items to "pending" at 45 s if
gcode_state hadn't changed, assuming the MQTT project_file was swallowed
by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00)
routinely keeps state=FINISH for 48-55 s after actually accepting the
command before transitioning to PREPARE. The watchdog reverted items
the printer had already started physically printing; the archive updated
normally via _active_prints, but the queue item was now "pending" again,
and the next scheduler tick after plate-clear re-dispatched the same
item as if it had never run. With one item left in the queue that looked
like a reprint of the just-finished job; with multiple items the
symptom was masked by item N+1 getting dispatched during the race.
Add a second "command landed" signal: subtask_id advancing past the
pre-dispatch value. Bambuddy already mints a unique submission_id per
project_file publish (#1042) and the printer echoes it back on the next
push_status as soon as it starts processing the command - well before
gcode_state transitions on slow-transition models. _start_print now
captures pre_subtask_id alongside pre_state and passes both to the
watchdog, which exits early on either a state change or a subtask_id
advance.
Raise default timeout 45 s → 90 s as belt-and-braces for printers that
neither flip state nor echo subtask_id inside the polling window.
Genuinely half-broken sessions (both signals unchanged across the full
90 s) still revert + force-reconnect exactly as before.
Transient subtask_id=None during reconnect is not mis-detected as a
change. pre_subtask_id=None falls back to state-only checking so the
fix is safe for printers that haven't reported a subtask_id yet.
New test_scheduler_watchdog.py pins the eight behaviours that matter:
pickup via state change; pickup via subtask_id change with state still
FINISH (the exact #1078 case); revert when neither signal changes;
default timeout is 90 s; pre_subtask_id=None state-only fallback;
current subtask_id=None not treated as change; printer disconnect
mid-watchdog leaves DB untouched; item that already moved on is not
clobbered.
If the printer drops or ignores the MQTT project_file command (same
half-broken-session shape as #887/#936), the queue item was permanently
stuck in "printing" at 100% because the scheduler optimistically flipped
the DB row right after the publish succeeded locally. A new watchdog
polls the printer state for up to 45s after dispatch; if there's no
transition, it reverts the item to "pending" and force-reconnects the
MQTT session so the scheduler can retry.
With Auto Off enabled and another job queued, the smart plug cut power when a
print finished and immediately re-powered the printer because the scheduler
saw pending items. The printer booted fresh into IDLE and the next job
auto-dispatched, bypassing the "Clear Plate & Start Next" confirmation.
Root cause: the plate-clear gate lived only in PrinterManager._plate_cleared
(in-memory set) and _is_printer_idle treated IDLE as unconditionally idle. On
power cycle the in-memory flag was lost and the IDLE-on-boot state skipped
the gate entirely.
Fix:
- Replace the in-memory flag with an awaiting_plate_clear column on the
printers table, rehydrated into the PrinterManager at startup.
- Set the flag in on_print_complete for completed/failed prints (not user
cancellations); clear it on ack and on scheduler dispatch.
- _is_printer_idle now short-circuits to not-idle whenever require_plate_clear
is on and the flag is set, regardless of the currently reported state —
so the gate holds through power cycles, Bambuddy restarts, and the printer
booting back into IDLE.
- /printers/{id}/clear-plate no longer requires the printer to report
FINISH/FAILED; it accepts the ack whenever the flag is raised.
- Frontend widgets (PrinterQueueWidget, Layout, BulkPrinterToolbar) gate on
the flag rather than reported state.
Tests: added regression tests for IDLE+awaiting=True (the #961 case) and
full DB round-trip tests for the persistence layer.
* feat: print files directly from project view (closes#930)
Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
When multiple smart plugs were assigned to the same printer, only the
first plug's automation triggered. All automation paths (print start
auto-on, print complete auto-off, queue auto-off, scheduler power-on)
now iterate every plug linked to the printer. Also fix queue auto-off
hardcoded to Tasmota instead of using the correct service for the plug
type.
Per-model start/end G-code snippets configurable in Settings (Workflow
tab). Queue items get "Inject G-code" toggle — scheduler injects
snippets into a temp 3MF copy before FTP upload. Supports Farmloop,
SwapMod, AutoClear, Printflow 3D and similar bed-clearing systems.
Original files are never modified.
New SJF toggle badge on the queue page. When enabled, the scheduler
picks shorter print jobs before longer ones instead of FIFO. A
starvation guard flags jobs that get skipped once, moving them to
the front on the next cycle so long jobs can't be postponed indefinitely.
- Add print_time_seconds and been_jumped columns to PrintQueueItem
- Cache print duration from 3MF metadata at queue item creation
- SJF query: printer_id, target_model, been_jumped DESC, print_time_seconds ASC, position
- Mark jumped items in-memory after each print start
- Toggle badge on queue page header with live state indicator
- Frontend auto-sorts to match scheduler order when SJF enabled
- Settings schema, boolean parsing, and migration (SQLite + PostgreSQL)
- i18n badge keys for all 7 locales
- 10 integration tests for SJF ordering and starvation logic
- Wiki, website, README, and changelog updated
When multiple AMS spools match the same type/color criteria, an optional
setting now prefers the spool with the lowest remaining filament. This
helps consume partial spools before starting new ones. Sorting applies
to all matching paths: queue scheduler, print modal, and multi-printer
mapping. Unknown remain values (-1) sort to end.
Stagger option now available when printing directly to multiple printers,
not just in queue mode. Prints are automatically queued with staggered
start times using group size/interval from Settings. New "Require
plate-clear confirmation" setting lets farm users disable per-printer
plate confirmations so queued prints start automatically on finished
printers.
Also fixes settings API type parsing for require_plate_clear (boolean),
stagger_group_size and stagger_interval_minutes (integer) — without this,
saved values returned as strings would cause the settings toggle to
always show enabled and trigger a permanent save loop.
Filenames with spaces (e.g. "Junktion Box PRO 90.3mf") broke the MQTT
print command because the url field (ftp://file name.3mf) contained
unencoded spaces the firmware can't parse. FTP upload succeeded but the
printer silently ignored the command and stayed IDLE. Replace spaces
with underscores in the remote filename before upload and print command.
When all matching printers were busy and a job was queued with ASAP
timing, the scheduler immediately fired a "Job Waiting for Filament"
notification even though the job was just waiting for a printer to
finish — no user action required.
Added _is_busy_only() check to skip the waiting notification when the
only reason is "Busy". Notifications still fire for actionable reasons
(missing filament, offline, wrong color). Also renamed the default
notification template title to "Queue Job Waiting" and updated
descriptions across all 7 locales.
Ambient drying: automatically dry filament on idle printers when
humidity exceeds threshold, regardless of queue state. Separate toggle
from queue auto-drying — both can run simultaneously. Uses the same
presets, humidity threshold, and power constraint detection.
Fix: block mode (wait for drying) previously skipped the humidity
auto-stop check for already-drying printers, causing drying to
continue indefinitely. Now only prevents starting new drying.
Queue auto-drying: scheduler automatically starts drying on idle printers
with scheduled queue prints when AMS humidity exceeds the configured
threshold. Uses conservative parameters (lowest temp, longest duration)
for mixed filaments. Drying stops when humidity drops below threshold
(30-minute minimum prevents oscillation), when scheduled items are
removed, or when the feature is disabled. Optional "block queue" mode
delays the next print until drying completes.
Configurable presets: temperature and duration per filament type,
editable in Settings → Print Queue, used by both manual drying popover
and queue auto-drying. Separate presets for AMS 2 Pro (n3f) and AMS-HT
(n3s) reflecting different heating capabilities.
PSU detection: drying button disabled with tooltip when dry_sf_reason
indicates insufficient power. Parses drying status bits and dry_sf_reason
from AMS info hex string via MQTT.
Backend: print_scheduler.py (+316 lines), bambu_mqtt.py, printer_manager,
schemas, settings route. Frontend: PrintersPage drying presets prop,
SettingsPage drying config UI, i18n (7 locales). Tests: 27 new tests in
test_scheduler_auto_drying.py covering conservative params, presets,
state sync, stop logic, minimum drying time, and auto-stop regressions.
Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable,
but the print scheduler and filament override UI used exact string matching.
A 3MF requiring PA-CF wouldn't match PA12-CF in the AMS, leaving the
override dropdown disabled and preventing scheduler assignment.
Add a filament type equivalence system (both backend and frontend) so
these PA variants are treated as compatible in scheduler assignment,
AMS slot matching, force color match validation, and the override dropdown.
Also fix the "Force color match" checkbox click target being too wide
(entire row instead of just the checkbox/label) by changing flex to
inline-flex on the label element.
Start, monitor, and stop drying sessions for AMS 2 Pro (n3f) and
AMS-HT (n3s) directly from the Printers page. Flame icon in AMS card
header opens a popover with filament-based temperature/duration presets
from BambuStudio. Live countdown status bar shows time remaining.
Backend: cache module_type from MQTT get_version, expose dry_time and
module_type in both WebSocket and REST paths, add supports_drying()
with firmware gating, add server-side guard on start_drying endpoint.
Frontend: drying button, popover with filament select + temp/duration
sliders, status bar, start/stop mutations. i18n for all 7 locales.
Supported: X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+),
H2D Pro, X1E. Not supported: P2S, A1, A1 Mini, H2S, H2C.
* Added "Force Color Match" feature to filament override
* Update settings.py
* Update settings.py
* Label updates for warnings and checkbox case
* Initial plan
* Add noMatchingMaterial i18n translations to all 7 locale files
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
* Use {{material}} interpolation in noMatchingMaterial; populate from filament_overrides with getColorName
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
* Updates for PR#625 comments
* Added "Force Color Match" feature to filament override
* Update settings.py
* Update settings.py
* Label updates for warnings and checkbox case
* Initial plan
* Add noMatchingMaterial i18n translations to all 7 locale files
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
* Use {{material}} interpolation in noMatchingMaterial; populate from filament_overrides with getColorName
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
* Updates to PrintersPage and QueuePage
* Update QueuePage.tsx
* Rebase to 0.2.2b3
* Fix compile errors with FilamentOverride
* Updates for PR#625 comments
https://github.com/maziggy/bambuddy/pull/625#pullrequestreview-3908738261
* Fix force color match default, loadedFilamentTypes bug, remove dead translations
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
When the print queue scheduler finds pending items but doesn't start
any, there was no logging to explain why. Added INFO-level summary
after each queue check showing skip reasons (manual_start,
scheduled_future) and busy printer details (state, connected,
plate_cleared). Also added DEBUG-level logging in _is_printer_idle()
for the specific condition that failed.
The print scheduler had two code paths for queue items: model-based
(target_model set) and printer-specific (printer_id set). Only the
model-based path computed ams_mapping before starting the print. VP
queue items assigned to a specific printer went through the
printer-specific path with null ams_mapping, causing the printer to
default to the first AMS slot regardless of what the 3MF required.
Add _compute_ams_mapping_for_printer() call to the printer-specific
path, matching the existing model-based behavior.
When scheduling a print to "any printer" with a filament color override,
the scheduler picked a printer with the correct filament type but wrong
color. _find_idle_printer_for_model() used color matching only for
ranking candidates, not filtering — a printer with 0 color matches was
still selected if it had the right types.
Now requires at least 1 color match when filament overrides specify
colors. Printers with 0 matches are skipped and reported in the
"waiting for filament" reason instead of being treated as candidates.
All backend timestamps used datetime.now() (server local time) or the
deprecated datetime.utcnow(). The frontend's parseUTCDate() assumes
timestamps without timezone indicators are UTC and appends 'Z', so
stored timestamps were off by the timezone offset when the container's
timezone wasn't UTC.
Backend: replaced datetime.now() and datetime.utcnow() with
datetime.now(timezone.utc) across 16 files (~80 call sites) for all
database fields and DB comparisons. Cosmetic timestamps (filenames,
user-facing local time formatting) intentionally left as local time.
Frontend: replaced 13 new Date(backendTimestamp) calls with
parseUTCDate() across 8 files to correctly interpret UTC timestamps.
When scheduling a print to "any printer" (model-based assignment),
users can now override the 3MF's original filament choices per slot.
The override dropdown shows compatible filaments loaded across all
printers of the selected model, filtered by type and nozzle (H2D).
The scheduler matches against overridden type/color instead of the
original 3MF values, preferring printers with exact color matches.
Backend:
- New GET /printers/available-filaments endpoint (aggregates loaded
filaments across printers of a model, includes extruder_id for
dual-nozzle nozzle-aware filtering)
- New filament_overrides JSON column on print_queue table
- Scheduler applies overrides before AMS mapping, clears tray_info_idx
on overridden slots so color matching takes priority
- Printer selection prefers printers with most override color matches
Frontend:
- New FilamentOverride component with per-slot override dropdowns
- Type-filtered options (PLA slots only show PLA)
- Nozzle-aware filtering (H2D: only shows filaments on correct extruder)
- Integrated into PrintModal for model-based queue mode
Tests:
- 11 backend unit tests (color match counting, override application)
- 12 frontend tests (rendering, type/nozzle filtering, interactions)
i18n: All 6 locales (en, de, fr, it, ja, pt-BR)
Three bugs allowed uploaded gcode files to survive on the printer's SD
card, causing firmware restarts to auto-start old prints hours later.
1. The post-print cleanup retry loop always broke after attempt 1
regardless of delete success — delete_file_async returns False on
failure instead of raising, so the except-based retry never fired.
2. When start_print() failed after uploading, the file was never cleaned
up since on_print_complete never fires for prints that never started.
3. Cleanup failures were logged at DEBUG level, invisible in production.