nfc_reader.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. """NFC reader wrapper with state machine for tag presence detection."""
  2. import logging
  3. import time
  4. from enum import Enum, auto
  5. logger = logging.getLogger(__name__)
  6. MISS_THRESHOLD = 3 # Consecutive misses before declaring tag removed
  7. ERROR_RECOVERY_THRESHOLD = 10 # Consecutive errors before attempting RF reset
  8. class NFCState(Enum):
  9. IDLE = auto()
  10. TAG_PRESENT = auto()
  11. class NFCReader:
  12. def __init__(self):
  13. self._nfc = None
  14. self._state = NFCState.IDLE
  15. self._current_uid: str | None = None
  16. self._current_sak: int | None = None
  17. self._miss_count = 0
  18. self._ok = False
  19. self._error_count = 0
  20. self._poll_count = 0
  21. self._last_status_log = 0.0
  22. try:
  23. from .pn5180 import PN5180
  24. self._nfc = PN5180()
  25. self._init_rf()
  26. self._ok = True
  27. logger.info("NFC reader initialized")
  28. except Exception as e:
  29. logger.warning("NFC not available: %s", e)
  30. def _init_rf(self):
  31. """Full RF initialization sequence."""
  32. self._nfc.reset()
  33. self._nfc.load_rf_config(0x00, 0x80)
  34. time.sleep(0.010)
  35. self._nfc.rf_on()
  36. time.sleep(0.030)
  37. self._nfc.set_transceive_mode()
  38. def _full_reset(self):
  39. """Full hardware reset + RF init to recover from stuck state."""
  40. try:
  41. self._init_rf()
  42. self._error_count = 0
  43. logger.info("NFC reader recovered after full reset")
  44. return True
  45. except Exception as e:
  46. logger.warning("NFC full reset failed: %s", e)
  47. return False
  48. @property
  49. def reader_type(self) -> str:
  50. """Return NFC reader hardware type."""
  51. return "PN5180" if self._nfc is not None else "Unknown"
  52. @property
  53. def connection(self) -> str:
  54. """Return NFC reader connection type."""
  55. return "SPI" if self._nfc is not None else "None"
  56. @property
  57. def ok(self) -> bool:
  58. return self._ok
  59. @property
  60. def state(self) -> NFCState:
  61. return self._state
  62. @property
  63. def current_uid(self) -> str | None:
  64. return self._current_uid
  65. def close(self):
  66. try:
  67. self._nfc.rf_off()
  68. self._nfc.close()
  69. except Exception:
  70. pass
  71. @property
  72. def current_sak(self) -> int | None:
  73. return self._current_sak
  74. def write_ntag(self, data: bytes, data_v2: bytes | None = None) -> tuple[bool, str]:
  75. """Write raw NDEF bytes to currently present NTAG tag.
  76. *data* is OpenTag3D v1, which fits every NTAG. *data_v2* (OpenTag3D
  77. 2.004, #3500) is written instead when the tag's Capability Container
  78. says it has room for it; v2 does not fit an NTAG213.
  79. The Capability Container is read first: a tag whose CC is not NDEF
  80. (a blank MIFARE Ultralight also answers with SAK 0x00) or whose data
  81. area is smaller than the payload is refused rather than written past
  82. its end (#3076). Afterwards the pages are read back, since the PN5180
  83. cannot see the tag's ACK: data that reads back different fails the
  84. write. A read-back that gets no answer leaves it "not verified".
  85. Requires tag in TAG_PRESENT state with SAK=0x00.
  86. Returns (success, message).
  87. """
  88. if self._state != NFCState.TAG_PRESENT:
  89. return False, "No tag present"
  90. if self._current_sak not in (0x00, 0x04):
  91. return False, f"Not an NTAG (SAK=0x{self._current_sak:02X})"
  92. if not self._nfc:
  93. return False, "NFC reader not available"
  94. try:
  95. # Reactivate card before writing
  96. result = self._nfc.reactivate_card()
  97. if result is None:
  98. return False, "Failed to reactivate card for write"
  99. uid_bytes, sak = result
  100. if uid_bytes.hex().upper() != self._current_uid:
  101. return False, "Tag UID changed during write"
  102. # Page 3 is the Capability Container; READ returns pages 3-6.
  103. try:
  104. cc_pages = self._nfc.ntag_read_pages(start_page=3, num_pages=1)
  105. except Exception as e:
  106. logger.warning("Could not read the tag's Capability Container: %s", e)
  107. cc_pages = None
  108. version = "v1"
  109. if data_v2 is not None:
  110. data, version = choose_ndef(cc_pages, data, data_v2)
  111. refusal = capacity_refusal(cc_pages, len(data))
  112. if refusal:
  113. logger.warning("NTAG write refused for tag %s: %s", self._current_uid, refusal)
  114. return False, refusal
  115. # A fresh select before writing, as the write path always had.
  116. if self._nfc.reactivate_card() is None:
  117. return False, "Failed to reactivate card for write"
  118. # Write starting at page 4
  119. if not self._nfc.ntag_write_pages(start_page=4, data=data):
  120. return False, "Write failed"
  121. verified, mismatch = self._verify_written(data)
  122. if verified is False:
  123. return False, mismatch
  124. note = "" if verified else ", not verified"
  125. logger.info(
  126. "NTAG write successful%s: %d bytes (OpenTag3D %s) to tag %s",
  127. note,
  128. len(data),
  129. version,
  130. self._current_uid,
  131. )
  132. return True, f"Write successful (OpenTag3D {version}{note})"
  133. except Exception as e:
  134. logger.error("NTAG write error: %s", e)
  135. return False, f"Write error: {e}"
  136. def _verify_written(self, data: bytes) -> tuple[bool | None, str]:
  137. """Read the written pages back and compare (#3076).
  138. (True, "") when they match, (False, where) when they differ, (None, "")
  139. when the tag could not be read back. The PN5180 used to
  140. get no answer to a READ right after a batch write, so the tag is
  141. selected afresh first; a read that still fails proves nothing either
  142. way, and the write keeps counting as done, as it did before.
  143. """
  144. padded = data + bytes(-len(data) % 4)
  145. try:
  146. if self._nfc.reactivate_card() is None:
  147. logger.warning("NTAG read-back skipped: the tag did not answer the reselect")
  148. return None, ""
  149. read = self._nfc.ntag_read_pages(start_page=4, num_pages=len(padded) // 4)
  150. except Exception as e:
  151. logger.warning("NTAG read-back failed: %s", e)
  152. return None, ""
  153. if read is None or len(read) < len(padded):
  154. logger.warning("NTAG read-back got no complete answer; the write is not verified")
  155. return None, ""
  156. for offset in range(0, len(padded), 4):
  157. if read[offset : offset + 4] != padded[offset : offset + 4]:
  158. page = 4 + offset // 4
  159. logger.warning(
  160. "NTAG read-back mismatch at page %d: wrote %s, read %s",
  161. page,
  162. padded[offset : offset + 4].hex(),
  163. read[offset : offset + 4].hex(),
  164. )
  165. return False, f"Verification failed at page {page}: the tag holds other data"
  166. return True, ""
  167. def poll(self) -> tuple[str, dict | None]:
  168. """Poll for tag. Returns (event_type, event_data).
  169. event_type: "none", "tag_detected", "tag_removed"
  170. """
  171. self._poll_count += 1
  172. # Periodic status log (every 60s)
  173. now = time.monotonic()
  174. if now - self._last_status_log >= 60.0:
  175. logger.info(
  176. "NFC status: state=%s, polls=%d, errors=%d, ok=%s",
  177. self._state.name,
  178. self._poll_count,
  179. self._error_count,
  180. self._ok,
  181. )
  182. self._last_status_log = now
  183. if self._state == NFCState.IDLE:
  184. # Full hardware reset before every idle poll. Each activate_type_a()
  185. # call that returns None corrupts the PN5180 state — subsequent calls
  186. # silently fail even when a tag is present. Only a full RST pin toggle
  187. # recovers the reader. ~240ms overhead per poll, giving ~1.8 Hz poll
  188. # rate which is fine for a spool tag reader.
  189. try:
  190. self._init_rf()
  191. except Exception as e:
  192. logger.warning("NFC pre-poll reset failed: %s", e)
  193. else:
  194. # Tag present: light RF cycle to reset card from ACTIVE back to IDLE
  195. # state after previous SELECT, so it responds to the next WUPA/REQA.
  196. try:
  197. self._nfc.rf_off()
  198. time.sleep(0.003)
  199. self._nfc.rf_on()
  200. time.sleep(0.010)
  201. except Exception:
  202. pass # Will be caught by activate_type_a() error handling below
  203. try:
  204. result = self._nfc.activate_type_a()
  205. except Exception as e:
  206. self._error_count += 1
  207. self._ok = False
  208. if self._error_count == 1:
  209. logger.warning("NFC poll error: %s", e)
  210. elif self._error_count == ERROR_RECOVERY_THRESHOLD:
  211. logger.warning(
  212. "NFC reader stuck (%d consecutive errors), attempting recovery...",
  213. self._error_count,
  214. )
  215. if self._full_reset():
  216. return "none", None
  217. # Reset failed — will keep trying on next threshold
  218. self._error_count = 0
  219. elif self._error_count % ERROR_RECOVERY_THRESHOLD == 0:
  220. logger.warning("NFC recovery attempt #%d", self._error_count // ERROR_RECOVERY_THRESHOLD)
  221. self._full_reset()
  222. return "none", None
  223. # Successful poll — clear error streak
  224. if self._error_count > 0:
  225. logger.info("NFC reader recovered after %d errors", self._error_count)
  226. self._error_count = 0
  227. self._ok = True
  228. if result is not None:
  229. uid_bytes, sak = result
  230. uid_hex = uid_bytes.hex().upper()
  231. self._miss_count = 0
  232. if self._state == NFCState.IDLE:
  233. self._state = NFCState.TAG_PRESENT
  234. self._current_uid = uid_hex
  235. self._current_sak = sak
  236. # Try reading Bambu tag data
  237. tray_uuid = None
  238. raw_blocks = None
  239. tag_type = "mifare_classic" if sak in (0x08, 0x18) else "ntag" if sak in (0x00, 0x04) else "unknown"
  240. if sak in (0x08, 0x18):
  241. # A reader fault mid-read (a BUSY timeout, an SPI error) must
  242. # not escape poll() and stop the loop: report the tag without
  243. # its tray UUID, so matching falls back to the UID (#3377)
  244. try:
  245. blocks = self._nfc.read_bambu_tag(uid_bytes)
  246. except Exception as e:
  247. logger.warning("Bambu tag read failed for %s: %s", uid_hex, e)
  248. blocks = None
  249. if blocks:
  250. tray_uuid = _extract_tray_uuid(blocks)
  251. raw_blocks = _raw_blocks(blocks)
  252. logger.info("Tag detected: %s (SAK=0x%02X, type=%s)", uid_hex, sak, tag_type)
  253. return "tag_detected", {
  254. "tag_uid": uid_hex,
  255. "sak": sak,
  256. "tag_type": tag_type,
  257. "tray_uuid": tray_uuid,
  258. "raw_blocks": raw_blocks,
  259. }
  260. # Tag still present — no event
  261. return "none", None
  262. # No tag found
  263. if self._state == NFCState.TAG_PRESENT:
  264. self._miss_count += 1
  265. if self._miss_count >= MISS_THRESHOLD:
  266. old_uid = self._current_uid
  267. self._state = NFCState.IDLE
  268. self._current_uid = None
  269. self._current_sak = None
  270. self._miss_count = 0
  271. logger.info("Tag removed: %s", old_uid)
  272. return "tag_removed", {"tag_uid": old_uid}
  273. return "none", None
  274. def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
  275. """Extract tray_uuid from Bambu MIFARE Classic data blocks.
  276. The tray UUID is the 16 raw bytes of block 9, as 32 uppercase hex chars.
  277. Both tags of a spool carry the same value, and it is what the AMS reports
  278. as tray_uuid. Blocks 4-5 hold the filament type ("PLA Matte"), not the UUID:
  279. reading them made every spool of one type share an id (#984).
  280. """
  281. data = blocks.get(9)
  282. if not data or len(data) < 16:
  283. return None
  284. uuid_str = data[:16].hex().upper()
  285. if uuid_str == "0" * 32:
  286. return None
  287. return uuid_str
  288. def capacity_refusal(cc_pages: bytes | None, size: int) -> str | None:
  289. """Why *size* bytes can't go onto this tag, or None (#3076).
  290. *cc_pages* starts with page 3, the Capability Container. NTAG21x tags come
  291. with an NDEF CC (E1, version, data area size / 8, access); a blank MIFARE
  292. Ultralight, which also reports SAK 0x00, has none and holds 48 bytes. An
  293. unreadable CC proves nothing, so the write goes ahead as it always did.
  294. """
  295. if not cc_pages or len(cc_pages) < 4:
  296. return None
  297. if cc_pages[0] != 0xE1:
  298. return "Not an NDEF-formatted NTAG tag. Use an NTAG213, 215 or 216."
  299. capacity = cc_pages[2] * 8
  300. if size > capacity:
  301. return f"Tag too small: {size} bytes to write, {capacity} bytes of space"
  302. return None
  303. def choose_ndef(cc_pages: bytes | None, data_v1: bytes, data_v2: bytes) -> tuple[bytes, str]:
  304. """Pick the OpenTag3D encoding the tag has room for (#3500).
  305. *cc_pages* starts with page 3, the NDEF Capability Container
  306. (E1, version, data area size / 8, access). v2 is chosen only when the CC
  307. reads as NDEF and its data area holds the whole v2 message: an NTAG213
  308. (size byte 0x12, 144 bytes) gets v1, an NTAG215/216 (0x3E/0x6D) gets v2.
  309. Anything unreadable gets v1, which fits every NTAG.
  310. """
  311. if cc_pages and len(cc_pages) >= 4 and cc_pages[0] == 0xE1 and len(data_v2) <= cc_pages[2] * 8:
  312. return data_v2, "v2"
  313. return data_v1, "v1"
  314. def _raw_blocks(blocks: dict[int, bytes]) -> dict[str, str]:
  315. """Bambu tag blocks as {"block number": "32 uppercase hex chars"}, for the backend to decode (#2685)."""
  316. return {str(block): data.hex().upper() for block, data in sorted(blocks.items())}