Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
per-event aggregation rewrite from #1378) and saw Quick Stats split
between consistent values (Total Prints, Print Time, Filament Used,
Energy, Success Rate matched the archive list) and zero-or-empty ones
(Filament Cost, Time Accuracy).
Root cause: #1378's migration added six columns to print_log_entries
- archive_id, cost, energy_kwh, energy_cost, failure_reason,
created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
on legacy data); the time-accuracy query JOINs PrintArchive ON
archive_id (drops every legacy run from the average). Counts and the
pre-existing per-row fields (status, duration_seconds,
filament_used_grams) kept working - which is why some panels looked
right and others didn't.
Two-step backfill added inside run_migrations next to the existing
column-add block, as DML inside begin_nested() (not _safe_execute,
which is documented DDL-only):
Step 1: link each orphan log entry to its archive via
print_name + printer_id (highest archive id wins on
tiebreak - newest matches the overwrite-then-stop shape
pre-#1378 reprints left behind).
Step 2: copy archive.cost / energy_kwh / energy_cost onto the
latest matching log entry per archive, BUT only for
archives where no log entry yet carries a cost. That
second clause is the idempotency anchor and the
double-count guard for users running this after #1378
has already written cost-bearing rows for new runs -
those archives are left untouched.
Earlier reprints stay NULL, matching the "first/latest writes, rest
stay NULL" convention #1378 introduced for new prints. Sum across the
legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
Stats Filament Cost matches the pre-upgrade total instead of dropping
to zero.
SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
end-to-end on SQLite (4 new unit tests in
test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
cost, idempotent, skip-archives-with-any-costed-run) and against a
live postgres:16-alpine + asyncpg container (first-pass and second-
pass produce identical state).
The other widgets the reporter listed (Printer Stats, Filament
Trends, By Material, Success by Material, Color Distribution) iterate
the archives list on the frontend rather than calling /stats - they
read consistent pre-upgrade data and aren't part of this fix; the
inconsistency between them and Quick Stats resolves once the backfill
brings Quick Stats in line.
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit
Save, and saw the value snap back to the subtype on the next read.
The earlier #1319 fix correctly handled the read/form-prefill half
(the color_name_is_synthesized flag, blank-on-synth form init), but
the write half assumed Spoolman has a `color_name` field on Filament.
It doesn't. Verified against the live FilamentUpdateParameters schema
on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material,
price, density, diameter, weight, spool_weight, article_number,
comment, settings_extruder_temp, settings_bed_temp, color_hex,
multi_color_hexes, multi_color_direction, external_id, extra. No
color_name. Spoolman's PATCH happily returns 200 for
{"color_name": "Red"} and silently discards the unknown key, so
find_or_create_filament was either patching a void or creating
filament after filament with the same field-that-doesn't-stick (which
is what produced the "BB also created a bunch of new filaments"
duplicate trail on each save attempt).
The fix follows the same pattern as the existing BambuStudio slicer-
preset storage: persist color_name on spool.extra.bambu_color_name as
a JSON-encoded string, register the extra field via
ensure_extra_field before write (Spoolman 400s on unknown extra keys),
and read it back in _map_spoolman_spool with priority
extra > filament.color_name (forward-compat for any future Spoolman
release that adds the field) > subtype synth.
Dropped the now-dead color_name passing through
find_or_create_filament and create_filament — Spoolman would discard
it anyway and keeping the dead pipe risked the same confusion the
next time someone reads this code. The previous "match by name then
patch color_name" loop is gone; what survives is the name-match
resilience that lets an AMS-sync-created filament named "Glow" still
match the user-driven edit's composed "PLA Glow", which prevents
re-introducing the duplicate-filament trail.
The frontend form's color_name_is_synthesized handling is unchanged
— that part already worked.
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
a search prefix matching a multi-entity device (one switch.* plus
several sensor.*/binary_sensor.* siblings under the same friendly
name), clicked one of the non-switch siblings, and got a 422 on Save:
String should match pattern
'^(switch|light|input_boolean|script)\.[a-z0-9_]+$'
The screenshot confirms the bug shape — the X button next to the
"empty-looking" Select Entity field only renders when haEntityId is
truthy. So haEntityId was set, but selectedEntity (haEntities.find by
that id) returned undefined, so the input rendered the placeholder
text instead of the friendly-name display. That can only happen when
the user had earlier picked an entity whose domain is NOT in the
schema's allowed list, then the search cleared, the entity-list
refetched without a search param, and the refreshed list (filtered to
the default domains) no longer contained the user's pick.
Root cause was in HomeAssistantService.list_entities: when a search
query was present, the function bypassed the domain filter entirely
and returned matches across every HA domain. Offering a clickable
choice the schema can't accept is broken UX, and the cryptic Pydantic
pattern echo on save made it look like a backend/schema problem
rather than a search-permissiveness problem. Confirmed via git diff
that the smart-plug code path is unchanged between v0.2.4 and
0.2.4.1 — this has been latent since the script-domain commit in
February 2026, only noticed now because the reporter hadn't reopened
the modal in months.
Fix: always apply the allowed-domains filter ({switch, light,
input_boolean, script} — kept in sync with the regex in
backend/app/schemas/smart_plug.py:17). Search composes on top as a
substring match against entity_id or friendly_name, instead of
replacing the domain filter. Whitespace-only search strings now
fall back to the no-search behavior.
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
The two # noqa: S501 comments on the local-sidecar reachability probes
were using ruff/flake8 suppression syntax; bandit only honors # nosec,
so the scan flagged both calls as high-severity. Switched to
# nosec B501 with strengthened reasoning (reachability/health probe
only, no secrets in the request). No behavioural change.
urllib3 2.6.3 was being pulled in transitively (none of our top-level
deps require >=2.7.0 yet) and trips two recent CVEs. Direct pin in
requirements.txt forces the resolver to install 2.7.0, which is the
upstream-fixed release for both findings.
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
Archives → Print Log was 404-storming the thumbnail endpoint on every
render: PrintLogEntry.thumbnail_path is copied by value from the archive
at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so
log entries survive archive deletion to preserve stats history - and the
cached path keeps pointing at a file that was removed when the archive's
directory was deleted. Same shape for failed prints whose extractor never
wrote the thumbnail.
Two-part fix:
1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the
entry and commits before returning 404 when the file is missing on
disk. The frontend's <img> tag is gated on entry.thumbnail_path being
truthy, so the next fetch of the log list skips the request entirely.
2. Eager clear on archive delete: new _null_print_log_thumbnail_paths()
helper called from both soft_delete_archive and delete_archive before
the on-disk files are removed. Avoids the one-time storm for future
deletes; covers both the manual delete route and the auto-purge
sweeper at archive_purge.py.
Regression tests cover soft delete, hard delete via ArchiveService, and
the route's lazy-NULL for failed-print orphans where the file was never
written.
Obico polling could freeze the live camera stream within seconds of opening
the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame
saw an empty _last_frames[printer_id] entry (stream startup before the first
JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell
through to capture_camera_frame_bytes() and opened a second RTSP socket. On
firmwares that allow only one camera connection, that second socket forced
the printer to drop the live fan-out connection - the viewer's ffmpeg then
hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing
the next Obico poll, and the broadcaster pump exited.
Widen the gate from "do we have a buffered frame?" to "is any fan-out stream
registered for this printer?". New is_stream_active() helper checks
_active_streams / _active_chamber_streams independently of buffer state.
_capture_frame consults it first: if a viewer is attached, it returns the
buffered frame when available or None (skip this poll cycle) when not. Never
opens a competing socket while a viewer is connected.
Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag).
Benefit: zero competing-socket events while any viewer is connected.
try_get_active_buffered_frame() refactored to delegate to is_stream_active()
so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged
behaviorally (snapshot is a user-initiated single-shot; falling through to
fresh capture on an empty buffer is the desired behavior there).
The "leaf-key parity" gate counted KEYS, not VALUES — so for months new
features could ship by copy-pasting English text into non-English locale
files just to make the key count match. ~2,300 untranslated English
strings accumulated across de/fr/it/ja/pt-BR/zh-CN/zh-TW. Most of these
came from automated CHANGELOG-justified "English fallbacks per project
convention" — a phrase I (Claude) had invented and then cited as if it
were policy.
Two structural fixes so it can't happen again:
1. New Check 4 in check-i18n-parity.mjs flags any leaf whose value is
identical to en.ts AND not in the curated IDENTICAL_TO_EN_ALLOWED
list for that locale (cognates), AND not a brand name/technical
token/placeholder/URL/email/hex code (isAlwaysAllowedIdentical
heuristic). Future English-into-non-English shortcuts fail CI loudly.
2. Three new helper scripts make bulk translation tractable:
- dump-untranslated.mjs: list every flagged (locale, key)
- expand-translations.mjs: unique-source table → per-(locale, key) JSON
- apply-translations.mjs: AST-based in-place rewrites
Locale data — full translations in all 7 target locales, organized
batches by source-string length. Cognates that legitimately match en
(Status/Firmware/Tag/etc. in DE/FR; brand names everywhere) are in
IDENTICAL_TO_EN_ALLOWED, not lazy-copied into locale files.
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
Matplotlib (imported lazily by stl_thumbnail.py) tried to create its
font/style cache at $HOME/.config/matplotlib on first STL upload.
HOME=/app per the Dockerfile but /app is root-owned and not writable
by the PUID:PGID the entrypoint drops to, so matplotlib logged
"Permission denied" and fell back to /tmp/matplotlib-* — wiped on
every restart, paying the font-scan cost again on the next STL.
Add ENV MPLCONFIGDIR=/tmp/matplotlib to make the cache directory
writable and persistent across the container's lifetime. /tmp is
writable by any uid, so this works regardless of PUID.
The bridge cache replaced _latest_print_state wholesale on every
push_status arrival. Bambu firmware sends full pushall responses
(with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
requests, but ~1 Hz incremental updates with only the fields that
changed. The first incremental push after a pushall therefore wiped
AMS info from the bridge cache, and slicers reading the cache (via
the VP's 1 Hz status push) saw a stripped-down state with no AMS
visible until the next pushall — typically only on a manual printer
power-cycle.
Preserve a small set of slicer-visible sticky keys from the previous
cache when the incoming push doesn't carry them: ams, vt_tray,
ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
same pattern Bambuddy uses for its own internal state.raw_data.
Both the queue-side _watchdog_print_start and the direct-dispatch
_verify_print_response used `status.state != pre_state` to decide
whether a project_file command had been accepted. When a printer was
in FINISH at dispatch time (un-dismissed post-print prompt from a
prior job), the firmware silently rejected the new command; if the
user then dismissed the screen prompt, the printer moved FINISH ->
IDLE and the watchdog returned early as "command landed" — leaving
the queue row stuck at status='printing' indefinitely and the
scheduler permanently marking the printer as busy.
Narrow the "command landed" check in both verifiers to an allow-list
of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
the revert. The subtask_id-advance signal stays in place for H2D's
slow FINISH -> PREPARE transition (#1078).
Also wrap _watchdog_print_start's revert commit and
printer_manager._persist_awaiting_plate_clear in run_with_retry so
SQLite single-writer contention can't silently drop these writes.
The revert path returns a tristate sentinel so the post-revert MQTT
session-recovery logic only runs when we actually reverted (or the
commit failed) — not when on_print_complete had already cleared the
row, where a forced reconnect could break a healthy concurrent print.
The #765 guard against shutdown-time data wipes skipped any AMS update
with power_on_flag=False, but some X1C firmware emits power_on_flag=False
while idle with tray_exist_bits still reflecting the real slot inventory.
Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
the bitfield path is the only signal — muting it left spool removals
undetected until a manual reconnect.
Narrow the skip to the exact shutdown pattern: zero bits AND
power_on_flag=False. Non-zero bits with power_on_flag=False are now
applied. The #765 shutdown protection is preserved (its regression test
uses tray_exist_bits='0' and still passes); newer firmwares are
unaffected because their per-tray state path catches the removal first.
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
hostnames serve the same webhooks. The validation now accepts either
prefix while keeping the check itself in place to catch the
paste-the-wrong-thing error.
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.
Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.
Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button
Reporter @Andlar94 hit a permanent "Build plate not empty" on every print
start on an A1 with an external RTSP camera. The runtime auto-check at
main.py:1819 called check_plate_empty with use_external=external_camera_enabled,
but the manual UI routes (camera.py) declared use_external: bool = False
and the frontend client always sent use_external=false. So calibration
captured a built-in frame and stamped it as the reference; the runtime
check captured an external frame and diffed it against that reference --
a permanent mismatch.
Centralise the default on the backend: both routes now take bool | None,
deriving the default from the printer's external_camera_enabled +
external_camera_url + external_camera_type. The frontend client stops
sending the flag unless the caller explicitly sets it, so the existing
UI call sites immediately benefit and any future caller gets the right
camera automatically. Explicit overrides still win.
Adds 4 regression tests pinning the new default for both the
external-enabled and external-disabled cases, plus the explicit override
path so a future "always built-in" caller stays supported.
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
print queue and got no MP4 output even though the log said "Stitching layer
timelapse for printer 1" after each print. Support bundle confirmed the
external camera was working (Obico was polling the snapshot URL fine for
plate detection).
Root cause: start_session() only ran in the two new-archive paths in
on_print_start (fallback_archive at main.py:2510 and regular new-archive at
2600). The expected-archive branch at main.py:1981-2052 — where every
reprint and every queue/VP-dispatched print lands — updated the existing
archive row to status=printing but never started a timelapse session.
So _background_layer_timelapse ran at print complete, called tl_complete(),
found nothing in _active_sessions, returned None silently, and the wrapper
at main.py:3917 produced no log message for the no-session case. Every
print through the queue silently lost its timelapse — likely the reason
this hasn't been caught before (direct slice-and-send-to-printer prints
take the new-archive path and work fine).
Fix: mirror the same start_session() call in the expected-archive branch,
guarded by the same external_camera_enabled + external_camera_url check the
other two paths use.
Also reworded the snapshot URL help text across all 8 locales to make clear
that timelapse and plate detection each require their own per-printer
toggle — the URL is just the image source they pull from when active. The
previous wording read as if filling in the URL was sufficient.
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
payload and found that P1S and A1 Mini send only {"id": N} for a
physically empty slot — no state, no tray_type, no other fields. Without
that signal, the assign-spool path was firing one wasted MQTT publish per
click on a truly-empty slot (firmware dropped it silently, but still).
The AMS parser in printer_manager.py now detects the bare-tray shape and
promotes it to state=9 — the firmware's explicit "no spool" code — which
lets the existing state in {9, 10} short-circuit in the inventory route
apply automatically.
The detection is intentionally narrow:
len(tray) == 1 and "id" in tray and state is None
so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
and tray_type="") has more than one key and stays unaffected — the #1322
root fix is preserved.
Add `# pragma: allowlist secret` markers to the three lines GitGuardian
flagged (ldap_server_url, ldap_bind_dn, admin_password) and pull the
duplicated AdminPass1! literal into a single test_password variable so
the marker only needs to live in one place.
All values are test fixtures (test directory + admin password used only
by the LDAP provisioning integration suite), not real credentials.
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
The original #1322 fix widened empty-slot detection to (state == 11 OR
tray_type != ""), which closed the configured-slot reconfig case but
didn't help the "Reset Slot on printer screen with spool still inserted"
flow. On these firmwares the AMS reports state=3, tray_type="" after a
Reset Slot regardless of whether a spool is physically present, so the
empty-detection still decided "empty", skipped MQTT, marked pending —
and on_ams_change replay never re-fired because the AMS never reported
any state change either.
RosdasHH traced the path: tray_state=3 falls into the else: branch,
slot_is_empty = not (fingerprint_type and fingerprint_type.strip()),
fingerprint_type is "", so slot_is_empty=True, MQTT is skipped, and the
slot stays unconfigured forever. He verified empirically that removing
the gate makes the firmware accept the push when a spool is physically
present.
Drop the tray_type fallback entirely. Only state in {9, 10} (firmware's
explicit "no spool" / "spool present but no feed") short-circuits the
MQTT publish. Every other state — including 3 (default-idle, ambiguous)
and missing-state (older firmwares) — attempts the publish. Bambu's
"firmware silently drops on empty slots" behavior makes the worst case
a no-op for a truly-empty slot, and on_ams_change replay still serves
as the safety net for state=9/10 slots whose spools get inserted later.
pending_config is now (slot_is_definitely_empty OR not configured) so a
printer-offline / no-client publish failure correctly flags the
assignment for replay instead of falsely showing "configured".
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
misleading error "Firmware file is not available from Bambu Lab" while the
logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
on the reporter's network, and the buildId was cached in memory only, so a
single 403 broke download-URL resolution for the rest of the session.
- Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
so the request stops tripping Cloudflare's "bare scraper" signal.
- Persist the buildId to <data_dir>/firmware/build_id.json so a transient
403 or a backend restart can't wipe a previously-valid buildId.
- Add a download_page_unreachable flag and use it in the prepare-update flow
to render an honest error ("page unreachable from this network — try later
or download manually from bambulab.com") instead of implying Bambu doesn't
have the file.
- Retry the per-model JSON once when a cached buildId returns 404 (page
rebuild), give up gracefully on 403 without churning.
Adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF
flow (same shape as the existing PETG HF) hit a missing option: KNOWN_VARIANTS
in spool-form/constants.ts didn't list CF or GF. Users had to type it
freehand into the "create new" tail of the dropdown.
Added both: CF (matches PETG-CF / PLA-CF / ASA-CF / PA-CF) and GF (the
natural pair for ABS-GF / PA6-GF). parsePresetName is unaffected — its
materials list is iterated longest-first, so cloud presets like
"Bambu PETG-CF Black" still resolve to material=PETG-CF with empty
afterMaterial.
Two latent issues surfaced after the original AssignSpoolModal z-50 →
z-[100] bump landed:
1. Material-mismatch ConfirmModal hidden behind AssignSpoolModal.
ConfirmModal's overlay was hardcoded to z-50 in its wrapper, so once
the parent moved to z-[100] the nested confirmation dialog sat
behind it. Added an optional overlayZIndex prop to ConfirmModal
(defaults to z-50 — none of the 82 other call sites change), and
the mismatch site in AssignSpoolModal passes z-[110] so the warning
stacks above its parent.
2. FilamentHoverCard / EmptySlotHoverCard covered by sibling printer
cards on the dashboard. The popovers used position:absolute with
z-[60] inside the trigger, but every printer card creates its own
stacking context (drop-shadow filter on the slot tiles is enough),
and z-index doesn't cross stacking-context boundaries — the next
sibling card always wins by DOM order. Visible as the "Jade White
· Bambu PETG HF" tooltip getting half-eaten by the neighbour card's
AMS column.
Fixed by portaling both hover cards to document.body with
position:fixed and screen-space coordinates from
triggerRef.getBoundingClientRect(). Coords recompute on visibility
change, scroll (capture), and resize so the popover follows the
trigger when the viewport moves; a requestAnimationFrame re-measure
after the first paint avoids a one-frame flicker before the card
has its rendered dimensions. Hover handlers are wired on both the
trigger AND the portaled card so moving the cursor from slot to
popover doesn't auto-dismiss after 100 ms. Top/bottom placement
and arrow-pointer logic preserved.
Reported by @IndividualGhost1905: printing the same model ten times and
then deleting nine archive entries (to keep the file list tidy) silently
rewound the totals on the Statistics page — total prints, filament,
cost, and per-print energy all dropped back to whatever the surviving
row contributed, as if the other nine prints had never happened.
Root cause: every metric in get_archive_stats is recomputed live from
PrintArchive rows via COUNT / SUM, so removing a row removes its
contribution. Energy in the default "Total" mode already survived
deletion because it reads the smart-plug lifetime counters — that's
the architectural shape we now generalise to the rest.
Fix: soft delete with opt-in hard purge.
Backend:
- New nullable, indexed deleted_at column on print_archives, dialect-
conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
- ArchiveService.soft_delete_archive flips deleted_at and removes the
files from disk (still reclaims storage); the path-safety checks were
extracted into _resolve_archive_dir_for_delete so soft and hard delete
share the rules.
- DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
- Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
detection (so a 1-live + 9-soft-deleted group no longer marks the
survivor as a duplicate), and ArchiveComparisonService's "similar"
suggestions. GET /stats and GET /slim deliberately do NOT filter so
Quick Stats and the dashboard widgets keep counting deleted prints.
Frontend:
- ConfirmModal gained an optional children slot.
- ArchivesPage (both card and detail views) own a per-instance
deletePurgeStats boolean and render an opt-in checkbox in the delete
dialog; resets to off on every close so the destructive option is
never sticky.
- api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
when the box is ticked.
- One new i18n key archives.modal.deletePurgeStats added across all 8
locales (full German, English fallbacks elsewhere).
* feat(auth): proxy OIDC provider icons server-side (#1333)
Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
The assign flow was sending slicer-invalid values for tray_info_idx and an
empty setting_id, which the slicer rejected — slot detail modal showed
empty fields. With a stored k-profile the realignment path masked the
issue; without one, garbage hit MQTT.
Backend (apply_spool_to_slot_via_mqtt):
- Discard tray_info_idx values that aren't real preset IDs: literal
material names ("PLA", "PETG-CF") AND PFUS-prefix cloud setting_ids
(valid as setting_id but rejected as tray_info_idx). Same check applied
to current_tray_info_idx so stale slot values don't get reused as
garbage.
- Local-preset path now reads the printer-recognized filament_id from
the preset's setting JSON (e.g. P4d64437) instead of falling through
to a generic material ID.
- Derive setting_id from filament_id_to_setting_id when empty so
ams_filament_setting always carries a matched pair.
- No stored k-profile: always send cali_idx=-1 (Default K), regardless
of the live cali_idx on the slot. The live value belongs to whatever
filament was there before, so reusing it would apply the wrong K to
the new spool.
Frontend (spool-form/utils.ts):
- Local preset options use String(preset.id) as the unique code instead
of preset.filament_type — every PLA local preset was collapsing onto
the same "PLA" code, so picking any of them saved slicer_filament=
"PLA" and lost the specific preset identity.
Spoolman counterpart in spoolman_inventory.py mirrors the cali_idx=-1
reset.
Both used z-50, so DOM stacking-order let the sidebar bleed over the
modal on narrow viewports. Bumped to z-[100] to match the convention
used by GitHubBackupSettings, FilamentHoverCard, and the Layout
confirmation modal.
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
Picking a color preset from the catalog only copied color_name and
rgba onto the spool — extra_colors (gradient stops) and effect_type
(sparkle / wood / etc.) were silently dropped at three layers above
the API: the SpoolFormModal state shape, the CatalogDisplayColor
mapping in ColorSection, and the selectColor handler itself. All
three widened to carry both fields through.
Picking a catalog swatch now writes both fields from the entry, so
solid presets cleanly replace previous gradients. Recent-colors and
the hardcoded-fallback palette stay as plain hex pickers — they
don't touch extras/effect since they aren't full presets.
Also fixed the en-US `colour` → `color` drift in 8 locale files
that the reporter flagged.
A1 Mini BMCU (01.07.02.00) and P1S Standard AMS (00.00.06.75) always
report tray.state=3, even for loaded configured slots. The empty-slot
detection preferred state==11 with tray_type as a fallback only when
state was absent, so every assign was classified as empty and MQTT
was skipped — both for "assign to unconfigured slot" and the secondary
"PETG over a PLA-configured slot won't reconfigure" symptom.
Empty-slot detection in the assign route and the on_ams_change replay
now treats the slot as loaded when EITHER state==11 OR tray_type is
non-empty. Reset-slot case (state=11 + tray_type="") still works
through the first clause; configured slots on these firmwares now
work through the second.
Truly empty unconfigured slots (state!=11 + tray_type="") still hit
the pending-config path, and the deferred publish now fires when the
user later configures the slot in Bambu Studio (tray_type goes
non-empty), since the replay uses the same disjunction.
Two regressions reported in #1336:
1. spoolMatchesQuery did not include spool.id in the predicate, so
typing a numeric Spoolman ID into the Assign Spool dialog or the
Inventory page search returned no matches. Predicate now also
tests String(spool.id).includes(q).
2. The Unassign button in the spool edit modal was permanently
disabled for Spoolman-mode spools. The modal only ever queried
the legacy spool_assignments table (keyed by spool_id), but in
Spoolman mode the assignment lives in spoolman_slot_assignments
(keyed by spoolman_spool_id). Both the lookup query and the
unassign mutation now branch on the spoolmanMode prop and call
the Spoolman-flavored endpoints.
Bambuddy's external SpoolmanDB lookup in `_find_or_create_filament` matched
on material+color only, with no manufacturer filter. Because SpoolmanDB is a
multi-vendor catalog and entries are roughly ID-sorted, the first hit for
any common combination is almost always a competitor — `bambulab_pla_black_1000_175_n`
is the 15th entry for PLA + `#000000`. Bambu Lab RFID spools were being
labeled with competitor product names (`3DJAKE Black`, `3DXTECH™ Black`, etc).
Restrict the external-library loop to entries whose manufacturer is
`"Bambu Lab"` (with `id.startswith("bambulab_")` as a defensive fallback
for schema drift). When multiple Bambu Lab candidates exist, prefer the
entry whose `name` equals the AMS `tray_sub_brands` so `"PLA Basic"` wins
over generic `"Black"` when both are present. Forward `density` from the
chosen external entry so it is no longer overwritten by the PLA-default
1.24 in `create_filament`.
Six unit tests added: internal short-circuit preserved, non-Bambu external
entries skipped, PLA Basic > generic PLA tiebreaker, no-match fallback,
id-prefix defensive fallback, density propagation.
Fixes#1309
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
control sent the nozzle straight into the build plate. Reporter
triggered it with the 50 mm step and crashed their nozzle.
Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
gap). The frontend maps "Up" to negative distance with that convention
in mind.
A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
firmware uses standard cartesian Z (Z+ = toolhead up). On those models
G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
classification at the bed-jog code path, so every printer got the same
X1-convention G-code.
Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
already-defined A1_MODELS frozenset which covers display names and
internal codes N1 / N2S). The bed-jog route now inverts the signed
distance before emitting G-code when the printer model is in that set,
so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
regardless of which physical part moves. Frontend untouched, single
source of truth lives in the backend, keyed off the Printer.model
column. Route Query description and docstring updated to spell out the
new contract: distance is the gap adjustment, not the raw Z value.
Editing a spool's color name on Spoolman-backed inventory appeared to
accept the new value but the inventory list column and the next edit
showed it back to the subtype. Three layers stacked to produce this:
1. find_or_create_filament matches by material/name/color_hex/vendor —
color_name is intentionally not part of the match key, but on a
match it returned the existing filament's id unchanged, silently
dropping the new value.
2. The read helper falls back to subtype when filament.color_name is
empty (kept on purpose: without it Spoolman installs that don't
fill the field render every spool as "Unknown color").
3. The edit form prefilled color_name from spool.color_name — which
on those installs was the synth value. Changing subtype but not
color_name silently round-tripped the OLD subtype back to Spoolman
as if it were a real user-set color_name.
Fixes:
- find_or_create_filament now patches the matched filament's
color_name via the existing patch_filament wrapper when the request
differs. Parameter convention: None = don't touch, "" = explicit
clear, any other string = set/update. A patch failure is logged but
does not block the match.
- The PATCH route uses model_fields_set to distinguish "field omitted"
from "field explicitly set to null" (mirrors the existing
storage_location pattern at the same site).
- The map helper returns color_name_is_synthesized: bool. The edit
form leaves the input blank when true, so the user sees the real
stored state and can't accidentally round-trip the synth value back.