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.
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).
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.
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 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 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().
The PN5180 transceive state machine wasn't being reset between the
SELECT (from reactivate_card) and the WRITE command — just re-setting
the TRANSCEIVE bits was a no-op. Use IDLE→TRANSCEIVE transition like
activate_type_a does. Also clear Crypto1 bit and add IRQ status
logging.
Temporary diagnostics to identify which step of the NTAG write
fails: per-page ACK status, reactivation, read-back, or data
mismatch. Enable DEBUG level for pn5180 module.
The NTAG WRITE ACK is 4 bits (0x0A), not a full byte. The PN5180
RX_STATUS register reports 0 complete bytes with 4 extra bits, but
the check only looked at the byte count — returning False on every
successful write. Check both byte and bit fields of RX_STATUS.
Also bump post-write delay from 5ms to 10ms for margin.
Three issues:
1. SAK gate too strict — NTAG chips can report SAK 0x04 (MIFARE
Ultralight family) instead of 0x00. Accept both in nfc_reader.py
and main.py.
2. TX CRC disabled for NTAG WRITE — the NTAG spec requires CRC on
the WRITE command frame. Enable TX CRC in ntag_write_page().
3. TX CRC disabled for NTAG READ — the post-write verification read
also failed because ntag_read_pages() sent the READ command
without CRC. Enable TX CRC there too.
Two issues:
1. SAK gate too strict — NTAG chips can report SAK 0x04 (MIFARE
Ultralight family) instead of 0x00. Accept both in nfc_reader.py
and main.py.
2. TX CRC disabled during WRITE — the NTAG WRITE command (0xA2)
requires a CRC on the frame. The PN5180 was sending without CRC,
causing the tag to NAK every write. Enable TX CRC for writes
(RX CRC stays off for the 4-bit ACK).
NTAG 213/215/216 chips can report SAK 0x04 (MIFARE Ultralight family)
instead of 0x00 during anticollision. Accept both values for NTAG
detection and write operations in nfc_reader.py and main.py.
The NAU7802 ADC returns a stale max-scale value (0x7FFFFF) on its
first conversion after power-up, polluting the moving average and
making the initial weight report wildly inaccurate. Flush the first
reading during init().
Also extract both hardware drivers out of diagnostic scripts into
proper daemon modules:
- NAU7802 scale driver: scripts/scale_diag.py -> daemon/nau7802.py
- PN5180 NFC driver: scripts/read_tag.py -> daemon/pn5180.py
The production daemon was importing driver classes from test scripts
since the original SpoolBuddy commit. Diagnostic scripts now import
from the driver modules. Removed the sys.path hack from main.py.
The daemon's self-update mechanism (git fetch/reset on its own code) was
fragile: .git permission errors, self-modifying code mid-run, hardcoded
main branch. Bambuddy now SSHes into the SpoolBuddy Pi and drives the
update remotely — matching its own branch, with step-by-step progress
via WebSocket.
SSH key pairing is automatic: Bambuddy generates an ED25519 keypair and
returns the public key in the registration response. The daemon deploys
it to authorized_keys on first connect — no manual setup needed.
- New: backend/app/services/spoolbuddy_ssh.py (keypair, SSH commands, update orchestration)
- Rewritten: trigger_daemon_update endpoint uses SSH instead of pending_command
- New: GET /spoolbuddy/ssh/public-key endpoint for manual pairing
- Removed: daemon _perform_update() and cmd=="update" heartbeat handler
- Updated: install.sh — bash shell, sudoers for systemctl restart, .ssh/ setup
- Updated: Dockerfile — added openssh-client
- Updated: frontend — SSH key display, force update button
- Fixed: update check now compares against APP_VERSION, not GitHub releases
The daemon's self-update mechanism (git fetch/reset on its own code) was
fragile: .git permission errors, self-modifying code mid-run, hardcoded
main branch. Bambuddy now SSHes into the SpoolBuddy Pi and drives the
update remotely — matching its own branch, with step-by-step progress
via WebSocket. Install script updated with SSH access, sudoers entry,
and --ssh-pubkey flag for pairing.
The daemon had a hardcoded __version__ = "0.2.2b1" that was never
bumped, causing the update check to always show an update available.
Changed to read APP_VERSION from backend/app/core/config.py at import
time so the daemon version stays in sync automatically.
SpoolBuddy devices can now be updated from Settings → Updates without
SSH access. The daemon picks up an "update" command via its existing
heartbeat, runs git fetch/reset + pip install, reports progress back
to the backend, then exits for systemd to restart with the new code.
Backend: update_status/update_message fields, trigger + status endpoints
Daemon: _perform_update() handler, report_update_status() API method
Frontend: "Apply Update" button with live progress in UpdatesTab
Write NTAG213/215/216 tags for third-party spools via the SpoolBuddy
kiosk UI. New "Write" page with three workflows: existing spool, new
spool creation, and tag replacement. Backend encodes 133-byte OpenTag3D
NDEF payloads (material, color, brand, weight, temp). Daemon writes
page-by-page via PN5180 NTAG WRITE command with read-back verification.
Write commands flow through heartbeat polling with WebSocket status
updates. Includes 39 new tests and translations for all 6 languages.
1. Device tab — repo URL unlinked
Replaced the <a> tag with a <span> so the GitHub URL is displayed as plain text (no clickable link in kiosk mode).
2. Display controls — moved to frontend
Root cause: The daemon runs as a system user without Wayland access, and the kiosk uses an HDMI display (no sysfs backlight). Neither wlopm, vcgencmd, nor sysfs writes
can work in this setup.
Fix — frontend-controlled display in SpoolBuddyLayout.tsx:
- Brightness: CSS filter: brightness(X) applied to the layout root. Immediate visual dimming that works on any display type.
- Screen blanking: Full-screen black overlay (z-[9999]) shown after the configured inactivity timeout. Touch anywhere to wake.
- Activity tracking: Resets on pointerdown/keydown events AND on WebSocket-driven NFC/scale changes (weight update, tag scan).
- Queries device data every 15s to read the current brightness and blank timeout values.
Daemon display_control.py — Simplified:
- Removed vcgencmd/wlopm/subprocess usage entirely
- _blank/_unblank are now state-tracking no-ops (log only)
- DSI backlight brightness via sysfs is preserved as a bonus for DSI displays
- Updated docstring to explain the architecture
3. Scale tab — redesigned with step indicator
Idle state: Same as before — weight card with tare/calibrate buttons.
Calibration wizard: Two-column layout:
- Left column (~64px): Vertical step indicator with numbered circles, checkmark for completed steps, and a connecting line
- Right column: Live weight bar (with stable/settling indicator), step-specific content (instructions or numpad), and action buttons pinned to bottom via mt-auto
The numpad buttons are min-h-[52px] with text-lg — large enough for touch on 1024x600. The weight info card is hidden during the weight entry step to maximize vertical
space.
Root cause: The daemon used wlopm for screen blanking, but wlopm was never installed. Additionally, the daemon runs as the spoolbuddy system user which has no access to
the Wayland socket, so Wayland-based tools can't work.
Fix in display_control.py:
- Replaced wlopm --off/--on with vcgencmd display_power 0/1 — this is a Raspberry Pi firmware-level command that's pre-installed and works without Wayland socket access
- Added shutil.which("vcgencmd") check at init to avoid repeated failures on non-RPi hardware
- Added explicit PermissionError handling for brightness writes with a helpful message about the video group
Fix in install.sh:
- Added video group to the spoolbuddy service user's groups (was: gpio, spi, i2c → now: gpio, spi, i2c, video), which grants access to both vcgencmd and sysfs backlight
files
Scale Tab Numpad
Root cause: On the 1024x600 kiosk screen (~376px available content height), the weight info card + numpad + action buttons exceeded the space, causing tiny buttons and
overlapping.
Fix in SpoolBuddySettingsPage.tsx:
- Hide the weight info card during weight entry step (calStep !== 'weight'), reclaiming ~70px
- Compact inline weight reading in the step header (small dot + monospace text) so users can still see the live scale value
- Larger numpad buttons: min-h-[56px] with text-lg font size (was no min-height, text-sm)
- Added active:scale-95 for tactile touch feedback
- mt-auto on action buttons to push them to the bottom, preventing overlap
- Removed the wrapping card around the calibration flow to save vertical padding
_get_mac_id() used Path.iterdir() to find a network interface for the
device ID, but iteration order is non-deterministic. On different boots
it could pick eth0 (MAC …3100) or wlan0 (MAC …3102), producing a
different device_id each time. Since calibration is stored per device ID
in the backend DB, a new ID meant registering as an uncalibrated device.
Sort interfaces alphabetically so the same one is always selected.
The tare and calibrate buttons on the Settings page queued commands
but never executed them due to three broken links:
1. Daemon received tare command via heartbeat but never called
scale.tare() — the ScaleReader was available in shared dict
but unused
2. No API endpoint for the daemon to report the new tare offset
back to the backend DB, so tare results were lost
3. Heartbeat updated config but never called
scale.update_calibration(), so ScaleReader kept initial values
Added set-tare endpoint + API client method, and fixed heartbeat
loop to execute tare, persist the result, and propagate calibration
changes to the ScaleReader instance. Skip calibration sync on the
heartbeat cycle that delivers a tare command, since the response
predates the tare and would overwrite it with stale values.
The tare and calibrate buttons on the Settings page queued commands
but never executed them due to three broken links:
1. Daemon received tare command via heartbeat but never called
scale.tare() — the ScaleReader was available in shared dict
but unused
2. No API endpoint for the daemon to report the new tare offset
back to the backend DB, so tare results were lost
3. Heartbeat updated config but never called
scale.update_calibration(), so ScaleReader kept initial values
Added set-tare endpoint + API client method, and fixed heartbeat
loop to execute tare, persist the result, and propagate calibration
changes to the ScaleReader instance.
Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
TagDetectedModal that auto-opens on NFC tag detection. Known spools show
remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
"Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
won't re-open for dismissed tags, reopens on re-place after removal.
Daemon: Fix PN5180 NFC reader failing to detect tags. Each
activate_type_a() call returning None corrupts the PN5180 transceive
state, silently preventing all subsequent tag detection. Fixed by
performing a full hardware reset (RST pin toggle + RF re-init) before
every idle poll (~240ms, ~1.8 Hz rate). When a tag is present, a light
RF off/on cycle (13ms) resets the card from ACTIVE to IDLE state for
continuous re-selection. Also added error-based recovery, periodic
status logging, and accurate heartbeat NFC/scale health reporting.
Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
TagDetectedModal that auto-opens on NFC tag detection. Known spools show
remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
"Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
won't re-open for dismissed tags, reopens on re-place after removal.
Daemon: Fix PN5180 NFC reader failing to maintain tag detection. After a
successful SELECT the card stays in ACTIVE state and ignores subsequent
WUPA/REQA. Added a conditional RF off/on cycle (13ms) before each poll,
but only when a tag is present — resets card from ACTIVE to IDLE for
re-selection. The cycle is skipped when idle to avoid degrading reader
state with continuous RF toggling, which prevented new tags from being
detected after removal. Also added a preventive full hardware reset every
60s when idle to recover from deeper stuck states, error-based recovery
after 10 consecutive exceptions, and accurate heartbeat reporting of
NFC/scale health.
Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
TagDetectedModal that auto-opens on NFC tag detection. Known spools show
remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
"Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
won't re-open for dismissed tags, reopens on re-place after removal.
Daemon: Fix PN5180 NFC reader failing to maintain tag detection. After a
successful SELECT the card stays in ACTIVE state and ignores subsequent
WUPA/REQA, causing immediate false "tag removed" events. Added a brief
RF off/on cycle (13ms) before each poll to force cards back to IDLE
state. Also added a preventive full hardware reset every 60s when idle
to recover from deeper stuck states where activate_type_a() silently
returns None without exceptions. Heartbeat now reports actual nfc_ok/
scale_ok instead of hardcoded True.
Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
TagDetectedModal that auto-opens on NFC tag detection. Known spools show
remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
"Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
won't re-open for dismissed tags, reopens on re-place after removal.
Daemon: Fix PN5180 NFC reader silently stopping tag detection. The reader
drifts into a stuck state where activate_type_a() returns None without
raising exceptions, making error-based recovery useless. Changed the
preventive idle maintenance from a lightweight RF off/on cycle to a full
hardware reset (RST pin toggle + RF re-init) every 60s — the same reset
that a service restart performs. Also added error-based auto-recovery
after 10 consecutive exceptions, promoted poll errors to WARNING, added
periodic status logging, and fixed heartbeat to report actual nfc_ok/
scale_ok instead of hardcoded True.
Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
TagDetectedModal that auto-opens on NFC tag detection. Known spools show
remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
"Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
won't re-open for dismissed tags, reopens on re-place after removal.
Daemon: Fix PN5180 NFC reader silently stopping tag detection when the
reader drifts into a stuck state. Add auto-recovery (full hardware reset
after 10 consecutive errors), preventive RF cycling every 60s when idle,
and periodic status logging. Heartbeat now reports actual nfc_ok/scale_ok
from reader instances instead of hardcoded True.
When ADC noise kept the spread hovering around the 2g stability
threshold, the stable flag toggled every cycle, forcing a report with
a slightly different weight each time. Now only actual weight changes
of >=2g trigger reports. The stable flag is still included in each
report for consumers that need it.
5 samples at 100ms (500ms window) wasn't enough to smooth NAU7802 ADC
noise — the averaged value still varied by >2g between 1s report
intervals, and the stability state kept flipping, triggering a report
every cycle. Increased to 20 samples (2s window) so noise is smoothed
before reaching the reporting layer.
The daemon imports read_tag and scale_diag as bare modules, but they
live in spoolbuddy/scripts/ which isn't on sys.path when systemd runs
the daemon. Added scripts/ to sys.path at startup, resolved relative
to the module file. Also moved the read_tag import inside NFCReader's
try/except (was crashing the daemon instead of skipping gracefully)
and demoted hardware-not-available messages from ERROR to INFO.
NFCReader.__init__ imported read_tag and instantiated PN5180() outside
the try/except block, so a missing module crashed the entire daemon.
Moved the import inside the existing try/except so the daemon gracefully
skips NFC polling — matching the scale reader's existing behavior.