NozzleRackCard computed its rack base via min(present_ids), which breaks
when the lowest-ID slot is the one currently mounted to a hotend — the
firmware omits that ID from device.nozzle.info entirely, so min() picks
the next slot up and every remaining nozzle renders one position too
far left, with the "empty" placeholder pushed off the right end.
Use the fixed H2C rack base of 16 (matching
test_h2c_nozzle_rack_populated_with_8_entries in the backend) so the
empty slot stays anchored to its physical position regardless of which
nozzle is currently mounted.
Adds a frontend regression test covering ID 16 missing.
Follow-up to the SpoolBuddy LCD power-off fix. Field-testing on a
Raspberry Pi OS Bookworm kiosk showed the watchdog appearing absent
from `ps ax | grep spool` — actually it had already `exec`'d into
`swayidle`, but with no logging there was no way to confirm that
without manually re-running the script.
- spoolbuddy-idle.sh now redirects stdout+stderr to
~/.cache/spoolbuddy-idle.log and prints WAYLAND_DISPLAY,
XDG_RUNTIME_DIR, PATH, the resolved timeout, and the final
`swayidle` command line on every start.
- Auto-detect WAYLAND_DISPLAY by scanning $XDG_RUNTIME_DIR for a
wayland-* socket (10s retry loop) so the script survives the race
where labwc launches autostart before exporting its env.
- Default XDG_RUNTIME_DIR to /run/user/$(id -u) if unset.
The SpoolBuddy kiosk's "screen blank timeout" setting only painted a
black CSS overlay over the browser window — the HDMI panel's backlight
stayed on indefinitely, wasting power and risking burn-in on
OLED/LED panels.
Move blanking down to the OS layer:
- install.sh now installs swayidle + wlopm + jq and rewrites labwc's
autostart to launch a new spoolbuddy-idle.sh watchdog instead of the
old `wlr-randr --on` keep-alive loop.
- The watchdog sources /opt/bambuddy/spoolbuddy/.env, derives device_id
from the first non-loopback MAC (same algorithm as daemon/config.py),
fetches the configured blank_timeout from the backend once on boot,
and execs `swayidle -w timeout $T 'wlopm --off HDMI-A-1' resume
'wlopm --on HDMI-A-1'`. Touch/keypress wakes via labwc's input event
path. timeout=0 skips swayidle entirely so existing installs that
never picked a timeout keep their current always-on behavior.
- New GET /api/v1/spoolbuddy/devices/{id}/display endpoint returns the
current brightness + blank_timeout. Gated on INVENTORY_UPDATE (same
level the daemon heartbeat key already uses) so existing SpoolBuddy
API keys work without extra permissions.
- SpoolBuddyLayout drops blanked state, the blank timer, activity
listeners, resetActivity, and the CSS overlay. Runtime updates to
the timeout take effect on next kiosk/browser restart; default for
newly-enabled blanking is 300 seconds.
The prior fix (9f643724) added a list branch to _strip_container but only
walked one level deep. SQLAlchemy's insertmanyvalues feature can pass
parameters as nested containers (e.g. a list of tuples, or a tuple inside
a list) depending on the dialect path, so the inner tz-aware datetimes
still reached asyncpg and the hourly snapshot loop kept failing with:
asyncpg.DataError: invalid input for query argument $2: ...
(can't subtract offset-naive and offset-aware datetimes)
Replaced the two-helper design with a single recursive _strip() that
walks dict/list/tuple at any depth. One top-level call now handles every
parameter shape SQLAlchemy may use, regardless of executemany or the
insertmanyvalues batching path.
The hourly smart plug energy snapshot loop introduced with #941 crashed
every cycle on PostgreSQL installs with:
asyncpg.DataError: invalid input for query argument $2:
datetime.datetime(..., tzinfo=datetime.timezone.utc)
(can't subtract offset-naive and offset-aware datetimes)
The engine has a `before_cursor_execute` hook that strips tzinfo from
aware datetimes before they reach asyncpg (all schema datetime columns
are TIMESTAMP WITHOUT TIME ZONE). The hook's `_strip_container` handled
dict and tuple parameter containers but fell through `list` unchanged.
When SQLAlchemy's insertmanyvalues feature batches two or more rows into
a single INSERT ... SELECT FROM (VALUES ...) statement, it passes the
positional params as a flat `list`, so the tz-aware datetimes survived
the hook and reached asyncpg.
Added a list branch that mirrors the tuple one. No schema change needed
— the column stays naive UTC like the rest of the codebase. SQLite was
never affected.
The Printer tab AMS popup and spool auto-provisioner resolved color
names from hardcoded tray_id_name tables with a suffix-code fallback —
and suffix codes like "R1" are not globally unique across material
families. A17-R1 (PLA Translucent Cherry Pink) fell through the
fallback and resolved to "Scarlet Red" (A01-R1, PLA Matte), baking
the wrong name into auto-created inventory spools.
The fix removes the hardcoded tables entirely. Backend resolves color
names via the existing color_catalog table by hex; frontend fetches a
compact {hex: name} map once per session via a new
GET /inventory/colors/map endpoint (auth-gated but not on
inventory:read — read-only views need it too) and stores it in a
ColorCatalogProvider context. A useSyncExternalStore hook cascades a
re-render into pages mounted before the fetch completes so they
refresh from HSL-fallback names once the catalog loads.
Existing auto-provisioned spools keep their stored names; only new
provisioning and live display benefit. Co-Authored-By is intentionally
omitted here per project convention — set it via git config if needed.
LDAP auto-provisioning hit a NOT NULL constraint error on upgraded SQLite
installs because the existing migration only ran on PostgreSQL. The SQLite
branch now patches sqlite_master via writable_schema and bumps schema_version
so the change takes effect without a restart. Fresh installs were unaffected.
The Statistics page reported "Gesamt" (All Time) kWh correctly but showed
zero for Today/Week/Month in total-consumption mode. Two bugs drove it:
1. The starting plug counter was kept in an in-memory dict
`_print_energy_start` that was lost on any backend restart mid-print, so
the per-print `energy_kwh` delta silently never got computed. The stats
endpoint's fallback path `SUM(PrintArchive.energy_kwh)` therefore summed
to zero for users running in total mode.
2. Total-consumption mode has no per-print delta by design — it includes
idle/preheat/standby — so the fallback to archive rows was the wrong
strategy even when the data existed.
Fix, in two parts:
- Persist `energy_start_kwh` on the archive row and read it back from a
fresh session at print end. Deletes `_print_energy_start` and its 5
call sites, replacing them with a single `_record_energy_start()` helper.
Per-print tracking is now restart-resilient regardless of tracking mode.
- Add hourly `smart_plug_energy_snapshots` table + `_snapshot_loop()` in
SmartPlugManager. Rewrote the `/archives/stats` energy branch as
`_sum_snapshot_deltas()` which computes per-plug
`max(0, last-in-range - baseline)` where baseline is the latest snapshot
at or before the range start, falling back to the earliest-ever snapshot
and signalling `energy_data_warming_up` when no pre-range baseline
exists (fresh upgrade). MQTT plugs are skipped from snapshots since they
only report "today" and have no lifetime counter.
Frontend: QuickStatsWidget renders an AlertTriangle next to Energy Used /
Energy Cost with a tooltip when `energy_data_warming_up` is true, so the
"low values right after upgrading" situation is explained in-product.
Fully localised across 7 UI languages.
Tests: new backend unit tests cover the snapshot delta arithmetic
(baseline/endpoint, counter reset clamp, multi-plug, warming-up fallback,
endpoint windowing), per-print restart resilience via expunge_all, and the
snapshot task lifecycle (start idempotent, stop cancels). Frontend tests
assert the warning icon appears only when the flag is set and only on the
energy tiles.
Docs: updated `CHANGELOG.md`, `README.md`, wiki `features/energy.md`,
wiki `features/statistics.md`, and website `features.html` with the new
behaviour and warming-up explanation.
Previously, if a SpoolBuddy daemon crashed during registration it could
end up registered twice. The kiosk UI silently used only the first
device and there was no UI path to remove the orphan — administrators
had to delete the row directly in the database.
Adds a new Settings → SpoolBuddy tab that lists every registered device
with live connection status, system details (firmware, IP, CPU temp,
memory, disk, OS, daemon + system uptime), hardware health flags, and
an Unregister action gated by a confirm modal. A yellow banner appears
whenever more than one device is registered to flag likely crash-
duplicates. Backend adds DELETE /spoolbuddy/devices/{device_id} gated
by inventory:delete and broadcasts spoolbuddy_unregistered over WS so
other tabs refresh immediately.
The tab header shows a device-count pill and a green/gray status bullet
reflecting whether at least one registered device is online. An online
device that is accidentally unregistered re-registers itself on its
next heartbeat. Localized in English, German, and Japanese. The kiosk
layout still uses devices[0] — once the orphan is unregistered, the
remaining device naturally becomes [0].
Audit PRs #920 (printers search/filter) and #932 (print from project
view) for regressions, i18n coverage, test gaps, and docs.
No regressions found: existing callers of the changed signatures
(archive_print, getLibraryFiles, filteredPrinters chain) are unaffected;
i18n is complete in all 7 locales for both features.
Backend tests (4 new, all passing):
- test_list_files_by_project_id — bulk JOIN returns files across all
linked folders, excludes unlinked ones
- test_list_files_folder_id_takes_precedence_over_project_id — guards
the documented precedence folder_id > project_id > include_root
- test_add_to_queue_with_project_id — project_id is persisted on the
queue row for later archive linkage
- test_add_to_queue_invalid_project_id_returns_404 — regression guard
for the validation pre-check on the queue path (mirrors the one on
the direct-print path in library.py)
* feat: print files directly from project view (closes#930)
Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
PR #920 was merged without a CHANGELOG entry and without test coverage
for the location filter dropdown (every other new control — name
search, model search, serial search, whitespace trim, clear button,
status filter, empty states, combined filters — already had tests).
Adds:
- `filters by location via dropdown` — overrides the printers mock so
printer 1 has location 'Workshop' and printer 2 has location
'Office', then verifies that selecting each location shows only the
matching printer and that switching between the two works.
- `hides location filter when no printers have a location` — both
printers get a null location, and the test asserts the status filter
dropdown is still rendered but the location filter dropdown is not.
- CHANGELOG entry under [0.2.3b3] > New Features crediting the
contributor and documenting the search/filter feature, including the
WebSocket-reactive status filter behavior.
* feat(printers): add live search/filter bar to printers page
Adds a search bar below the page title that filters the printer list
in real time by name, model, location, and serial number. Includes an
X button to clear the search and an empty-state message when no
printers match. Filtering is purely client-side — no backend changes
needed.
Closes#852
OrcaSlicer's "Send job" flow sat on "Synchronizing device information…"
until it gave up, even though FTP upload worked when the user clicked
"Send job anyway". The virtual printer's MQTT server gated all incoming
command handling on `f"device/{self.serial}/request" in topic` — if the
slicer's cached serial for the VP didn't exactly equal the VP's computed
self.serial (model prefix + per-VP serial_suffix), every get_version,
pushall, and project_file publish was silently dropped. Nothing was
logged past the initial "MQTT publish to …" line, so the slicer never
received a push_status or get_version response on its subscribed
device/{serial}/report topic and hit its sync timeout. Responses were
also unconditionally published on device/{self.serial}/report, so even
when the inbound check happened to pass, replies targeted a topic the
slicer wasn't listening on if its serial had drifted.
Both directions are now serial-adaptive:
- `_handle_publish` accepts any authenticated publish on a
`device/*/request` topic and extracts the serial from the topic itself
rather than comparing against self.serial.
- A per-connection `_client_serials` dict tracks the serial the slicer
actually uses, populated from the first SUBSCRIBE or PUBLISH seen on
each connection and cleared on disconnect/stop.
- `_send_status_report`, `_send_version_response`, `_send_print_response`
now take an optional `serial` parameter (defaulting to self.serial)
so every outgoing publish — including the periodic 1-second status
push — targets the topic the slicer subscribed to.
- The version response's embedded `module[].sn` fields now also carry
the client's serial so the payload is internally consistent with the
topic.
- When the client's serial differs from self.serial an INFO log records
the adaptation so it's visible in future support bundles.
The working case (slicer's cached serial equals self.serial, as in my
own H2D-1 Proxy setup) is bit-for-bit identical to the old behavior —
the new check is strictly more permissive and only affects cases the
old code silently dropped.
Regression tests cover:
- `_extract_serial_from_topic` valid/invalid topic shapes
- mismatched-serial publish → handler runs, response topic and sn field
both use the client's serial
- non-`/request` topics → still rejected
- pushall → status_report routed to the client's subscribed topic
- `_client_serials` cleared on stop()
The SpoolBuddy remote-update flow always pulled `main` on the remote
device when Bambuddy ran under Docker, regardless of which branch the
image was built from. Root cause: the Dockerfile COPYs only backend/
and static/, and .dockerignore excluded .git entirely, so the container
had no git metadata anywhere. detect_current_branch() silently fell
through its file-read path and returned the GIT_BRANCH env-var default
of "main".
The old subprocess-based implementation had the same bug but it was
masked twice: no .git in the image AND no `git` binary in the image,
so git rev-parse raised FileNotFoundError, the bare except swallowed
it, and the fallback kicked in.
Let the one file we actually need (.git/HEAD — ~20 bytes containing
`ref: refs/heads/<branch>`) through the .dockerignore filter and COPY
it into the image at /app/.git/HEAD. detect_current_branch() already
reads exactly that path, so no Python code changes are needed. Bind-
mount development setups are unaffected — the bind mount overlays the
baked-in file with the live repo's .git/HEAD.
Verified with a throwaway alpine build using the same .dockerignore
pattern: .git/HEAD passes through, decoy .git/refs and .git/objects
entries are excluded, and COPY writes the expected content into the
image.
detect_current_branch() was reading .git/HEAD from settings.base_dir,
which points at the data volume (DATA_DIR=/app/data in Docker) and
never contains .git. The repo is at /app, so the lookup always failed
and the code fell through to the GIT_BRANCH env-var → "main" fallback.
The SpoolBuddy device was therefore checking out `main` regardless of
which branch Bambuddy itself was running.
The old subprocess-based implementation had the same bug but it was
masked: the stock Docker image has no `git` binary, so `git rev-parse`
raised FileNotFoundError, the except clause swallowed it, and the
fallback kicked in. Swapping to filesystem reads exposed the wrong
lookup path.
Add a module-level _APP_DIR constant (parents[3] of the module file,
same depth as config.py uses for its own _app_dir) and read `.git/HEAD`
from there. A regression test plants a decoy .git in the data dir and
asserts we still pick up the real one from the app root.
Per your NO GIT WRITES rule, nothing is staged or committed.
Follow-up to the asyncssh migration. asyncssh.connect() internally
calls getpass.getuser() for ~/.ssh/config host matching, regardless
of the explicit `username=` passed for the remote login. Under an
arbitrary Docker PUID with no /etc/passwd entry, getpass.getuser()
raises "No username set in the environment" (OSError in Python 3.13+,
previously a bare KeyError).
Fix: set LOGNAME=bambuddy, USER=bambuddy, HOME=/app in the Dockerfile.
getpass.getuser() tries env vars before pwd.getpwuid(), so the lookup
never touches the passwd database and works for any PUID the operator
picks — no helper code, no image rebuild for different UIDs.
Also pass config=[] to asyncssh.connect() so it does not try to load
~/.ssh/config (whose default path needs a resolvable home directory).
An earlier draft of this fix added a Python helper that caught the
KeyError and injected LOGNAME at module import. That was both more
code than needed and broken on Python 3.13, which wraps the KeyError
in an OSError the helper didn't catch — so the module import itself
crashed, producing a 500 on /spoolbuddy/devices/{id}/update. Reverted
in favour of the one-line ENV fix.
Follow-up to the previous commit that swapped the `ssh`/`ssh-keygen`
subprocesses for asyncssh. asyncssh.connect() internally calls
getpass.getuser() to resolve the *local* username for ~/.ssh/config
host matching, regardless of the explicit `username=` we pass for the
remote login. Under an arbitrary Docker PUID with no /etc/passwd
entry, getpass.getuser() tries LOGNAME/USER/LNAME/USERNAME (all unset
in python:3.13-slim) and falls back to pwd.getpwuid(), which raises
KeyError. asyncssh rewraps that as "Unknown local username: set one
of LOGNAME, USER, LNAME, or USERNAME in the environment" — which
surfaced in the UI as "ssh connection failed: no username set in the
environment".
Fix is two-part:
- _ensure_local_username_env() runs at module import. If getpass
.getuser() already works, or any of LOGNAME/USER/LNAME/USERNAME is
set, it is a no-op. Otherwise it sets LOGNAME=bambuddy so asyncssh
can proceed. Native installs are untouched.
- asyncssh.connect() is now called with config=[] to skip the
default ~/.ssh/config load, which relies on a resolvable home
directory that may not exist under arbitrary Docker PUIDs.
Three new unit tests cover the env-var fallback, including the case
where the operator has set USER but the passwd lookup still fails.
Commit 67749565 eliminated ssh-keygen from the SpoolBuddy remote-update
flow, but the update path still shelled out to the OpenSSH `ssh` client
for every command. Like ssh-keygen, the `ssh` binary calls
getpwuid(getuid()) during startup and aborts with "No user exists for
uid <N>" when the container runs under an arbitrary PUID that isn't in
/etc/passwd (python:3.13-slim only ships a root entry, so any
`user: "1000:1000"` compose setup trips the same error).
detect_current_branch() had a related problem: when the git repo is
bind-mounted into the container, .git exists inside Docker, so the code
tried to run `git rev-parse`. Git isn't in the image, so the subprocess
silently fell back to the GIT_BRANCH env var — and if git ever were
added, it could hit the same getpwuid trap.
The entire update path is now subprocess-free:
- _run_ssh_command uses asyncssh (pure-Python, built on the already
installed cryptography library). Connection errors map to rc=255 to
match `ssh`'s convention; asyncio.timeout handles the timeout path.
- detect_current_branch reads .git/HEAD directly (handling git-worktree
`gitdir:` pointer files too), keeping the same GIT_BRANCH → "main"
fallback chain.
- shutil and the inline `import subprocess` are gone from the module.
Regression tests assert that neither keypair creation, branch
detection, nor command execution spawns any subprocess. Native installs
are unaffected.
The sidebar <img> tag in Layout.tsx fetched custom external-link icons
via a raw /api/v1/external-links/{id}/icon URL. That endpoint is
protected by the shared camera-stream token (passed as ?token=xxx
because <img> tags cannot send Authorization headers), so the request
came back 401 with the "Valid camera stream token required" message.
The edit dialog already routed through api.getExternalLinkIconUrl(),
which wraps the URL via withStreamToken(); the sidebar now does the
same in both the open-in-new-tab and NavLink branches.
The Shortest Job First toggle badge was rendered inside the Pending
Queue section header, which only mounts when pendingItems.length > 0
and the list view is selected. Clicking the toggle often lined up
with the scheduler picking up the last pending item, which unmounted
the whole section and took the toggle with it.
Moved the toggle into the queue page header next to the list/timeline
view switcher so it stays visible regardless of pending-item count,
filters, or view mode. On mobile the view-mode switcher remains
hidden (as before) but the SJF button is visible icon-only.
Fixes#929. The breadcrumb in ProjectDetailPage used the non-existent key
`navigation.projects`. Changed to the correct key `nav.projects` which is
defined in all locale files and resolves to e.g. 'Projects' in English.
The SpoolBuddy remote-update flow shelled out to `ssh-keygen` to create
its update keypair on first use. Inside the Docker container the process
runs under an arbitrary PUID that is not listed in /etc/passwd, so
ssh-keygen aborted at the getpwuid() home-directory lookup with
"no user exists for uid 1001" and the update button failed.
Generate the ed25519 keypair in-process via the `cryptography` library
(already a dependency) and serialize it in OpenSSH format. No subprocess,
no /etc/passwd lookup. Native installs are unaffected.
Added a regression test that asserts no subprocess is spawned during
keypair creation so this can't come back.
Two bugs surfaced while investigating camera reconnect behaviour in #925.
The camera page briefly displayed "Reconnecting attempt 6 of 5" before
giving up, because the attempt counter could be incremented to the
maximum while the reconnect banner was still rendering. The displayed
value is now clamped to the configured maximum.
Every failed ffmpeg spawn logged the full ~20-line ffmpeg version,
configuration, and lib* banner, producing hundreds of lines of noise
per failed camera click (one reported click produced 555 log lines
across 30 retries). A new _summarize_ffmpeg_stderr helper strips the
banner and caps output at the last 10 meaningful lines, applied at
all three stderr log sites (immediate-failure, stream-ended,
read-timeout). Covered by unit tests for empty input, banner
stripping, line cap, blank-line filtering, and banner-only input.
The underlying "camera service stops accepting connections after
prolonged uptime" behaviour in the X1C firmware is still under
investigation — these two fixes are independent of that root cause.
Two related LDAP authentication changes.
Fix: POSIX primary group membership was ignored. authenticate_ldap_user
only searched for posixGroup entries via memberUid (supplementary
groups). A user's primary group — referenced by the gidNumber attribute
on the user object matching gidNumber on a posixGroup — was never
resolved, so users whose role came from their primary group landed
without the expected permissions. The authenticator now runs a second
search for posixGroup entries whose gidNumber matches the user's
primary gidNumber, then dedupes DNs case-insensitively before passing
the list to resolve_group_mapping (LDAP DNs are case-insensitive by
spec).
New feature: ldap_default_group setting. Settings → Authentication →
LDAP → Advanced has a new "Default group" selector. When an LDAP user
authenticates but is not listed in any mapped LDAP group, they are
assigned to this fallback group instead of being left with no groups
(and therefore no permissions). A warning is logged each time the
fallback is applied so admins can spot missing group assignments.
Empty setting preserves the old behavior.
Tests: added 4 mocked authenticate_ldap_user tests covering primary
gidNumber lookup, dedupe of overlapping memberUid+primary gid matches,
case-insensitive DN dedupe, and the guard when a user entry has no
gidNumber attribute. Also extended the existing parse_ldap_config tests
to cover the new default_group field.
Backend: ldap_service.py (primary group + dedupe + default_group
field), schemas/settings.py (schema field), api/routes/auth.py
(fallback wiring in _provision_ldap_user / _sync_ldap_user).
Frontend: LDAPSettings.tsx default-group dropdown in the Advanced
collapsible, api/client.ts type field, new i18n keys in all 7 locales
(defaultGroup, defaultGroupNone, defaultGroupHint).
The debug support bundle included virtual_printer_remote_interface_ip
unmasked in support-info.json. The setting key didn't match any
sensitive-key filter substring. Added "_ip" to the filter set so IP
address settings are excluded. Log file content was already redacted
by the existing IPv4 regex.
The on_ams_change auto-sync callback set locations for new spools but
never called clear_location_for_removed_spools(), leaving stale locations
that caused double-booked slots. Also pass synced_spool_ids in the
single-printer sync route to match the sync-all endpoint behavior.
The clearPlateMutation.isSuccess state from React Query persisted after
the first successful plate clear. When the next print finished, the
stale isSuccess rendered the static confirmation instead of the clickable
button. Reset mutation state when printer leaves FINISH/FAILED.
Swipe down from the top of the SpoolBuddy display to open a quick-access
menu for toggling printer smart plugs and managing the device (restart
daemon, restart browser, reboot, shutdown). All destructive actions
require confirmation.
Backend: new POST /spoolbuddy/devices/{id}/system/command endpoint
queuing reboot/shutdown/restart_daemon/restart_browser commands.
Daemon: handles commands via subprocess (sudo reboot, systemctl restart).
Frontend: SpoolBuddyQuickMenu component, swipe-down gesture detection,
i18n keys for all 7 locales.
Swipe down from the top of the SpoolBuddy display to open a quick-access
menu for toggling printer smart plugs and managing the device (restart
daemon, restart browser, reboot, shutdown). All destructive actions
require confirmation.
Backend: new POST /spoolbuddy/devices/{id}/system/command endpoint
queuing reboot/shutdown/restart_daemon/restart_browser commands.
Daemon: handles commands via subprocess (sudo reboot, systemctl restart).
Frontend: SpoolBuddyQuickMenu component, swipe-down gesture detection,
i18n keys for all 7 locales.
Users can authenticate against an LDAP/AD server with configurable
server URL, bind DN, search base, and user filter. Supports StartTLS
and LDAPS — plaintext is not allowed. Both Active Directory (memberOf)
and POSIX groups (memberUid) are mapped to BamBuddy groups on each
login. Auto-provisioning creates local accounts on first LDAP login.
Local admin accounts remain as fallback when LDAP is unreachable.
Password management is disabled for LDAP users.
When auto_archive was disabled but the print was dispatched by BamBuddy
(queue/reprint), on_print_start discarded the expected print entry and
returned early. The archive was never promoted to _active_prints, so at
completion archive_id and ams_mapping were both None — all tracking paths
failed silently. Now detects expected prints before the auto-archive
early-return and falls through to the normal promotion path. Also injects
the stored ams_mapping into the usage tracker session for printers where
MQTT request topic subscription fails (P1S, A1).