Files
bambuddy/spoolbuddy/daemon/nfc_reader.py
T
maziggy 2b1fe03159 Fix SpoolBuddy scale first reading and extract hardware drivers
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.
2026-03-25 11:57:28 +01:00

266 lines
9.1 KiB
Python

"""NFC reader wrapper with state machine for tag presence detection."""
import logging
import time
from enum import Enum, auto
logger = logging.getLogger(__name__)
MISS_THRESHOLD = 3 # Consecutive misses before declaring tag removed
ERROR_RECOVERY_THRESHOLD = 10 # Consecutive errors before attempting RF reset
class NFCState(Enum):
IDLE = auto()
TAG_PRESENT = auto()
class NFCReader:
def __init__(self):
self._nfc = None
self._state = NFCState.IDLE
self._current_uid: str | None = None
self._current_sak: int | None = None
self._miss_count = 0
self._ok = False
self._error_count = 0
self._poll_count = 0
self._last_status_log = 0.0
try:
from .pn5180 import PN5180
self._nfc = PN5180()
self._init_rf()
self._ok = True
logger.info("NFC reader initialized")
except Exception as e:
logger.warning("NFC not available: %s", e)
def _init_rf(self):
"""Full RF initialization sequence."""
self._nfc.reset()
self._nfc.load_rf_config(0x00, 0x80)
time.sleep(0.010)
self._nfc.rf_on()
time.sleep(0.030)
self._nfc.set_transceive_mode()
def _full_reset(self):
"""Full hardware reset + RF init to recover from stuck state."""
try:
self._init_rf()
self._error_count = 0
logger.info("NFC reader recovered after full reset")
return True
except Exception as e:
logger.warning("NFC full reset failed: %s", e)
return False
@property
def reader_type(self) -> str:
"""Return NFC reader hardware type."""
return "PN5180" if self._nfc is not None else "Unknown"
@property
def connection(self) -> str:
"""Return NFC reader connection type."""
return "SPI" if self._nfc is not None else "None"
@property
def ok(self) -> bool:
return self._ok
@property
def state(self) -> NFCState:
return self._state
@property
def current_uid(self) -> str | None:
return self._current_uid
def close(self):
try:
self._nfc.rf_off()
self._nfc.close()
except Exception:
pass
@property
def current_sak(self) -> int | None:
return self._current_sak
def write_ntag(self, data: bytes) -> tuple[bool, str]:
"""Write raw NDEF bytes to currently present NTAG tag.
Requires tag in TAG_PRESENT state with SAK=0x00.
Returns (success, message).
"""
if self._state != NFCState.TAG_PRESENT:
return False, "No tag present"
if self._current_sak != 0x00:
return False, f"Not an NTAG (SAK=0x{self._current_sak:02X})"
if not self._nfc:
return False, "NFC reader not available"
try:
# Reactivate card before writing
result = self._nfc.reactivate_card()
if result is None:
return False, "Failed to reactivate card for write"
uid_bytes, sak = result
if uid_bytes.hex().upper() != self._current_uid:
return False, "Tag UID changed during write"
# Write starting at page 4
success = self._nfc.ntag_write_pages(start_page=4, data=data)
if success:
logger.info("NTAG write successful: %d bytes to tag %s", len(data), self._current_uid)
return True, "Write successful"
else:
return False, "Write or verification failed"
except Exception as e:
logger.error("NTAG write error: %s", e)
return False, f"Write error: {e}"
def poll(self) -> tuple[str, dict | None]:
"""Poll for tag. Returns (event_type, event_data).
event_type: "none", "tag_detected", "tag_removed"
"""
self._poll_count += 1
# Periodic status log (every 60s)
now = time.monotonic()
if now - self._last_status_log >= 60.0:
logger.info(
"NFC status: state=%s, polls=%d, errors=%d, ok=%s",
self._state.name,
self._poll_count,
self._error_count,
self._ok,
)
self._last_status_log = now
if self._state == NFCState.IDLE:
# Full hardware reset before every idle poll. Each activate_type_a()
# call that returns None corrupts the PN5180 state — subsequent calls
# silently fail even when a tag is present. Only a full RST pin toggle
# recovers the reader. ~240ms overhead per poll, giving ~1.8 Hz poll
# rate which is fine for a spool tag reader.
try:
self._init_rf()
except Exception as e:
logger.warning("NFC pre-poll reset failed: %s", e)
else:
# Tag present: light RF cycle to reset card from ACTIVE back to IDLE
# state after previous SELECT, so it responds to the next WUPA/REQA.
try:
self._nfc.rf_off()
time.sleep(0.003)
self._nfc.rf_on()
time.sleep(0.010)
except Exception:
pass # Will be caught by activate_type_a() error handling below
try:
result = self._nfc.activate_type_a()
except Exception as e:
self._error_count += 1
self._ok = False
if self._error_count == 1:
logger.warning("NFC poll error: %s", e)
elif self._error_count == ERROR_RECOVERY_THRESHOLD:
logger.warning(
"NFC reader stuck (%d consecutive errors), attempting recovery...",
self._error_count,
)
if self._full_reset():
return "none", None
# Reset failed — will keep trying on next threshold
self._error_count = 0
elif self._error_count % ERROR_RECOVERY_THRESHOLD == 0:
logger.warning("NFC recovery attempt #%d", self._error_count // ERROR_RECOVERY_THRESHOLD)
self._full_reset()
return "none", None
# Successful poll — clear error streak
if self._error_count > 0:
logger.info("NFC reader recovered after %d errors", self._error_count)
self._error_count = 0
self._ok = True
if result is not None:
uid_bytes, sak = result
uid_hex = uid_bytes.hex().upper()
self._miss_count = 0
if self._state == NFCState.IDLE:
self._state = NFCState.TAG_PRESENT
self._current_uid = uid_hex
self._current_sak = sak
# Try reading Bambu tag data
tray_uuid = None
tag_type = "mifare_classic" if sak in (0x08, 0x18) else "ntag" if sak == 0x00 else "unknown"
if sak in (0x08, 0x18):
blocks = self._nfc.read_bambu_tag(uid_bytes)
if blocks:
tray_uuid = _extract_tray_uuid(blocks)
logger.info("Tag detected: %s (SAK=0x%02X, type=%s)", uid_hex, sak, tag_type)
return "tag_detected", {
"tag_uid": uid_hex,
"sak": sak,
"tag_type": tag_type,
"tray_uuid": tray_uuid,
}
# Tag still present — no event
return "none", None
# No tag found
if self._state == NFCState.TAG_PRESENT:
self._miss_count += 1
if self._miss_count >= MISS_THRESHOLD:
old_uid = self._current_uid
self._state = NFCState.IDLE
self._current_uid = None
self._current_sak = None
self._miss_count = 0
logger.info("Tag removed: %s", old_uid)
return "tag_removed", {"tag_uid": old_uid}
return "none", None
def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
"""Extract tray_uuid from Bambu MIFARE Classic data blocks."""
# Block 4-5 contain the tray UUID as 32 ASCII hex chars across 32 bytes.
if 4 in blocks and 5 in blocks:
raw = blocks[4] + blocks[5]
try:
# Preferred path: decode full ASCII payload, keep only hex chars.
ascii_candidate = raw.decode("ascii", errors="ignore")
hex_chars = "".join(ch for ch in ascii_candidate if ch in "0123456789abcdefABCDEF")
if len(hex_chars) >= 32:
uuid_str = hex_chars[:32].upper()
if uuid_str != "0" * 32:
return uuid_str
except Exception:
pass
try:
# Fallback for partially decoded payloads: use first 16 raw bytes as hex.
# This preserves compatibility with older decoding behavior.
uuid_str = raw[:16].hex().upper()
if uuid_str and uuid_str != "0" * 32:
return uuid_str
except Exception:
pass
return None