GET /spoolman/spools/unlinked hid any spool with a non-empty
extra.tag from the AMS-slot assignment picker. extra.tag is only an
RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value
into that same Spoolman field -- so every OpenSpoolman-tagged spool
became un-assignable in Bambuddy even when it occupied no slot.
get_unlinked_spools now determines assignability from the
spoolman_slot_assignments table (the documented source of truth for
slot assignments) and ignores extra.tag entirely. Both link_spool and
the AMS auto-sync upsert a row there for every occupied slot, so the
ledger is complete. get_linked_spools and find_spool_by_tag still use
extra.tag -- they are genuine tag-match maps and are unaffected.
Internal-inventory mode needs no parallel change: it stores tags in
its own DB with no Spoolman extra collision.
Updates test_get_unlinked_spools_success and adds
test_get_unlinked_spools_excludes_slot_assigned.
notify_missing_spool_assignments_on_print_start queried only the legacy
SpoolAssignment table. In Spoolman mode that table is empty -- bindings
live in spoolman_slot_assignments -- so every used tray was flagged
missing, firing a false-positive notification on every print.
- spool_assignment_notifications.py: the assigned-tray set is now the
union of SpoolAssignment + SpoolmanSlotAssignment rows. Union-only,
so legacy-mode behavior cannot regress.
- settings.py: the Spoolman toggle cleared SpoolAssignment on switch-on
but never cleared SpoolmanSlotAssignment on switch-off. Added the
symmetric clear so stale Spoolman rows can't leak into a later
internal-mode session and mask a real missing-assignment warning.
Adds 3 notification tests + 1 mode-switch integration test. An audit
of the remaining SpoolAssignment consumers confirmed usage_tracker,
spool_tag_matcher and routes/inventory are correctly internal-mode-only.
notify_missing_spool_assignments_on_print_start queried only the legacy
SpoolAssignment table. In Spoolman mode that table is empty -- bindings
live in spoolman_slot_assignments -- so assigned_global_trays came back
empty and every used tray was flagged missing, firing a false-positive
notification on every print.
Union SpoolAssignment + SpoolmanSlotAssignment rows for the printer
before computing the missing set. Both tables expose printer_id /
ams_id / tray_id identically, so _global_tray_from_assignment is
unchanged. Union-only, so legacy-mode behavior cannot regress.
The search bar was rendered only when totalCount > 0, but totalCount is
the sum of the post-filter preset lists. A query that matched nothing
dropped it to 0 and unmounted the search bar along with the columns,
so the user couldn't clear the query without a page refresh.
Gate the search bar on hasAnyPresets (pre-filter count) instead, so it
stays visible as long as any preset is imported. Split the empty state:
"No local presets yet" when nothing is imported, vs a new "No presets
match your search" message when a query filtered everything out.
The Status panel's Low / High thresholds readout was stuck at 0.38 /
0.78 regardless of the Sensitivity dropdown, so the setting looked
dead. Detection itself was correct -- classify() always used the real
sensitivity -- but get_status() computed the displayed thresholds with
a hardcoded thresholds("medium").
get_status() now takes an optional sensitivity argument and the
/obico/status route passes settings["sensitivity"] (it already loads
settings fresh). The readout updates as soon as the change is saved.
Reporter's H2C connected over MQTT+TLS but every status field stayed
unknown. Root cause was layer-8: the MQTT broker is the printer, it
authenticates on the access code and SUBACKs any topic string, so a
wrong or mis-cased serial connects fine and silently receives nothing
(the report topic device/<serial>/report is case-sensitive; Bambu
serials are uppercase). Bambuddy stored and used the serial verbatim.
- schemas/printer.py: field_validator strip()+upper()s serial_number on
create, rejects blank-after-strip. The subscribed topic now always
matches the printer's correctly-cased one.
- bambu_mqtt.py: count report-topic messages per connection; when a
stale reconnect fires with zero reports received, log a one-shot
hint pointing at the serial number instead of looping silently.
Reporter on X2D set a 1h AMS dry; the printer powered off seconds in.
Support log: every "Sent drying command duration=1" was followed 3-9s
later by "AMS 0 drying complete (dry_time 60 -> 0)" -- the completion
callback fired right after drying started, arming smart-plug auto-off.
Root cause: the tray-bearing branch of the AMS partial-update merge
rebuilt the unit as {**ams_unit, "tray": merged_trays}, never spreading
existing_unit. Tray-bearing partials carry no drying fields, so dry_time
(and info) was dropped; the falling-edge detector read the absent field
as 0 and saw a false 60->0 edge.
- Merge: tray branch now spreads existing_unit first, preserving
dry_time / info / humidity / temp across tray-only partials. Matches
the no-tray branch. Also fixes dry_status/dry_sub_status UI flapping.
- Detector: only evaluate the falling edge when dry_time is explicitly
present and parseable; skip otherwise without updating state.
#1447 added computePopoverPosition() to flip the drying popover above
the trigger when below would overflow. Its degraded branch (popover
taller than the viewport) keeps the popover below and its comment
promised "the user can scroll inside it" -- but the popover div was
overflow-hidden with no max-height, so the Start button stayed
unreachable on short viewports.
Make the promise true: the popover is now a flex column capped at
calc(100vh - top - 8px); the body scrolls (overflow-y-auto) and the
header/footer are shrink-0, so the Start button is pinned and always
reachable. Covers the MacBook + iPhone 15 Pro Max PWA cases in #1458.
security.yml had this ignore added in 9d440beb but ci.yml runs its
own pip-audit step with a separate ignore list. CI was still failing
on main + dev. Reasoning identical to the security.yml comment —
disputed by PyJWT maintainers, no fix exists, Bambuddy uses
secrets.token_urlsafe(64) and rejects short secrets.
- requirements.txt: pin idna>=3.15 to clear ReDoS in idna.encode() on
crafted Unicode payloads. Transitive via anyio/httpx/requests/yarl,
so the explicit floor stops a future downstream loosening from
silently downgrading us.
- security.yml: permanently --ignore-vuln CVE-2025-45768 (PyJWT). The
advisory is disputed by the maintainers — "key length is chosen by
the application" — and no fix version exists. Bambuddy is safe:
auto-generates secrets via secrets.token_urlsafe(64) and rejects
file-loaded secrets shorter than 32 chars (auth.py:177, :184).
- security.yml: drop the stale Pygments --ignore-vuln CVE-2026-4539.
Pygments has been patched upstream; the ignore no longer matches
anything.
Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
3MF for a different printer" assumption: feeding an 18-color H2D-bound
Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
and bed_exclude_area all coming from the target bundle.
- SliceModal: drop !printerMismatch from isReady; remove the banner and
the sourcePrinterModel / printerProfileName / printerMismatch state
entirely. Cross-printer slicing is now indistinguishable from a normal
slice; the picker already shows the target printer.
- Remove slice.printerMismatch from all 8 locales.
- API cleanup: drop source_printer_model from /library/files/{id}/plates
and /archives/{id}/plates responses, drop the field from
frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
delete extract_source_printer_model_from_3mf from threemf_tools.py and
its 6 unit tests. Zero remaining consumers.
- i18n discipline cleanup in SliceModal.tsx (same drop): strip every
inline English defaultValue / positional fallback from t() calls (22
sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
to all 8 locales — they had no entry in any locale file and were being
served from the inline English fallback for every non-English user.
- Tests: rewrite the mismatch-warning test to assert "no banner, Slice
enabled" when models differ (regression guard); delete 2 obsolete
tests covering gate states that no longer exist.
Adds BZD with symbol BZ$ to the Settings cost-currency picker so
users in Belize can track filament costs in their local currency
without doing 2:1 USD mental conversions.
Reporter on Postgres + Spoolman saw weight never decremented after
prints. Traced to _report_spool_usage_for_slots calling only
client.find_spool_by_tag() — which returns None when extra.tag is empty.
Non-RFID spools assigned via the Bambuddy UI intentionally leave
extra.tag empty (per #1457 — we don't want fallback tags polluting
Spoolman), so tag-less spools never got matched and weight tracking
silently no-op'd. The tracker never consulted the local
spoolman_slot_assignments table that has the binding.
Adds _resolve_spool_id_via_slot_assignment() as stage 2 of the
resolution chain. Stage 1 (existing tag-lookup) wins when present so
RFID auto-sync remains unchanged. (ams_id, tray_id) derived from
global_tray_id via the existing _global_tray_id_to_ams_slot helper,
so external slots and AMS-HT slots resolve correctly. Threaded
printer_id through the three callers (partial G-code, partial linear,
final-usage report). Resolution path is logged ("via tag" vs "via
slot-assignment") so support bundles confirm the fix is live.
extra.tag is deliberately NOT auto-populated — that would re-introduce
the exact pollution #1457 cleaned up. Slot-assignment table is the
source of truth for non-RFID; extra.tag is reserved for hardware RFID.
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
correctly showed the freshly assigned full spool. Two layers compounded:
(1) Non-RFID slots fall back to a deterministic per-slot tag
(hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
that tag to Spoolman extra.tag but never cleared it from the
previous holder on re-binding.
(2) The frontend's hover-card resolver preferred the (stale) tag-link
over the user's explicit slot-assignment. Same precedence bug in
SpoolBuddy's fill-bar resolver and slot-action picker.
Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
everywhere. FilamentHoverCard's existing match-dedupe then collapses the
two "Open in Inventory" buttons back into one.
Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
from POST /spoolman/inventory/slot-assignments (with the slot's
deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
the literal tag being bound — works for RFID and fallback). Best-effort:
Spoolman 5xx and per-spool patch failures log + continue, never wedge
the bind. get_fallback_spool_tag_for_slot promoted to a public helper
mirroring the frontend's signature exactly.
Two bugs in one report, both shipped here.
(1) Popover positioning. The flame-icon onClick on PrintersPage
computed popover position as a fixed { top: rect.bottom + 4,
left: Math.max(8, rect.right - 240) } with no viewport-overflow
check. The flame icon sits at the bottom of the AMS info section
on the printer card, so on most realistic viewports
rect.bottom + 4 + popover_height (~320px) overruns viewport.height
and the popover renders partially or entirely off-screen with the
Start button unreachable. Reporter worked around it via DevTools to
confirm the popover was actually there, just clipped.
Extract a computePopoverPosition() helper in utils/popoverPosition.ts:
- defaults to below + right-aligned to the trigger (preserves the
original visual layout when there's room),
- flips ABOVE the trigger when below would overflow AND above fits,
- stays below in the degraded case (popover taller than viewport) —
at least the top is visible and the user can scroll inside; flipping
to a top-clipped position would lose the action buttons too,
- clamps the left coordinate so a trigger near either viewport edge
can't push the popover off-screen horizontally either.
Both PrintersPage callsites (compact AMS row at :3498 and dual-nozzle
layout at :4011) route through the helper.
(2) Diagnostic logging for the silent-drying-ignore. Reporter's
support bundle shows the printer receives every ams_filament_drying
command (P1S 01.10.00.00 firmware, AMS-HT at ams_id=128) and ACKs
each one, but the AMS info field never changes — drying neither
starts nor stops on Bambuddy's request, while pressing Start on the
printer's touchscreen works immediately. The command JSON matches
the format documented as working on H2D, all required fields present.
Diagnosing the silent rejection needs the printer's actual response
payload — result/reason — but bambu_mqtt.py:918 was only logging the
response command name, not the body. The existing extrusion_cali_* /
ams_filament_setting debug path at :919-920 was the template; this
PR extends it to ams_filament_drying at INFO level (not DEBUG like
its siblings) because drying responses are rare (user-initiated only)
and INFO ensures the body lands in support bundles by default without
the user having to bump log level first. Paired with an outgoing-side
INFO log inside send_drying_command that captures the full wire JSON,
so the next bundle has both halves of the conversation.
No guessing on the command-side. Mutating a field that matches the
documented-working H2D shape (e.g. flipping close_power_conflict)
could break currently-working installs. When the reporter retries on
this build and re-attaches a bundle, the rejection reason is visible
and the command-side fix follows from real data.
The flame-icon onClick on PrintersPage computed popover position as a
fixed { top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }
with no viewport-overflow check. The flame icon sits at the bottom of
the AMS info section on the printer card, so on most realistic viewports
rect.bottom + 4 + popover_height (~320px) overruns viewport.height and
the popover renders partially or entirely off-screen with the Start
button unreachable. Reporter (kleinweby, P1S + AMS-HT) worked around it
via DevTools to confirm the popover was actually there, just clipped.
Extract a computePopoverPosition() helper in utils/popoverPosition.ts:
- defaults to below + right-aligned to the trigger (preserves the
original visual layout when there's room),
- flips ABOVE the trigger when below would overflow AND above fits,
- stays below in the degraded case (popover taller than the viewport) —
at least the top is visible and the user can scroll inside the
popover; flipping to a top-clipped position would lose the action
buttons too,
- clamps the left coordinate so a trigger near either viewport edge
can't push the popover off-screen horizontally either.
Both PrintersPage callsites (the compact AMS row at :3498 and the
dual-nozzle layout at :4011) route through the helper.
This is part 1 of #1447. The functional drying bug — printer receives
the ams_filament_drying MQTT command, ACKs it, but never starts/stops
drying on Bambuddy's request while the printer's own touchscreen works
— is NOT addressed here. Diagnosing it needs the printer's actual
response payload (whether result: "fail" and the specific reason code),
which bambu_mqtt.py:918 currently doesn't log for ams_filament_drying.
Punted to a follow-up where I'll extend the existing extrusion_cali_* /
ams_filament_setting payload-logging path at :919-920 to cover
ams_filament_drying too, ask the reporter to retry, and fix the
command side based on what the printer actually returns.
PrintCalendar.tsx had three instances of the same UTC-shortcut anti-pattern:
1. Bucketing input dates via `date.split('T')[0]` — gives the UTC day
while the cell tooltip rendered local via `toLocaleDateString`. Same
data, two renderers, only one was tz-correct. Reporter on CDT (UTC-5)
saw evening prints jump to "tomorrow's" cell.
2. Per-cell lookup key built via `day.toISOString().split('T')[0]`. The
`day` Date objects produced by the calendar-generation loop are
local-tz (constructed via `new Date()` + `setDate`), so `toISOString`
shifted them back to UTC before the lookup — would have re-broken the
join even after the bucketing fix.
3. "Today" ring comparison used `new Date().toISOString().split('T')[0]`
too — at 23:00 local the ring would have moved to UTC-tomorrow's cell.
Fix adds a `localDateKey(input: string | Date): string` helper to
utils/date.ts that wraps parseUTCDate and formats via the local-tz
getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding),
returning a stable comparable YYYY-MM-DD. PrintCalendar.tsx uses it in
all three spots so the buckets, the cell join, and the today ring share
the same local-tz axis as the user's tooltip label.
Backend stays UTC. Bucketing is a presentation concern and the browser
already knows the user's tz.
Stragglers flagged for follow-up: StatsPage.computeDateRange builds
dateFrom/dateTo for backend stats queries using getUTC* getters, so a
"this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based
ranges to the backend. Fixing it properly also needs the backend to
filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting
model today. localDateKey is in place for reuse when that work lands.
The pre-insert MQTT probe added in 0.2.4.2 (b51598ea) had two bugs that
compounded on P1S firmware specifically:
1. Fixed 2-second sleep was too short. P1S broker + TLS handshake
routinely needs 3-5s to surface CONNACK on a cold MQTT session (same
firmware family with the documented "broker stops publishing but TCP
stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected
a printer that would have connected fine. H2C's broker is snappier and
cleared the 2s window without trouble — which is why the reporter's
H2C added without issue and only the P1S misbehaved.
2. client.disconnect() ran synchronously on the asyncio thread.
BambuMQTTClient.disconnect() ends in paho's loop_stop() which joins
the network thread; if that thread was still mid-TLS-handshake to the
slow P1S socket when teardown ran, the join blocked the asyncio thread
for as long as the handshake took to complete or fail. POST /printers
wedged, every other HTTP request queued behind it, Docker healthcheck
timed out — user-visible symptom: "the container hangs."
Fix:
- Replace the fixed sleep with a polling loop (8s budget, 200ms tick,
early-returns the moment state.connected flips True). Slow brokers
get the headroom they need; happy-path connects still finish in
~1-2s. Constants exposed as PROBE_TIMEOUT_SECONDS / PROBE_POLL_
INTERVAL_SECONDS class attributes so tests can dial them down.
- Move client.disconnect() to await asyncio.to_thread(...) so paho's
thread-join can never block the event loop.
The empty-card-report-prevention goal of the original probe stays
intact: a genuinely wrong access code still results in connected=False
after the 8s budget, the 400 with code=printer_connection_failed
still fires, the row is still never persisted.
PrintLogEntry.failure_reason is captured once at print-completion time
(main.py:3641) by copying archive.failure_reason — which is NULL while
the user hasn't classified the failure yet. The PATCH /archives/{id}
route then writes only to print_archives via a generic setattr loop,
so the log entry stays NULL and failure_analysis.py keeps grouping the
print as "Unknown". Same desync hits status — flipping it in the modal
never reached the entry either.
Mirror failure_reason and status from the PATCH payload to the latest
PrintLogEntry for that archive (highest id). Latest-only because
archive.failure_reason / status already reflect the latest run's outcome
(each reprint clears the archive value at main.py:2195 and rewrites it
at completion), so the Edit Archive modal is implicitly editing the
latest run — reprints of an archive that succeeded on the second attempt
keep the earlier failed run's original classification intact.
Scoped to those two fields only. cost / print_name / printer_id stay
unmirrored because per-run values legitimately diverge from archive
ones (partial-print cost on a failed run vs source archive's full-print
cost — see _compute_run_filament_grams at main.py:596).
Two bugs surfaced by @flom89 in the same thread:
(1) The Write-Tag page hardcoded api.getSpools and friends regardless of
inventory backend. Users in Spoolman mode saw internal spools they never
created and a successful tag write would have bound the NFC tag to the
wrong backend (the backend write-tag route is mode-aware; the frontend
was driving it with the wrong IDs). Fix follows the InventoryPageRouter
pattern: detect spoolmanMode from getSpoolmanSettings, gate the spool
fetch on spoolmanModeReady to avoid the initial wrong-backend request,
and branch all 6 API call sites (list, autocomplete, untag, K-profile
save, single create, bulk create — the bulk variant returns a different
envelope shape so the duck-typed check from SpoolFormModal is mirrored).
(2) The spool ID was missing from three more SpoolBuddy components
beyond the first round of #1439 work — SpoolInfoCard (the dashboard's
right-side found-tag panel — the "main screen" view the reporter named),
InventorySpoolInfoCard, and SpoolBuddyAmsPage's assigned-spool block.
Same #1385 pattern (#<id> in muted small monospace with shrink-0).
Python 3.13 negotiates TLS 1.3 by default. The P2S firmware 01.02.00.00
vsFTPd build doesn't tolerate TLS 1.3's async session-ticket model on
the FTPS data channel — session resumption races, the data channel gets
torn down mid-stream, uploads land truncated at a chunk boundary, and
the printer replies 426 instead of 226. Visible to the user as "unable
to parse 3mf file" 30 s into the print.
Capping the SSL context's maximum_version to TLS 1.2 makes session
resumption synchronous and uploads complete normally.
Follow the per-model pattern established by camera_profiles.py in the
#1395 follow-up: add backend/app/services/ftp_profiles.py with a frozen
FTPProfile dataclass and a per-model registry. Only P2S (display name
+ N7 SSDP code) gets the cap today. X1C, H2D, P1S, A1 stay on negotiated
TLS 1.3 — the maintainer's dogfooded printers see zero behaviour change.
The browser-side 3MF parser in ModelViewer.tsx runs entirely on the main
thread (JSZip extract + DOMParser + vertex/triangle iteration), and Bambu's
external-component shape chains one of these per part. Multi-color parted
statues from MakerWorld can spend tens of seconds in straight-line JS,
during which the modal close button can't fire and the browser shows
"page unresponsive".
Add nextTick() yields at four hot spots:
- every 20 000 vertex iterations inside parseMeshFromDoc
- every 20 000 triangle iterations inside parseMeshFromDoc
- the matching loops in parse3MF's direct-mesh path
- once per top-level <object> iteration in parse3MF
- once per <component> iteration in parse3MF
This doesn't make parsing faster — it surrenders control to the browser
between batches so the modal stays interactive. Proper Web Worker refactor
is a tracked follow-up.
The snapshot-diff strategy in _scan_for_timelapse_with_retries needs
_timelapse_baselines[printer_id] populated at print start so the
completion-time scan can find the new MP4 by set-difference (mtime is
unreliable — LAN-only printers don't sync NTP).
The baseline-capture call was only in on_print_start's new-archive branch.
Queue / VP-dispatched / reprinted jobs take the expected-archive branch
which returns earlier, so the dict stayed empty and the completion-time
scan fell into the "take baseline now" fallback that snapshots after the
new file has already landed — no diff ever matches.
Extract the snapshot into _capture_timelapse_baseline_at_start and call
it from both branches.
PR #1434 CI flagged 5 B402 (ftplib import) in test_bambu_ftp.py and 2
B108 (hardcoded /tmp) in test_print_start_assigns_printer_id_to_vp_archive.py.
Both are intentional in tests: the FTP client tests need real ftplib
exception classes to construct mock 426 responses, and the /tmp path is
a MagicMock attribute never written to. Marked with `# nosec B402` /
`# nosec B108` plus a one-line justification each, matching the
convention from c2630399.
Reporter — the same person who originally requested the labels
feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
dimension didn't actually fit any variant of the MakerWorld AMS
Filament Label Holder (model 752566) it advertised. Two new
presets replace it:
- ams_holder_74x33 (74 x 33 mm) matches the printable label STL
bundled in the MakerWorld project
- ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
the reporter validated on bench
Both cross the 20 mm height threshold so they land in the roomy
layout branch — swatch on the left, QR on the right, multi-line
text (brand, material, hex code, spool ID) in the middle. The
old 30x15 mm preset couldn't fit a QR code; the new ones do.
No DB migration: the preset name was never persisted. Callers
scripting the old ams_30x15 value get a clean 422 at the route's
Literal validator with the new valid values listed.
i18n: replaced inventory.labels.templates.ams.{label,hint} with
amsHolderSmall and amsHolderLarge across all 8 locales with real
translations; parity guard cleaned of the stale English-fallback
cognate entries. Parity holds at 4856 leaves per locale.
Tests: backend label renderer + integration tests cover both new
presets; LabelTemplatePickerModal test updated for the 6-button
grid and the new template value in the API-call assertion.
VP-queue archives are created with printer_id=None at queue-add
time because the scheduler hasn't picked a printer yet (and even
for explicit-printer queue items, the archive predates dispatch).
on_print_start's expected-archive branch updated status,
started_at, and subtask_id but never assigned printer_id, so
VP-queue-dispatched archives stayed permanently unassigned.
That broke every UI/API path gated on archive.printer_id —
critically the post-print "Scan for timelapse" action: the
H.264 file is on the printer's SD card and reachable via the
file browser, but the archive's scan endpoint refused the request
and the button stayed greyed out forever.
One-line fix: archive.printer_id = printer_id in the
expected-archive branch. Guarded against clobbering an
already-correct value so library-file queue items (which create
their archive with the printer pre-assigned) are idempotent.
Reporter on a Raspberry Pi 5 couldn't read NFC tags — gauge worked,
SPI bus and wiring fine, but PN5180 transfers didn't complete.
Manually commenting out `self._spi.no_cs = True` restored
communication. Root cause: Pi 5's RP1 southbridge SPI driver
(spi-rp1) doesn't honour the SPI_NO_CS ioctl the way the historical
Broadcom driver on Pi 4 did.
Safe to relax Pi-wide. SpoolBuddy's PN5180 NSS line is wired to
GPIO23 (manual CS in _cs_low / _cs_high — the kernel's default
auto-CS timing doesn't meet the PN5180's 5µs setup / 100µs hold
spec). The hardware CE0 line (GPIO8) is not connected to the
reader, so whether the kernel auto-toggles it is electrically
invisible. The no_cs = True call was always cosmetic on this
hardware.
Wraps the assignment in try/except OSError in both the daemon and
the diagnostic script; the daemon logs at debug level so future
Pi-5-specific triage is greppable. README updated to drop the
"spidev.no_cs = True resolves this" sentence and explain the
manual GPIO23 CS scheme carries the timing on its own.
Cover endpoint had no negative cache: when every FTP path returned
550 for a print whose 3MF wasn't on the printer (typical SD-card
print), each frontend refresh re-ran the full 8-path fan-out. Add
_cover_404_cache keyed by (subtask_name, view_key) and short-circuit
to 404 on hit; clear alongside _cover_cache on print start. Only
populated on genuine 404 paths, not transient FTP errors, so flaky
network doesn't lock out future retries.
GitHub update-check had no backoff on 403 rate-limit. Add module-
level _github_rate_limit_until plus three helpers; check before
every api.github.com call in /updates/check and
_discover_target_release. Read X-RateLimit-Reset from the 403 with a
1-hour fallback when the header is absent and a 60-second floor to
guard against container/GitHub clock skew. Route surfaces
retry_after_seconds so the UI can display real wait time.
The "ffmpeg didn't terminate gracefully" line the reporter quoted
is the standard SIGTERM/SIGKILL pattern in camera.py and unrelated
to the FTP loop; it goes away on its own once the cover endpoint
stops hammering the printer.
Reporter (@snozzlebert on A1 mini external slot) saw the
Filament page Location column update correctly after Assign
Spool, but the Printer card kept showing "Empty slot" until
they manually pressed Force-refresh. MQTT command was going
through fine; gap was client-side.
AssignSpoolModal's two onSuccess callbacks invalidated the
inventory / slot-assignment queries but never invalidated
['printerStatus', printerId] and never issued a pushall. For
Bambu RFID-tagged spools the printer echoes the new tray_type
on its own; for non-RFID spools and A1 mini external slots
the firmware doesn't volunteer that state change.
Added nudgePrinterRepublish() helper called from both onSuccess
paths: api.refreshPrinterStatus(printerId) to issue the pushall
(same call the Force-refresh button uses) plus invalidate
printerStatus so the refetch lands. Refresh failures are
swallowed — the assignment itself succeeded; a stale-cache
nudge that didn't go through shouldn't surface as "assign
failed". Same pattern as ConfigureAmsSlotModal since #1235,
with the extra pushall because assign-spool affects firmware-
side state.
Previous daily build (1fac0276) tightened the post-STOR voidresp
handler to fail on any ftplib.Error, stopping Bambuddy from
sending a print command for a truncated 3MF. Reporter
(@enjoylifenow on a P2S) then confirmed — after a clean SD-card
filesystem check, reformat, and power cycle — that v0.2.4.1
worked on the same hardware. That proves the 426 returned by
this firmware revision is noise: the TLS data-channel close
races the 226 confirmation, server reports failure, file is in
fact on the SD card.
Reverting wholesale would re-introduce the silent-truncation
bug from the original fix. Narrow the rule instead: after an
ftplib.Error from voidresp, run an FTP SIZE against the upload
path. SIZE matches the local file size → warn and proceed
(the reporter's case). SIZE mismatch, or SIZE itself raises →
fail loudly with full diagnostics (the original tightened
behavior — preserved).
Applied identically to upload_file() and upload_bytes() so the
A1-compatibility manual-transfer path is covered.
Tests: two regressions from the previous round renamed and
split into intact / truncated / size-check-fails. Intact-file
tests inject SIZE explicitly because pyftpdlib only flushes on
a clean voidresp — which can't happen when we monkeypatch
voidresp to raise. Docstring spells that out. 87 FTP unit tests
green; 118 FTP-touching tests across unit+integration green;
ruff clean.
The View-Timelapse-greyed-out behavior #1417 was originally
about stays untouched; once the reporter confirms upload
reliability is back, that diagnosis continues on a healthy
install.
Two-part fix for the #1322 follow-up by @RosdasHH.
Data layer.
The previous narrow heuristic in printer_manager.py only caught
the bare {"id": N} payload firmware sends right after a printer
restart. In steady-state operation — and on the more common
post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
populated payload and signals emptiness via the tray_exist_bits
bitmask. We already parse that bitmask and use it to wipe stale
tray_type / tray_color / tag_uid fields, but never touched the
state field, so downstream readers (printers.py API serializer,
inventory.py's tray_state in {9, 10} short-circuit, AMS card)
saw state: null and had to guess from absent payload fields.
Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
uses == not `in {...}` so a string would silently miss and the
reporter's deadlock would come back) to the outer `if not
slot_exists` branch, so the bitmask path now writes the
canonical "no spool" code for every empty slot regardless of
stale fields. The narrow heuristic in printer_manager.py:797
stays as belt-and-suspenders for any MQTT path that doesn't
flow through _handle_ams_data.
UI layer.
With the data flow now consistent, the AMS slot card renders
physically-empty slots distinctly from reset slots, per
reporter's mockup. New helper getEmptySlotKind(tray) returns
"physical" (state ∈ {9, 10}), "reset" (any other empty state),
or null (loaded). The inline label below the slot circle reads
"Empty" for physical and "Reset" for reset; pre-fix both showed
an em-dash. FilamentSlotCircle gains an emptyKind prop that
picks a quieter dashed border colour for reset slots so the
visual hierarchy reads loaded > reset > physically empty.
EmptySlotHoverCard gains a kind prop and switches between
"Empty slot" and "Slot reset — no spool assigned".
Three independent root causes behind the divergences the reporter
flagged after the archived-spool fix shipped — fixed together.
(1) Filament Used vs Total Consumed.
_compute_run_filament_grams returned the slicer estimate for completed
prints even when inventory had measured the actual AMS weight delta.
That made Stats and Inventory two different sources of truth: Stats
showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
the two never agreed. Reordered the helper so the tracked spool delta
(same source that drives weight_used behind Total Consumed) takes
priority for every status. Slicer estimate stays as the fallback when
no inventory was tracked; partial-progress scale stays as the fallback
for failed/cancelled with no tracker. The _run_cost block right next
to it was already tracker-first; only filament_used_grams was
inconsistent.
(2) Printer Stats By Time vs Quick Stats Print Time.
/archives/slim only set actual_time_seconds when status == "completed".
For failed/cancelled rows the frontend fell back to print_time_seconds
(the slicer's full-print estimate — wrong number for a print that
failed at 15%). Quick Stats already summed elapsed duration across
all statuses, so the two halves of the page disagreed by the
(estimate - actual-elapsed) gap on every non-completed event. Dropped
the completed-only gate; failed/cancelled now report measured elapsed.
(3) Success Rate %.
Was successful / (successful + failed), excluding cancelled / stopped
from the denominator. With "Total Prints: N" displayed right above
the gauge that produced confusing numbers — 4 successful, 0 failed,
48 cancelled showed 100% out of an apparent 52 prints. Switched to
successful / total_prints — matches the count the user reads from
the widget header.
Reporter (@IndividualGhost1905) saw archived spools' consumption
silently drop out of the Total Consumed running total — un-archiving
put it back. The stat is lifetime-since-reset, not currently-
available, so archived consumption belongs in it.
InventoryPage.tsx stats loop split: totalConsumed sums over all
spools (active + archived); totalWeight, lowStock, byMaterial,
activeCount keep their archived-skip.
Also fixes two adjacent UX gaps the reporter surfaced:
- Per-spool eraser button was gated on !archived_at && weight_used
> 0. Dropped the archived gate so an archived spool's tracking
counter can be zeroed without un-archiving first.
- activeSpoolIds (target of "Reset all usage" bulk action) excluded
archived. Renamed to resetableSpoolIds and broadened to include
archived so Reset-all genuinely zeroes the now-broader stat in
one click. Backend reset endpoints already accept archived IDs.
Reporter asked for an option to order printed label sheets by colour
instead of spool number so multi-colour rolls group related colours
together physically on the sheet.
Backend (labels.py) already preserves caller order, so this is
frontend-only. LabelTemplatePickerModal gains a "Sort: By ID / By
colour" chip pair next to the material filter. Colour mode converts
each spool's rgba to HSL: chromatic colours (s >= 0.1) cluster in
bucket 0 ordered by hue 0..360, achromatic colours go in bucket 1
ordered by lightness so neutrals trail the rainbow black -> white.
Stable tiebreaker on spool ID.
Also fixes a latent issue exposed by the same code: the submit was
always re-sorting selected IDs ascending, which would have clobbered
any frontend order. Submit now uses sortedSpools.filter().map() so
the visible order flows through to the PDF.
Session-only state; toggle resets to "By ID" each time the modal
opens. 3 new i18n keys translated across all 8 locales (parity 4852
leaves). 2 new modal tests pin the colour-sort payload order and
the unchanged ID-default. 17 modal tests + i18n parity + build all
green.