require_ownership_permission gates API keys on `all_perm` only — the
comment at auth.py:1659 says OWN and ALL "both map to the same scope
flag" for queue / archives / etc., so checking `all_perm` is the
correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
POST /library/files/move hit "administrative operations" 403, even
for keys with can_manage_library=True. Only slice worked, because it
doesn't go through require_ownership_permission.
The "ALL stays admin-only because it crosses the user boundary"
intent was internally inconsistent. API keys have no per-row
ownership identity (user=None), so the route's
`file.created_by_id != user.id` ownership check would AttributeError
on a key acting under OWN anyway — the only working path is
can_modify_all=True, which `all_perm` denial blocked outright.
Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
_APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
map to can_queue for the same per-key-identity reason). Both removed
from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
bypasses the soft-delete window and is genuinely destructive.
On dual-nozzle printers (H2C/H2D), the External card stacked a
separate "Ext-L" / "Ext-R" caption below each tray to mark which
extruder it fed. That caption appeared on the External card only,
making the bottom row of the printer card's AMS panel visibly
taller than the row above it.
Fix: the L/R distinction now lives inside the slot's colour circle
in place of the numeric index, and the bottom caption is removed.
FilamentSlotCircle's slotNumber prop is widened to `number | string`
to carry the letter. Single-nozzle externals (one tray, no L/R
distinction) keep the numeric "1".
The Ext-L / Ext-R strings still drive the slot's "location" label
in the filament hover card, so detail context is preserved.
Adds a global local_login_enabled setting plus a per-provider
is_autologin flag on OIDCProvider so operators who run their own SSO
enabled, or if the calling admin has no UserOIDCLink — either would
lock everyone out. App-layer invariant: at most one provider can carry
is_autologin; setting it on one clears it on every other.
/auth/advanced-auth/status surfaces both new fields so the LoginPage
decides UI in one query. The env-var bypass flips the reported
local_login_enabled back to true so the SPA matches what the route
will accept.
fix(db): order filament_shopping_list color_name ALTER after CREATE
PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
the ALTER hit "no such table" — not in _safe_execute's swallow list —
and aborted run_migrations, breaking every migration test that starts
from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
Postgres branches; the CREATE already declares color_name, so this is
purely the upgrade path and "duplicate column name" on re-runs is
swallowed.
Reprints triggered a bogus "Print Stopped" push notification while the print
kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE
on MQTT reconnect.
bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the
on_print_start expected-archive promotion only wrote subtask_id when the
stored value was empty (`not archive.subtask_id`) — so the archive kept the
FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints
(#1542) compared the stale stored id against the printer's live id, found
a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires
the "Print Stopped" notification.
Captured cleanly in the reporter's support bundle:
[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31
— subtask_id changed ('1844213296' → '2103771517')
immediately followed by gcode_state: RUNNING on the same wire.
Fix: update archive.subtask_id whenever the new effective id differs from
the stored one, not only when the stored one is empty. Inequality check
preserves the noop-on-stable-push behaviour the original guard provided.
Two places in main.py (expected-print and duplicate-printing-archive
branches). 3 new unit tests cover the reprint, first-run, and stable-push
paths. Reconciler itself unchanged — it was doing the right thing given
the data it had.
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.
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.
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.
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.
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.
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
cross-DB restore path) can have created_at = NULL because the column was
originally created without a DEFAULT clause — server_default=func.now()
only fires at table creation, not for existing rows or raw cross-DB
inserts. The list_archives response model required a datetime, so a
single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.
- Boot-time backfill: COALESCE(completed_at, started_at, now()) for
any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
vs Postgres NOW()).
- Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
and ArchiveSlim so a future NULL-leaking path doesn't break the list
endpoint again.
require_ownership_permission gates API keys on `all_perm` only — the
comment at auth.py:1659 says OWN and ALL "both map to the same scope
flag" for queue / archives / etc., so checking `all_perm` is the
correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
POST /library/files/move hit "administrative operations" 403, even
for keys with can_manage_library=True. Only slice worked, because it
doesn't go through require_ownership_permission.
The "ALL stays admin-only because it crosses the user boundary"
intent was internally inconsistent. API keys have no per-row
ownership identity (user=None), so the route's
`file.created_by_id != user.id` ownership check would AttributeError
on a key acting under OWN anyway — the only working path is
can_modify_all=True, which `all_perm` denial blocked outright.
Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
_APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
map to can_queue for the same per-key-identity reason). Both removed
from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
bypasses the soft-delete window and is genuinely destructive.
Adds a global local_login_enabled setting plus a per-provider
is_autologin flag on OIDCProvider so operators who run their own SSO
enabled, or if the calling admin has no UserOIDCLink — either would
lock everyone out. App-layer invariant: at most one provider can carry
is_autologin; setting it on one clears it on every other.
/auth/advanced-auth/status surfaces both new fields so the LoginPage
decides UI in one query. The env-var bypass flips the reported
local_login_enabled back to true so the SPA matches what the route
will accept.
fix(db): order filament_shopping_list color_name ALTER after CREATE
PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
the ALTER hit "no such table" — not in _safe_execute's swallow list —
and aborted run_migrations, breaking every migration test that starts
from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
Postgres branches; the CREATE already declares color_name, so this is
purely the upgrade path and "duplicate column name" on re-runs is
swallowed.
Brings the Spoolman writer up to parity with the internal-inventory
side, which has had this fallback since #1119. When a Bambu print
starts without a retrievable .gcode.3mf on the printer (typically an
unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
longer silently skips the print's filament consumption.
- ActivePrintSpoolman.filament_usage now nullable; new tray_remain_start
column captures per-slot {remain, tray_uuid} at print start.
- store_print_data: always snapshots remain, even when 3MF is present
(mirrors usage_tracker.on_print_start), so partial-3MF prints can also
fall back per-slot.
- report_usage: 3MF path stays primary; new _report_remain_delta_for_slots
handles slots the 3MF didn't cover via delta * Filament.weight / 100,
resolving the spool via the existing slot-assignment table.
- _report_partial_usage: same fallback for aborted no-3MF prints.
#1119 invariant preserved: per-slot, per-print, gated on a valid
start/current remain AND a resolvable Spoolman spool. Uses curated
Filament.weight (not MQTT's unreliable tray_weight) — same trick the
internal-inventory side uses.
Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
Double-charge prevented via handled_global_tray_ids dedup.
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.
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.
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.
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.
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.
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 24h session cap from the M-2 audit finding was hard-coded, so the
"Remember Me" checkbox could only control storage location, never
duration. Add session_max_hours setting (default 24, max 720) honoured
at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
backup, OIDC.
- backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
that clamps to [1h, 720h] and falls back to 24h on missing/blank/
unparseable. DB errors propagate — the login transaction must abort
on a broken DB rather than silently extend or shrink the lifetime.
- backend/app/api/routes/auth.py, mfa.py: all four sites read the
resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
- backend/app/schemas/settings.py, routes/settings.py: schema field
with ge=1 le=720 + int coercion in _build_settings_response.
- frontend/src/pages/SettingsPage.tsx: half-width card at top of
Settings -> Users left column with 24h/7d/30d presets, custom input,
and a yellow warning when value > 24h.
- frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
- backend/tests/integration/test_session_policy.py: 15 tests across
resolver clamping, login JWT exp end-to-end, settings API round-trip.
Already-issued tokens keep their original expiry; the new setting only
affects future logins.
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
cross-DB restore path) can have created_at = NULL because the column was
originally created without a DEFAULT clause — server_default=func.now()
only fires at table creation, not for existing rows or raw cross-DB
inserts. The list_archives response model required a datetime, so a
single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.
- Boot-time backfill: COALESCE(completed_at, started_at, now()) for
any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
vs Postgres NOW()).
- Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
and ArchiveSlim so a future NULL-leaking path doesn't break the list
endpoint again.
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.
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.
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
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
bytes through to the real printer. The slicer holds one code in its
profile (the one it bound the VP with), and that code has to satisfy
both the VP listener and the real printer at the far end of the
bridge. If the codes diverge the bridge silently fails at the second
hop — slicer reaches .49:8883, FINs before sending a ClientHello,
retries identically. The wiki framed the code-match requirement as a
camera-only concern; it isn't, all bridged protocols inherit.
Fix removes the foot-gun instead of re-documenting it. When a target
is selected on a non-proxy VP the access-code field switches to a
read-only display showing the target's code with an Eye-toggle
reveal; the backend auto-inherits on every create / update (any
explicit access_code submitted alongside a target is silently
overridden as belt-and-braces for non-UI clients). The required-when-
enabling check now treats target-set as satisfying the access-code
requirement. Standalone (no-target) non-proxy VPs still get the
editable input + Save button.
One-shot startup migration corrects any pre-existing mismatched
rows: SELECTs diverged VPs and logs one INFO line per row for the
audit trail, then UPDATEs via correlated subquery. Idempotent and
portable between SQLite and Postgres.
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.
files + unify file_type classification across ingest paths
#1600: external-folder sliced outputs landed
with no thumbnail. Cause: four backend ingest paths classified
LibraryFile.file_type differently for the same .gcode.3mf family.
upload / ZIP-extract / in-process used os.path.splitext()[1] which
returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching
the thumbnail-extraction gate at library.py:1467 (file_type == "3mf").
External-folder scan explicitly detected the compound and stored
file_type="gcode.3mf" — preserving "sliced output" identity — but
then skipped both the "3mf" gate and the "gcode" gate, so the file
landed with thumbnail_path = None. Same compound-extension drift that
bit #1543 in the 3D preview, in a surface that audit didn't trace
back to.
Unified fix:
- New classify_file_type(filename) helper in routes/library.py is the
single source of truth. Returns "gcode.3mf" for sliced outputs and
ext[1:] otherwise.
- Applied to every ingest path: upload (line 1704), ZIP-extract
(1998), external-folder scan (the bug site — the manual compound
check is replaced), and in-process save_3mf_from_bytes (471, used
by MakerWorld import).
- External-scan thumbnail gate widened to
`if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip
with Metadata/plate_1.png; ThreeMFParser doesn't care about the
trailing extension.
- gcode-download endpoint at GET /library/files/{id}/gcode had the
same drift in reverse: gate was `elif file.file_type == "3mf":` so
a row stored with file_type="gcode.3mf" (the external-scan path's
pre-unification behaviour, and the canonical going forward) got
rejected with HTTP 400. Widened to the same compound-aware tuple.
One-shot DB migration in core/database.py::run_migrations backfills
existing legacy rows:
UPDATE library_files
SET file_type = 'gcode.3mf'
WHERE file_type = '3mf'
AND LOWER(filename) LIKE '%.gcode.3mf'
Idempotent (post-update rows no longer match the file_type='3mf'
predicate, so re-runs at every boot are no-ops) and dialect-neutral
(LOWER + LIKE are identical under SQLite and Postgres). Without the
backfill, users would have a permanent split state: old uploads at
'3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced
outputs in the dashboard stats query at line 4615 and show two
entries in the file-manager filter dropdown for the same conceptual
type.
Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx
already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After
the migration the DB only contains canonical values, so the legacy
'3mf' branches in the frontend become dead code for sliced files —
they stay as defence-in-depth in case any future ingest path I
missed reverts to the legacy classifier.
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
that rules out subnet / mDNS-reflector theories): with the physical printer
off the slicer's "Send" landed in Bambuddy's archive; once the printer
powered on every subsequent "Send" went straight to the printer's SD card
and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
once the printer was online.
Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
_vp_ip_uint32_le ONLY on client-identity change and early-returned on
every refresh tick when the same client object was still bound. If
target_client.ip_address was empty at first bind (DB row stale, or client
constructed before SSDP refresh filled it in), the encoding stayed None,
the net.info[*].ip rewrite block was skipped, the cache filled with the
real printer IP, sticky-key preservation kept the poisoned net value
alive across every subsequent incremental push, and the slicer followed
the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
workaround both reporters independently arrived at. Same shape on
multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
whose ip equalled _target_ip_uint32_le, so a secondary interface IP
Bambuddy never saw would leak through unchanged.
Bridge fix:
- _resolve_client calls a new _refresh_ip_encoding() on every refresh
tick, even when client identity is unchanged; self-heals once
ip_address becomes valid.
- _refresh_ip_encoding() sweeps the existing _latest_print_state when
encoding becomes valid for the first time. Without the sweep,
sticky-key preservation keeps the pre-arm poisoned cache alive
forever — incremental pushes that don't include net carry the bad
value forward.
- _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
that doesn't already equal the VP IP, not just entries matching
_target_ip_uint32_le. Multi-NIC printers stop leaking secondary
interfaces. Zero-IP placeholders are left alone so "active interface"
detection still works.
- INFO logging on encoding arm/update and on cache sweep so future
bundles directly answer "did the rewrite fire?".
Mode wire-value rename (#1429 follow-up, separate confusion source):
- Both reporters' support bundles showed mode: immediate while the UI
said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
immediate in the support-info.json file. In the webui the printer is
set to archive". UI button "Archive" had always saved immediate, and
"Queue" had always saved print_queue. Canonical wire values are now
archive / review / queue / proxy, matching the button labels 1:1.
- New normalize_vp_mode() + VP_MODE_* constants in
models/virtual_printer.py; manager.py normalises on construction so
a legacy row read pre-migration still dispatches correctly.
- core/database.py::run_migrations rewrites existing virtual_printers
and settings rows; idempotent (re-runs are no-ops); identical SQL
under SQLite and Postgres.
- API routes accept both legacy and canonical on input, normalise
before storage. GET /settings/virtual-printer normalises on read so
the frontend's mode-button highlight works for stale legacy values.
- Three frontend VP components (VirtualPrinterSettings,
VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
and type aliases to canonical; each got its own normalizeMode()
helper so a stale-cached settings payload still highlights the right
button. Two pre-existing `printer.mode === 'queue' ? 'review'`
legacy mappings in VirtualPrinterCard were the source of a test
failure caught mid-implementation where the new canonical 'queue'
was being mis-aliased back to 'review' and hiding the auto-dispatch
+ force-color-match toggles.
mode handler is NOT the dispatch bug: manager.py::_archive_file (the
handler for archive mode) doesn't dispatch to the physical printer.
The "files end up on the printer's SD card" symptom was the IP-leak
from the bridge cache. The mode rename is purely clarity / support-
bundle accuracy.
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
is_auth_enabled() and auth_middleware both caught every exception during
the auth-state probe and returned the "allow" answer instead of denying
the request. Reporter's PoC floods /api/v1/auth/login to exhaust file
descriptors, forcing the next SQLite connect to raise, then hits a
protected endpoint during the fail-open window with no token — granting
unauthenticated access to admin-account creation, API-key creation, DB
backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6.
Fix:
- is_auth_enabled (backend/app/core/auth.py): only returns False for the
legitimate "settings row absent" case; any actual exception propagates
so the caller can deny the request.
- auth_middleware (backend/app/main.py): returns 503 on any probe failure
instead of await call_next(request).
4 new regression tests in test_auth_fail_closed.py pin the contract
(propagates DB exceptions, returns False for no-row, True for "true",
False for "false"). 1 existing security test renamed and updated to
accept either 500 or 503 (both fail-closed) and to verify the
SQLAlchemy detail does not leak in the body.
Codebase grep confirmed no other auth-decision predicate has the same
fail-open shape: _validate_api_key returns None on catch (→ 401 fail-
closed downstream), is_advanced_auth_enabled propagates correctly,
permissions.py has no catch-alls.
Reported by @wondercrash via private advisory.