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.
The kiosk's Settings -> Update Daemon button returned "API keys cannot
be used for administrative operations" because POST /spoolbuddy/devices/
{id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
Every kiosk-side request tripped the deny-list before the API key's
scope set (Read / Print Queue / Control / Legacy) was even consulted.
Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
(Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
in that audit on the reasoning "replaces the daemon binary, different
threat surface" — but that's wrong: restart_daemon already replaces
the running daemon process, so daemon-replacement is not a step up in
blast radius. The SSH update is also strictly scoped to the one device
the operator physically controls (git fetch + pip install + systemctl
restart on that host) — same threat profile as the system commands
already running on INVENTORY_UPDATE.
Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
(calibration/tare, display, cancel-write, system/command,
system/command-result, update-status). The main Bambuddy in-app updater
at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
the Bambuddy host and is correctly fenced behind the deny-list.
Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display
didn't change actual blanking behaviour: whatever value was active when
the kiosk last booted continued to fire. A user who started with the
10m preset and switched to 1m or "Off" still saw the screen blank at 10m.
Cause: blanking is driven by swayidle, started once by spoolbuddy-idle.sh
at labwc autostart with the timeout passed as a command-line argument.
The script fetched blank_timeout from the backend exactly once at startup
and swayidle has no runtime control surface for changing its timeout.
The daemon's display.set_blank_timeout() updated an in-memory variable
that was never reached by swayidle, so UI changes were silently discarded
until the next kiosk restart.
Fix: extend the wake FIFO protocol with a "reload-timeout N" message.
The daemon writes it whenever set_blank_timeout() sees a value that
differs from the current one (the very first call is suppressed because
the watchdog already fetched the same value at its own startup —
signalling there would just thrash swayidle on every cold boot). The
script's FIFO loop is restructured around start_swayidle / stop_swayidle
helpers and a case statement: wake → wlopm --on + arm a re-blank at the
current timeout; reload-timeout N → kill running swayidle, set TIMEOUT=N,
restart swayidle, wlopm --on so the user sees the change took effect
even if the screen was already blanked. The script de-dupes too — a
reload-timeout whose N matches the current value is a no-op. Going from
any positive timeout to 0 ("Off") stops swayidle and doesn't restart
it; going from 0 to a positive value starts a fresh swayidle. Both work
without a kiosk restart.
The script's main loop opens the FIFO read+write (exec 3<>"$WAKE_FIFO")
so bash read never sees EOF when the daemon momentarily disconnects
between writes. A cleanup trap on TERM/INT/HUP stops swayidle, removes
the FIFO, and exits cleanly.
fix(spoolbuddy): gate display wake on stable scale reading
A noisy load cell that bounced ≥50 g around its midpoint kept the kiosk
screen permanently lit. The wake threshold check ran against last_wake_grams
which itself advanced to noisy values, so every bounce back across 50 g
re-fired display.wake(), keeping the FIFO reader pumping wlopm --on
faster than swayidle could trigger wlopm --off.
The fix gates wake on the scale's `stable` flag — only readings that
have settled within 2 g over a 1 s window count as a real spool
placement / removal. Unstable noise can't fire wake and can't advance
last_wake_grams, so the next genuine settled change is still measured
against the right baseline.
Three regression tests pin the contract: noisy ±60 g unstable readings
never wake, a settled >50 g jump wakes, a noise burst between two
settled readings doesn't poison last_wake_grams.
Bambuddy's SSH keypair under <DATA_DIR>/spoolbuddy/ssh/ regenerates whenever
the data dir is recreated (volume remount, container recreate, fresh deploy).
The daemon previously only fetched the pubkey at registration, so any
rotation after a successful boot left ~/.ssh/authorized_keys pointing at
a stale public half — every Update click then failed with "Connection
closed by authenticating user spoolbuddy [preauth]" until the daemon was
restarted by hand. Each prior registration also appended a fresh entry
without pruning, accumulating stale Bambuddy-tagged keys indefinitely.
- HeartbeatResponse now carries ssh_public_key; the heartbeat route reads
it via the same try/except shape as the register route so a missing or
unreadable backend key doesn't break telemetry.
- _deploy_ssh_key() strips lines tagged bambuddy-spoolbuddy and writes
the current key once. No-op when already in sync (no mtime churn on
every heartbeat). User-managed entries are preserved.
- Daemon heartbeat handler calls _deploy_ssh_key when the response
carries a key, so rotations propagate within one heartbeat instead
of requiring a service restart.
Tests: 5 unit (creates-when-missing, replace-stale-pileup, preserve-user-keys,
idempotent, swallows-write-errors) + 2 backend integration (heartbeat carries
the key; backend key-read failure leaves ssh_public_key None but the
heartbeat still 200s).
Reproduced live during the #1133 rollout: the SpoolBuddy display kept
serving the pre-fix picker for hours after every cache-clear,
chromium-restart, and pkill attempt because a chain of stale state
across HTTP cache + Service Worker + persistent profile prevented
fresh code from reaching the running tab.
Three independent changes — any one of them sufficient on a clean
profile, but all three needed to escape an already-corrupted one:
(1) backend/app/main.py — index.html now served with
Cache-Control: no-cache, must-revalidate on both / and the SPA
catch-all. Vite emits content-hashed JS/CSS bundle filenames so the
assets themselves are safe to cache forever, but the HTML wrapping
them is the only file that knows which hash is current. Without
explicit cache directives Chromium falls back to heuristic caching
(typically 10% of time since Last-Modified) and on long-running
kiosks happily serves stale HTML across browser restarts. That stale
HTML references an old bundle hash which is also still in disk
cache, so the kiosk runs pre-deploy JS forever without ever knowing
why.
(2) frontend/public/sw.js — CACHE_NAME bumped from bambuddy-v25 to
bambuddy-v26 so any client that fetches the new sw.js drops its old
CacheStorage. The SW does network-first for HTML/JS/CSS but
intercepts and falls back to cache, and cache-control on HTTP
responses doesn't reach into the SW's own cache layer.
(3) spoolbuddy/install/install.sh — generated kiosk launcher now uses
--user-data-dir=/tmp/spoolbuddy-kiosk-userdata with a pre-launch
rm -rf, so every kiosk restart starts from a clean slate (no HTTP
cache, no SW registration, no IndexedDB). Trade-off is a slightly
slower first paint and zero offline support; neither matters for a
single-purpose kiosk facing a backend on the same LAN, and the
guarantee that next-deploy-just-works is worth far more.
4 new tests in test_static_html_cache_headers.py: index.html on /
and SPA catch-all paths emit Cache-Control: no-cache,
must-revalidate; API routes are unaffected (no leak of HTML cache
directive onto endpoints we want React Query to cache aggressively).
For existing kiosks already trapped by an old persistent profile,
operator runs once: rm -rf ~/.config/chromium && systemctl restart
getty@tty1.service. The new launcher then picks up automatically.
Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
archive-preview tool, matching Bambuddy's data model instead of the
OctoPrint-style "connected-printer + library file picker" flow it shipped
with. Reached only from the Archives page 3D-preview button; URL
/gcode-viewer?archive=<id>[&plate=<N>].
Backend:
- /archives/{id}/gcode accepts ?plate=N and resolves the filename by
parsing the suffix as int, so zero-padded names like plate_01.gcode
are found when the plates endpoint reports index 1.
- /archives/{id}/plates gains top-level has_gcode: bool. Source-only
3MFs (PNG/JSON fallback path) surface the flag so the frontend can
skip the picker instead of sending the user into a dead viewer.
- printer_state_to_dict injects name + model into every WS snapshot so
consumers render proper labels on the initial tick without racing a
separate /printers fetch.
- /gcode-viewer (no trailing slash) dropped from the backend so reloads
fall through to the SPA catch-all and keep the layout shell; only
/gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
the iframe + static assets.
Frontend:
- PlatePickerModal shown only for multi-plate archives with sliced
gcode, grid layout with thumbnails matching the Re-print modal.
- Source-only archives show a noGcode toast instead of the empty
viewer.
- ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
no trailing slash; GCodeViewerPage iframe forwards
window.location.search so the archive reference survives both the
initial navigate and a full-page reload.
- Viewer iframe's auth path: fetch intercept injects Bearer; a 401
redirects to / so the SPA handles login.
Viewer adapter:
- Stripped the printer selector, WebSocket subscription, library file
picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
Selector. The viewer no longer observes live printer state.
- Bed size derived from /archives/{id}/capabilities.build_volume
(extracted from the 3MF's printable_area/printable_height), so H2D,
H-family, and any future printer render on the correct bed without
a hardcoded map.
- loadArchiveById accepts a plate param; fetch intercept rewrites
__bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].
Nav + locale cleanup:
- Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
now, not a destination page).
- 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
- platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
added in all 8 locales.
ArchivesPage: the now-unreachable ModelViewerModal render paths + its
showViewer state removed. ModelViewerModal itself stays — File Manager
still uses it for library file previews (plate picker + .3mf 3D model).
pre-commit:
- gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
so vendored third-party JS libs don't drift away from upstream.
Incidental sweeps picked up by pre-commit and kept (unrelated but
benign):
- NotificationsPage.tsx: single trailing-whitespace line removed.
- spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
the pn5180 driver module imported at line 27 does its own
`import gpiod` and `gpiod.Chip()` calls, so the diag script's
top-level import was never referenced.
Tests:
- 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
behaviour (plate=N resolution, zero-padded filenames, missing-plate
404, no-plate fallback, plate=0 rejection, has_gcode true/false).
- 3 new cases in test_printer_manager.py for name/model WS injection.
- PlatePickerModal.test.tsx — 6 frontend cases covering render,
plate-name composition, onSelect payload, backdrop close, and
thumbnail fallback.
Full-mode install booted into an unusable kiosk:
- Chromium opened before uvicorn → "can't connect to localhost"
- After reload, requires_setup=true hijacked /spoolbuddy → /setup
- Touch-only Pi has no keyboard to complete the setup wizard
- Declining auth left the user at / instead of the kiosk
Fixes, bundled:
1. backend/app/cli.py kiosk-bootstrap now, in one DB transaction:
- creates a scoped API key (can_read_status=True, rest false)
- upserts setup_completed=true
so AuthContext never redirects and the kiosk URL loads directly. Users
who want auth can still enable it from the admin UI; the provisioned
key keeps working.
2. install.sh full-mode runs the CLI as the bambuddy service user after
create_bambuddy_service and sed-replaces the CHANGE_ME_AFTER_SETUP
placeholder in spoolbuddy/.env.
3. The generated spoolbuddy-kiosk-launch polls ${backend_url}/health for
up to 60s before exec'ing chromium, so cold boots wait for uvicorn
instead of flashing ERR_CONNECTION_REFUSED.
Standalone mode was unaffected — users supply a real key from their
existing Bambuddy before install.
Full-mode install wrote CHANGE_ME_AFTER_SETUP as SPOOLBUDDY_API_KEY because
no admin exists yet to create a real one. On reboot the kiosk launched with
that placeholder, AuthContext rejected it, and the user hit the Bambuddy
login page instead of the kiosk. Standalone mode was unaffected — users
paste a real key from their existing Bambuddy before install.
Adds backend/app/cli.py with a kiosk-bootstrap subcommand that creates a
scoped APIKey row directly in the DB (can_read_status=True, everything else
false) and prints the full key to stdout. install.sh full-mode runs it as
the bambuddy service user after create_bambuddy_service, captures the key,
and sed-replaces the placeholder in spoolbuddy/.env. Idempotent with
--force for re-installs.
Drops the outdated "create an API key and edit .env" next-step block since
the kiosk is now provisioned automatically.
Display now powers on via wlopm when the daemon detects an NFC tag or
a significant weight change (>=50g, i.e. spool placed/removed) while
the screen is blanked. Minor scale fluctuations no longer wake the
display or prevent blanking. The daemon only re-blanks screens it woke
itself — touch-based wake/blank stays with swayidle so the two don't
conflict. Daemon discovers the Wayland session from the shared runtime
dir since it runs as a systemd service outside the compositor.
Display now powers on via wlopm when the daemon detects an NFC tag or
a significant weight change (>=50g, i.e. spool placed/removed) while
the screen is blanked. Minor scale fluctuations no longer wake the
display or prevent blanking. The daemon only re-blanks screens it woke
itself — touch-based wake/blank stays with swayidle so the two don't
conflict. Daemon discovers the Wayland session from the shared runtime
dir since it runs as a systemd service outside the compositor.
Display now powers on via wlopm when the daemon detects an NFC tag or
weight change while the screen is blanked. The daemon only re-blanks
screens it woke itself — touch-based wake/blank stays with swayidle so
the two don't conflict. Daemon discovers the Wayland session from the
shared runtime dir since it runs as a systemd service outside the
compositor.
Display now powers on via wlopm when the daemon detects an NFC tag or
weight change while the screen is blanked. Daemon discovers the Wayland
session from the shared runtime dir and coexists with swayidle which
continues to handle touch-based wake independently.
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.
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.
The touchscreen display blanked right after boot, requiring a touch
to wake. Two issues: no consoleblank=0 in cmdline.txt (kernel blanks
the console during Plymouth→labwc transition), and the wlr-randr
anti-blank loop slept 60s before its first run.
- Add consoleblank=0 to kernel cmdline in install.sh
- Move sleep after wlr-randr in labwc autostart so it fires immediately
- Round scale weight to integer before sending to backend (Pydantic
rejects non-whole floats for int fields), move modal close to finally
block, add error toast with actual API message
- Fix null-field crash in SpoolInfoCard prop construction: pick one
source object instead of per-field ?? fallbacks that crash when
displayedSpool has null subtype/brand/rgba and matchedSpool is null
- Add React ErrorBoundary to App so crashes show error instead of
black screen
- Remove --max-old-space-size=128 and --enable-low-end-device-mode
from kiosk Chromium flags (crashed renderer/display)
- Append kiosk flags to Pi GPU defaults instead of resetting them
- Add wlr-randr keep-alive and screenBlankTimeout=0 to prevent
display blanking on labwc 0.9.x
- Fix tests: add ToastProvider to Dashboard test wrapper, update
StatusBar tests for removed animate-pulse class
Frontend: replace expensive idle dashboard animations (3x animate-ping
with scale transforms, blur-2xl glow, continuous animate-pulse on
status dots) with static NFC rings and slow 5s color-cycling spool.
Chromium: add --disable-extensions, --disable-background-timer-throttling,
--memory-pressure-off, --disable-renderer-backgrounding, --disable-breakpad,
and --js-flags=--max-old-space-size=128. Install script: mask stripped
services (not just disable) to prevent socket/dbus reactivation; use
/etc/systemd/user/ global overrides for user services instead of
unreliable su-based systemctl --user. Remove chromium/upower from
strip_packages since kiosk reinstalls them immediately.
Add Chromium flags to cut overhead on Pi: disable extensions, crash
reporter, background timer throttling, renderer backgrounding, and cap
V8 heap at 128MB. Mask (not just disable) stripped system services to
prevent socket/dbus reactivation, and add xdg-permission-store to the
disable list. Remove chromium and upower from strip_packages since the
kiosk needs them — they were being uninstalled then immediately
reinstalled on every run.
reporter, background timer throttling, renderer backgrounding, and cap
V8 heap at 128MB. Also mask (not just disable) stripped system services
to prevent socket/dbus reactivation, and add xdg-permission-store to
the disable list.
Replace Chromium with cog (WPE WebKit) for the kiosk browser. Cog is
purpose-built for embedded kiosk displays with a fraction of Chromium's
CPU and memory footprint on Pi hardware.
Add React Query `select` to SpoolBuddyLayout and SpoolBuddyDashboard
printer status queries so only `connected` is extracted. Temperature,
fan, and progress changes no longer trigger re-renders on every MQTT
tick.
Expand service/package stripping to disable pipewire audio stack, CUPS
printing, rpcbind, upower, polkit, accounts-daemon, xdg-desktop-portal,
and mpris-proxy. Add user-level service masking for pipewire/portals.
Update SSH update cache clearing to handle both WPE WebKit and legacy
Chromium cache paths.
Override Debian's default Chromium flags via /etc/chromium.d/spoolbuddy-kiosk
to disable GPU rasterization, enable low-end device mode, and disable smooth
scrolling/background networking. The system default --enable-gpu-rasterization
conflicted with per-launch flags — the new config replaces all system defaults
so kiosk flags take effect cleanly.
Expand service/package stripping to disable pipewire audio stack, CUPS
printing, rpcbind, upower, polkit, accounts-daemon, xdg-desktop-portal,
and mpris-proxy. Add user-level service masking for pipewire/portals
that system-level disable misses.
Add Chromium performance flags (disable-gpu-rasterization,
enable-low-end-device-mode, disable-smooth-scrolling,
disable-background-networking, disable-dev-shm-usage) to reduce
CPU load from ~54% to manageable levels on Pi 4B.
Expand service/package stripping to disable pipewire audio stack,
CUPS printing, rpcbind, upower, polkit, accounts-daemon,
xdg-desktop-portal, and mpris-proxy. Add user-level service
masking for pipewire/portals that system-level disable misses.
New splash shows only the SpoolBuddy logo with green glow bloom,
radial gradient, light rays, and vignette. Removed Bambuddy branding.
Includes generator script for easy customization.
Defers initramfs rebuild during install until after Plymouth theme
is configured, avoiding redundant rebuilds from apt hooks.
New splash shows only the SpoolBuddy logo with green glow bloom,
radial gradient, light rays, and vignette. Removed Bambuddy branding.
Includes generator script for easy customization.
Defers initramfs rebuild during install until after Plymouth theme
is configured, avoiding redundant rebuilds from apt hooks.
Reverts the fim/fbi experiment — Plymouth is the only splash tool
that reliably handles Pi KMS/DRM from early boot. install.sh is
restored to the original Plymouth setup. The new polished splash
image and generator script are kept.
Plymouth ran as a persistent daemon throughout boot, consuming memory
and competing for framebuffer allocation. fim renders via DRM (Pi KMS
doesn't expose a usable legacy framebuffer), displays the image, and
exits — zero ongoing resource cost.
New splash image shows only the SpoolBuddy logo with baked-in glow,
radial gradient, light rays, and vignette effects (66KB vs 205KB).
Install script auto-purges Plymouth on existing installs in a single
pass to avoid redundant initramfs rebuilds.
Plymouth ran as a persistent daemon throughout boot, consuming memory
and competing for framebuffer allocation. fbi writes pixels directly
to the framebuffer and exits — zero ongoing resource cost.
New splash image shows only the SpoolBuddy logo with baked-in glow,
radial gradient, light rays, and vignette effects (66KB vs 205KB).
Install script auto-purges Plymouth on existing installs in a single
pass to avoid redundant initramfs rebuilds.
Plymouth ran as a persistent daemon throughout boot, consuming memory
and competing for framebuffer allocation. fbi writes pixels directly
to the framebuffer and exits — zero ongoing resource cost.
New splash image shows only the SpoolBuddy logo with baked-in glow,
radial gradient, light rays, and vignette effects (66KB vs 205KB).
Install script auto-removes Plymouth on existing installs.
The read_tag.py diagnostic script had stale PN5180 NTAG methods: TX CRC
was off (should be on), no Crypto1 clear, no IDLE→TRANSCEIVE state
reset. Multi-batch reads failed because the PN5180 enters an
unrecoverable state after an NTAG READ — requires a full GPIO hardware
reset between 4-page batches. Also rejected SAK 0x04 as unsupported,
and failed hard when reading past the end of smaller tags (MIFARE
Ultralight has 16 pages vs NTAG's 44+). Synced write methods with
daemon.
The read_tag.py diagnostic script had stale PN5180 NTAG methods: TX CRC
was off (should be on), no Crypto1 clear, no IDLE→TRANSCEIVE state
reset. Multi-batch reads failed because the PN5180 enters an
unrecoverable state after an NTAG READ — requires a full GPIO hardware
reset between 4-page batches. Also rejected SAK 0x04 as unsupported.
Synced write methods with daemon.
The read_tag.py diagnostic script had stale PN5180 NTAG methods: TX CRC
was off (should be on), no Crypto1 clear, no IDLE→TRANSCEIVE state
reset, and multi-batch reads failed because the PN5180 can't issue
consecutive NTAG READs without a full RF power cycle. Added extended-
timing reactivation (50ms gaps vs 10ms) between 4-page batches. Also
rejected SAK 0x04 as unsupported. Synced write methods with daemon.
The read_tag.py diagnostic script had stale PN5180 NTAG methods: TX CRC
was off (should be on), no Crypto1 clear, no IDLE→TRANSCEIVE state
reset, and multi-batch reads failed because subsequent READ commands
need a full state machine reset between batches. Also rejected SAK 0x04
as unsupported. Synced register setup and write methods with daemon.
The read_tag.py diagnostic script had stale PN5180 NTAG methods: TX CRC
was off (should be on), no Crypto1 clear, no IDLE→TRANSCEIVE state
reset, and the PN5180 drops the card after each READ batch requiring
reactivation between 4-page reads. Also rejected SAK 0x04 as
unsupported. Synced register setup with daemon and added per-batch
card reactivation.
The read_tag.py diagnostic script had stale PN5180 NTAG methods that
were never synced with the daemon's fixes: TX CRC was off (should be
on), no Crypto1 clear, no IDLE→TRANSCEIVE state reset, and unreliable
ACK/verification logic. Also rejected SAK 0x04 as unsupported. Synced
ntag_read_pages, ntag_write_page, and ntag_write_pages with daemon.
The read_tag.py diagnostic script only accepted SAK 0x00 for NTAG,
showing "Unsupported tag type" for chips reporting SAK 0x04 (MIFARE
Ultralight family). The daemon already handled both values — the
diagnostic was missed. Now accepts both 0x00 and 0x04.
The daemon now collects CPU temp, core count, load average, memory/disk
usage, OS info, and system uptime every heartbeat using stdlib-only reads
from /proc and /sys. Stats are sent as a JSON blob in the heartbeat
payload, stored in a new system_stats TEXT column, and displayed in a
new "System" tab in SpoolBuddy Settings with color-coded usage bars.
The PN5180 cannot read more than 4 NTAG pages after a batch write:
the second READ command (page 8+) returns rx_status=0 regardless of
state machine reset strategy. The write itself succeeds (tag ACKs
via SOF on every page). Remove verification and trust the writes.
Repeated IDLE→TRANSCEIVE resets inside the read loop broke the card
session after the first READ (page 8 returned rx_status=0). Match
the activate_type_a pattern: IDLE→TRANSCEIVE once before the loop,
set_transceive_mode() for subsequent iterations.
The verification read after writing failed because ntag_read_pages()
used set_transceive_mode() which was a no-op when already in
TRANSCEIVE. Apply the same IDLE→TRANSCEIVE reset pattern used in the
write path, clear Crypto1, and add diagnostic logging.
IRQ logging revealed the tag IS responding (RX_SOF_DET set) but the
PN5180 cannot capture the 4-bit ACK as a complete frame — RX_IRQ
never fires and RX_STATUS stays zero. Skip per-page ACK checking
and rely on the read-back verification in ntag_write_pages().