diff --git a/.gitignore b/.gitignore index 81aef0b..50d8141 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # VSCodium / VSCode # ---------------------------- .vscode/ +OpenParcelBox.code-workspace # ---------------------------- # Codex diff --git a/CHANGELOG.md b/CHANGELOG.md index 90998ca..081c6da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ### Fixed +- Aligned project documentation with the implemented PN532, BLE, mobile, + opening-history, user-role, one-time-code, 150 ms lock-pulse, and diagnostic + behavior, while keeping unfinished timeout, power, Zigbee, and Home Assistant + work explicitly marked as pending. +- Restored explicit 2-second open-door reminder diagnostics and increased the + D9 lock-control pulse from 100 ms to 150 ms so mechanical relay stages have + more time to present `SIG` without excessive coil-on time. +- Added a clipboard action to permanent and temporary access-code rows in the + mobile application, positioned before the edit action. +- Matched NFC access feedback to keypad-code feedback: authorized badges use + the long success beep, while unauthorized badges and duplicate enrollment + attempts use the same three-beep invalid sequence. +- Reworked box-reader NFC enrollment into an administrator-only 15-second BLE + flow with an active waiting modal, in-modal errors, post-detection naming, + cancellation, duplicate detection, and authoritative tag-list refresh. - Prevented generated permanent and temporary codes from colliding with any active code, including across code kinds, and made firmware reject exclusive creation when the value already exists. @@ -39,6 +54,12 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ### Added +- Added PN532 passive-tag detection over the shared I2C bus at 7-bit address + `0x24`, including firmware-version probing, ISO/IEC 14443-A UID reads, mobile + enrollment, stored-tag unlock, history, and UART diagnostics. +- Added a fixed 15-second NFC scan window started only by the first keypad press; + additional presses cannot extend it, and the PN532 enters I2C-wakeable + power-down mode outside scan windows. - Internal identity-reset button on XIAO `P1.11`: holding it for three seconds clears administrator and guest app identities plus BLE bonds without erasing access codes, NFC tags, history, or the box name. diff --git a/README.md b/README.md index 55e4476..67e8b38 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,14 @@ Completed or validated: - BLE LE Secure Connections, encrypted GATT access, and 128-bit phone identities - First-phone administrator provisioning and named guest identities - App-triggered NFC enrollment with stored tag names -- Lock command pulse on XIAO `D9` +- PN532 passive-badge reader over I2C at `0x24`, with a fixed non-renewable + 15-second keypad-triggered scan window and power-down between windows +- 150 ms lock command pulse on XIAO `D9`, inactive throughout startup - KR-S79 `COM/NC` lock state feedback on XIAO `D7` / `D8` - Internal identity-reset button on XIAO `P1.11` - Green-open, off-closed, red-invalid LED feedback -- Short key beeps, long valid-code beep, invalid-code beep sequence, and 2s open reminder beep +- Short key beeps, long valid-code beep, invalid-code beep sequence, and 2-second + open reminder beep - Door opened / closed serial debug messages - Firmware debug output over serial console - One-second white RGB confirmation when the local keypad path is ready @@ -58,14 +61,13 @@ In progress: - Original hardware reverse engineering - Production keypad mapping -- NFC credential reader hardware selection +- PN532 reader hardware validation and production integration - Hardware validation -- BLE protocol documentation and hardening +- BLE security and hardware interoperability validation Planned: - Firmware update from the mobile application -- NFC badge unlock with a dedicated reader circuit - Battery management - Zigbee - OTA updates @@ -144,7 +146,9 @@ Current development platform: - KR-S79 `COM` feedback reference on XIAO `D8` - Internal identity-reset button between `GND` and XIAO `P1.11` -The XIAO nRF52840 exposes the Nordic NFCT pins (`NFC1/P0.09` and `NFC2/P0.10`). This peripheral is tag-side NFC-A hardware, not a standalone active reader for passive badges. Badge UID reading requires a dedicated NFC reader circuit on the production hardware. +The XIAO nRF52840 exposes tag-side Nordic NFCT pins, which are not used as a +passive-badge reader. Badge UID reading uses a PN532 module on the shared I2C +bus at address `0x24` during development. The production hardware is planned around a custom PCB and the original parcel box mechanical parts. diff --git a/ROADMAP.md b/ROADMAP.md index a4b63a8..75e4340 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,7 @@ The firmware is based on Zephyr RTOS and follows a modular architecture to simpl | Persistent access code storage | Done | | Battery driver | Pending | | NFC credential storage and scan API | Done | -| Dedicated NFC reader driver | Pending | +| PN532 NFC reader driver | Done | | Power management | Pending | | Bluetooth configuration | In progress | | Zigbee integration | Pending | @@ -117,10 +117,10 @@ Objective: validate every reusable or newly added hardware component before inte Components: -- Lock command pulse. +- 150 ms active-high lock command pulse, inactive throughout startup. - Door or lock state feedback through the KR-S79 `COM/NC` contact. - Battery monitoring. -- Dedicated NFC reader circuit and antenna. +- PN532 development reader and production antenna/placement validation. - Buzzer. - External LEDs. @@ -151,7 +151,7 @@ Components: - Battery. - Buzzer. - NFC credential storage. -- Dedicated NFC reader backend. +- PN532 I2C reader backend. Security: @@ -231,9 +231,12 @@ Implemented MVP pieces: - Keypad-based unlock flow. - Persistent six-digit code table. - Default development unlock code. -- Lock command pulse. +- 150 ms active-high lock command pulse with no startup pulse. - Lock state feedback. - Audible and visual feedback for valid codes, invalid codes, and open state. +- PN532 badge unlock with persistent named tags and low-power scan windows. +- Local BLE administration with administrator and guest identities. +- One-time access codes and persistent opening history. At this stage, the original proprietary electronics are no longer required. @@ -264,6 +267,7 @@ Implemented so far: - Permanent code management screen. - One-time temporary code management screen. - NFC tag add/remove screen. +- Box-reader NFC enrollment with timeout, duplicate handling, and tag naming. - Stored code and NFC tag readback from the firmware BLE state characteristic. - Branded Android launcher icon and native splash screen. - Automatic French/English selection with a manual override. @@ -273,6 +277,10 @@ Implemented so far: - AES-256-GCM password-protected application backup and restore. - Opening history, code limits, phone-side NFC scans, and destructive-action warnings. +- Permanent-code editing and clipboard actions for permanent and temporary + codes. +- Secure offline state cache keyed by application identity and BLE rediscovery + after backup restoration. Status: in progress. @@ -290,7 +298,18 @@ Features: - Permanent NFC badges through dedicated reader hardware. - User permissions. -Status: planned. +Implemented so far: + +- One administrator identity and named guest identities stored in firmware. +- Administrator-generated guest invitation QR codes and guest revocation. +- Firmware-enforced administrator/guest permissions. + +Remaining work: + +- Associate individual keypad codes and NFC tags with user records. +- Expand permission granularity beyond the current administrator/guest roles. + +Status: in progress. --- @@ -308,7 +327,18 @@ Events: - Door opened. - Door closed. -Status: planned. +Implemented so far: + +- Persistent history of the newest 64 successful openings. +- Seven-day retention with permanent-code, temporary-code, NFC-tag, or mobile + identity source information. +- Mobile readback and offline caching. + +Remaining work: + +- Invalid credential, low-battery, and explicit door transition events. + +Status: in progress. --- @@ -366,7 +396,18 @@ Features: - One-time PINs. - Temporary NFC badges through dedicated reader hardware. -Status: planned. +Implemented so far: + +- One-time six-digit PINs removed after their first successful keypad use. +- Guest creation and removal of temporary PINs. + +Remaining work: + +- Date/time expiration. +- Configurable usage counters. +- Temporary NFC badges. + +Status: in progress. --- @@ -413,7 +454,13 @@ Scope: - Hardware documentation. - Firmware documentation. -Status: planned. +Implemented so far: + +- Firmware module and BLE protocol documentation. +- Development hardware pinout, BOM, interfaces, and lock reverse engineering. +- Mobile application behavior and build documentation. + +Status: in progress. --- diff --git a/TODO.md b/TODO.md index a28f04f..3cf4e38 100644 --- a/TODO.md +++ b/TODO.md @@ -72,7 +72,7 @@ Tasks are grouped by development phase and updated throughout the project. - [x] Lock driver. - [x] Door or lock state driver. - [ ] Battery driver. -- [ ] NFC driver. +- [x] PN532 NFC driver over I2C. ### Documentation @@ -83,7 +83,7 @@ Tasks are grouped by development phase and updated throughout the project. - [ ] led.md. - [x] lock.md. - [ ] battery.md. -- [ ] nfc.md. +- [x] nfc.md. --- @@ -111,10 +111,10 @@ Tasks are grouped by development phase and updated throughout the project. ### NFC -- [ ] Select dedicated NFC reader IC. +- [x] Select PN532 development NFC reader. - [ ] Design reader antenna. - [ ] Validate reader tuning network. -- [ ] Read UID through the dedicated reader. +- [x] Read UID through the PN532 reader. - [ ] Detect badge removal. ### Buzzer @@ -130,8 +130,8 @@ Tasks are grouped by development phase and updated throughout the project. ### Initialization -- [ ] Complete startup sequence. -- [ ] Initialize all peripherals. +- [x] Keep the local keypad path available before Settings and BLE startup. +- [x] Initialize all currently implemented peripherals. - [ ] Standardize error handling. ### Access Control @@ -170,12 +170,12 @@ Tasks are grouped by development phase and updated throughout the project. - [x] Door or lock feedback. - [ ] Battery monitoring. - [ ] Administrator PIN. -- [ ] Administrator NFC badge through dedicated reader hardware. +- [x] Administrator NFC badge through the PN532 reader. - [x] Audible feedback. -- [ ] Standalone operation. +- [x] Standalone local keypad and NFC operation. - [ ] Battery-powered operation. -- [ ] No cloud. -- [ ] No subscription. +- [x] No mandatory cloud dependency. +- [x] No subscription. --- @@ -186,6 +186,7 @@ Tasks are grouped by development phase and updated throughout the project. - [x] Mobile Bluetooth scan and connect UI. - [x] Mobile clock-sync command. - [x] Mobile random permanent and one-time code management UI. +- [x] Mobile permanent-code editing and clipboard actions for both code lists. - [x] Mobile NFC tag add/remove UI. - [x] Mobile direct lock-open command UI. - [x] Mobile stored-state readback from firmware. @@ -203,7 +204,8 @@ Tasks are grouped by development phase and updated throughout the project. - [ ] Validate pairing recovery and guest invitation on Android and iOS hardware. - [ ] Zigbee connectivity. - [ ] Home Assistant integration. -- [ ] Temporary access. +- [x] One-time temporary PINs. +- [ ] Time-limited and usage-count temporary credentials. - [ ] Dedicated PCB. - [ ] Field validation. - [ ] Complete user, maker, and developer documentation. diff --git a/docs/architecture.md b/docs/architecture.md index 50304b7..3f9d0fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,21 +20,18 @@ Main goals: ## Overview ```text -Home Assistant +Flutter mobile application | -Zigbee (ZHA / Zigbee2MQTT) +Secure BLE administration | -OpenParcelBox - | - +-- Zephyr RTOS - +-- Seeed Studio XIAO nRF52840 - +-- Access Control - +-- Keypad - +-- NFC - +-- Lock Controller - +-- Power Manager - +-- Diagnostics - +-- OTA +OpenParcelBox firmware on Zephyr RTOS + +-- Keypad and access codes + +-- PN532 NFC reader and stored tags + +-- Lock command and COM/NC feedback + +-- Audible, visual, and serial diagnostics + +Planned: Home Assistant via Zigbee (ZHA / Zigbee2MQTT) +Planned: battery manager and secure firmware update ``` --- @@ -82,11 +79,14 @@ Responsibilities: - Keypad scanning. - NFC credential coordination. - Lock control. -- Battery monitoring. - RGB LED status. - Active buzzer feedback. + +Planned responsibilities: + +- Battery monitoring and system sleep. - Zigbee communication. -- OTA updates. +- Secure firmware updates. --- @@ -114,37 +114,60 @@ The firmware is organized into independent modules. ### NFC - UID validation. -- Low-power wake-up. +- Fixed 15-second keypad-triggered scan windows. +- Administrator-triggered enrollment through the mobile application. +- PN532 power-down outside active scan windows. -The nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware. Passive badge UID reading requires a dedicated NFC reader circuit on the production hardware. +The nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware. Passive +badge UID reading currently uses a PN532 module on the shared I2C bus; its +production placement and antenna implementation remain to be validated. ### Access Control -- Administrator codes. -- Permanent codes. -- Temporary codes. -- Anti brute-force logic. +- Eight permanent six-digit codes. +- Twenty one-time six-digit codes. +- Ten named NFC tags. +- Administrator and named guest mobile identities. + +Attempt limiting and more granular credential ownership remain planned. ### Lock Controller -- Lock actuation. -- Opening validation. -- Error handling. +- 150 ms lock actuation pulse. +- Continuous `COM/NC` state feedback. +- Open-state LED, serial, and buzzer feedback. -### Power Manager +The current implementation provides actuation and continuous feedback. The +time-bounded command-result validation and explicit error path remain planned. + +### Bluetooth + +- BLE LE Secure Connections and encrypted GATT access. +- Administrator and named guest application identities. +- Paginated authoritative state readback for the Flutter application. +- Commands for opening, clock synchronization, credentials, guests, NFC + enrollment, and factory reset. + +### Opening History + +- Persistent storage of the newest 64 successful openings. +- Seven-day retention. +- Permanent-code, temporary-code, NFC-tag, or mobile-identity source. + +### Power Manager (Planned) - Battery monitoring. - Deep sleep. - Wake-up handling. - Power optimization. -### Zigbee +### Zigbee (Planned) - Home Assistant communication. - State publication. - Command reception. -### OTA +### OTA (Planned) - Download. - Verification. @@ -152,9 +175,15 @@ The nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware. Passive badg --- -## Opening Validation +## Opening Feedback and Planned Validation -Opening must never be validated by a fixed delay alone. +The current firmware sends a 150 ms command pulse and independently monitors +the KR-S79 `COM/NC` contact. That feedback drives the green open indicator, +door transition logs, and the reminder beep every 2 seconds. + +Correlating an opening request with a time-bounded `COM/NC` transition and +reporting an explicit opening failure remain to be implemented. The target +sequence is: Sequence: @@ -229,8 +258,9 @@ Core build tools: ## Future Work -- Local event history. - Advanced user management. - Hardware diagnostics. -- Bluetooth Low Energy configuration. +- Battery monitoring and power optimization. +- Zigbee and Home Assistant integration. +- Secure firmware updates. - Support for multiple parcel box variants. diff --git a/docs/firmware/bluetooth.md b/docs/firmware/bluetooth.md index 70f0c17..73105c9 100644 --- a/docs/firmware/bluetooth.md +++ b/docs/firmware/bluetooth.md @@ -314,6 +314,18 @@ Example: "name": "Alice" } ], + "nfc_enrollment": { + "active": false, + "uid": "", + "error": "" + }, + "history": [ + { + "unix_ms": 1784135045000, + "kind": 2, + "actor": "Alice" + } + ], "guests": [ { "name": "Delivery team", @@ -357,12 +369,16 @@ NFC enrollment is started with: ```json { "command": "start_nfc_enrollment", - "identity_key": "<32 hexadecimal characters>", - "name": "Alice" + "identity_key": "<32 hexadecimal characters>" } ``` -The next UID reported by the dedicated reader is stored with that name. +The PN532 scans immediately for 15 seconds. Authenticated state exposes +`nfc_enrollment.active`, then either a new `uid` or an `error` (`timeout`, +`already_exists`, or `reader_unavailable`). The application asks for a name +only after receiving a new UID, then stores it with the regular `add_nfc_tag` +command. `cancel_nfc_enrollment` stops an active scan and returns the reader to +power-down mode. All enrollment commands remain administrator-only. When the phone has synchronized the clock, lock-opening logs include a Unix timestamp in milliseconds. diff --git a/docs/firmware/buzzer.md b/docs/firmware/buzzer.md index f0a0936..a47612b 100644 --- a/docs/firmware/buzzer.md +++ b/docs/firmware/buzzer.md @@ -39,10 +39,15 @@ void buzzer_off(void); - `buzzer_init()` configures the buzzer control GPIO and leaves the buzzer off. - `buzzer_beep_key()` emits a short blocking beep. -- `buzzer_beep_success()` emits a longer blocking beep when a valid access code is accepted. +- `buzzer_beep_success()` emits a longer blocking beep when a valid access code + or NFC badge is accepted. - The keypad calls `buzzer_beep_key()` only when a new key press is accepted, so holding a key does not produce a continuous beep. -- Invalid six-digit access codes are signaled by a higher-level sequence of three short GPIO pulses. +- Invalid six-digit access codes, unauthorized NFC badges, and duplicate tags + during enrollment are signaled by the same higher-level sequence of three + short GPIO pulses. - While the lock state feedback reports open, the main loop emits one short reminder beep every 2 seconds. +- Each open-state reminder is also logged as `Door open reminder beep` so the + feedback contact and buzzer behavior can be distinguished during diagnosis. --- diff --git a/docs/firmware/debug.md b/docs/firmware/debug.md index 107dca0..e2a7835 100644 --- a/docs/firmware/debug.md +++ b/docs/firmware/debug.md @@ -15,13 +15,12 @@ The firmware currently supports the following debugging methods: - UART console (`printf`) - On-board RGB LED - Bluetooth state and command diagnostics -- Zephyr Shell (when enabled) +- PN532/NFC UART diagnostics Additional methods may be added later: - Zigbee diagnostics - Home Assistant diagnostic entities -- NFC diagnostics --- @@ -46,10 +45,9 @@ The onboard RGB LED is used to quickly identify the firmware state. ## Startup -| Color | Meaning | -| ------- | ----------------------- | -| Off | Startup in progress | -| Magenta | Initialization failed | +The LED remains off during early startup, then turns white for one second when +the local keypad path is ready. Peripheral failures are reported over UART and +do not currently use a dedicated fatal LED state. --- @@ -71,7 +69,6 @@ and keypad initialization is retried once per second. | Green | Lock state feedback reports open | | Blue | NFC credential scan mode active | | Red | Invalid six-digit access code | -| Magenta | Fatal initialization error | --- @@ -112,11 +109,12 @@ Door opened timestamp_ms=1784135045050 Lock state: ```text -Door opened +Door opened timestamp_ms=1784135045050 +Door open reminder beep Door closed ``` -Future dedicated NFC reader backend: +PN532 reader: ```text Scan NFC: ON diff --git a/docs/firmware/gpio_expander.md b/docs/firmware/gpio_expander.md index fdf6cc0..8fac5a6 100644 --- a/docs/firmware/gpio_expander.md +++ b/docs/firmware/gpio_expander.md @@ -6,7 +6,9 @@ This module provides a hardware abstraction layer (HAL) for the external GPIO ex The current implementation targets the PCF8574 connected over the I²C bus. Other GPIO expanders (PCF8575, MCP23017, etc.) can later be supported by modifying only this module. -All higher-level modules (keypad, lock, etc.) must use this API instead of directly accessing the I²C bus. +Higher-level modules connected to the PCF8574 use this API instead of directly +accessing the I²C bus. Direct XIAO GPIO peripherals such as lock control do not +pass through the expander. --- @@ -68,7 +70,6 @@ Only `gpio_expander.c` should require modifications. ## Used By - Keypad -- Future lock module (if additional GPIO are required) - Future peripherals connected through an external GPIO expander --- @@ -79,4 +80,4 @@ The PCF8574 uses quasi-bidirectional GPIOs. A pin must be written HIGH before it can be used as an input. -This behavior is fully handled by this module and should remain transparent to higher-level drivers. \ No newline at end of file +This behavior is fully handled by this module and should remain transparent to higher-level drivers. diff --git a/docs/firmware/keypad.md b/docs/firmware/keypad.md index 4e9fca3..f8eb6b8 100644 --- a/docs/firmware/keypad.md +++ b/docs/firmware/keypad.md @@ -69,6 +69,9 @@ Each key press is reported only once. - Single key detection - Anti-repeat - Modular GPIO Expander interface +- Higher-level short beep on each accepted key press +- `B`, `*`, or `#` cancellation of an in-progress access-code entry +- First-key trigger for the fixed, non-renewable 15-second NFC scan window --- @@ -76,5 +79,4 @@ Each key press is reported only once. - Support for the production 2x6 keypad. - Configurable key mapping. -- Optional audible feedback handled by the buzzer module. -- Unit tests. \ No newline at end of file +- Unit tests. diff --git a/docs/firmware/lock.md b/docs/firmware/lock.md index d3f3da3..9ec58ec 100644 --- a/docs/firmware/lock.md +++ b/docs/firmware/lock.md @@ -35,7 +35,7 @@ int lock_control_open(void); - `lock_control_init()` configures D9 directly as an inactive GPIO output. - `lock_control_init()` never sends an unlock pulse. -- `lock_control_open()` drives D9 high for 100 ms, then immediately returns it inactive with `gpio_pin_set_dt(..., 0)`. +- `lock_control_open()` drives D9 high for 150 ms, then immediately returns it inactive with `gpio_pin_set_dt(..., 0)`. The additional 50 ms compared with the original pulse gives the mechanical relay more time to pull in while limiting coil-on time and battery use. - All GPIO return codes used by the lock control path are logged. - No PWM is used. @@ -50,13 +50,15 @@ The main loop collects numeric keypad input. - Codes are persisted through Zephyr settings with the NVS backend. - The development default code is `784512`. - A valid six-digit entry triggers `lock_control_open()`. -- A future dedicated NFC reader backend may trigger `lock_control_open()` after a valid stored tag UID is detected. +- The PN532 reader triggers `lock_control_open()` after a valid stored tag UID is detected. - `*`, `#`, and `B` clear the current entry. - A valid six-digit entry triggers one long success beep before the opening pulse. - An invalid six-digit entry triggers three short close beeps and keeps the red LED on for 1 second. -- NFC credential scan mode starts on keypad activity, remains active for 1 minute, and is shown with the blue LED. -- A valid NFC tag UID will use the same success beep and lock opening path as a valid keypad code once the dedicated reader backend is added. +- The first keypad press starts one non-renewable 15-second NFC scan window, + shown with the blue LED; later presses cannot extend the active window. +- A valid NFC tag UID uses the same success beep and lock opening path as a valid keypad code. - The green LED stays on while the lock state feedback reports the lock open. - A short reminder beep is emitted every 2 seconds while the lock state feedback reports open. - Door state changes are reported over UART as `Door opened` and `Door closed`. +- Each periodic reminder is reported over UART as `Door open reminder beep`. - LEDs stay off during startup and while the lock is closed. diff --git a/docs/firmware/nfc.md b/docs/firmware/nfc.md index 2eb9a9d..09796ef 100644 --- a/docs/firmware/nfc.md +++ b/docs/firmware/nfc.md @@ -4,11 +4,16 @@ This module provides the application-level NFC credential flow and persistent NFC tag UID storage. -NFC tag UIDs are stored in non-volatile settings, like access codes. They remain available after power loss and expose add, replace, and clear APIs for the future mobile application and Home Assistant integration. +NFC tag UIDs are stored in non-volatile settings, like access codes. They +remain available after power loss and expose add, replace, and clear APIs used +by the mobile application and reserved for the future Home Assistant +integration. The Seeed Studio XIAO nRF52840 exposes the Nordic NFCT pins (`NFC1/P0.09` and `NFC2/P0.10`). The integrated NFCT peripheral is tag-side NFC-A hardware and is not a standalone active reader for passive badges. -Passive badge UID reading requires a dedicated NFC reader circuit on the production hardware. +Passive badge UID reading is provided by a PN532 module connected to the same +I2C bus as the development keypad expander. The module uses the fixed 7-bit +address `0x24` (`0x48` in NXP's 8-bit address notation). --- @@ -35,26 +40,29 @@ int nfc_tags_clear(size_t slot); ## Scan Mode -The NFC reader polling flow is temporarily disabled in the production main loop -while the local keypad/BLE startup baseline is being validated. Persistent tag -storage and the BLE management protocol remain compiled and initialized. +The firmware keeps the PN532 in its I2C-wakeable power-down mode by default. -The intended firmware flow keeps NFC credential scan mode disabled by default. +The first keypad key press wakes the PN532 and starts one fixed 15-second scan +window. Additional key presses during that window never extend or restart its +deadline. A later key press can start a new window only after the previous one +has ended. -The main loop enables scan mode when a keypad key is pressed. Scan mode remains active for 1 minute after the latest key press, then the firmware disables NFC credential scanning again. - -An authenticated administrator can also start enrollment from the mobile -application. The next UID reported by the reader is stored with the supplied -name and scan mode stops immediately. +An authenticated administrator can start a separate 15-second enrollment scan +from the mobile application. This explicit administration flow is the only +exception to keypad-triggered scanning. A new UID is returned through BLE but +is not authorized until the administrator enters a name and validates it in +the active modal. A duplicate tag or timeout is returned as an enrollment +error, and cancellation powers the reader down immediately. While scan mode is active, the RGB LED is blue. -When a future NFC reader backend detects a tag: +When the PN532 detects an ISO/IEC 14443-A tag: - The UID is printed over UART. -- A valid UID triggers the same unlock path as a valid keypad code: success beep, lock open pulse, and regular lock state feedback. -- After a valid NFC unlock through the dedicated reader backend, scan mode is disabled immediately. -- An invalid UID is reported over UART and scan mode remains active until timeout or a valid tag. +- A valid UID triggers the same success beep and unlock path as a valid keypad code. +- After a valid NFC unlock, scan mode is disabled immediately. +- An invalid UID triggers the same three-beep error sequence as an invalid + six-digit code, then scan mode remains active until timeout or a valid tag. Runtime UART messages: @@ -68,8 +76,13 @@ NFC not valid --- -## Current Driver Status +## PN532 Driver -The current `nfc` module defines the scan-mode API and keeps NFC disabled at startup. - -The hardware-specific tag detection backend is still a placeholder. It currently returns no detected UID until a dedicated NFC reader implementation is added. +- Interface selection: `I2C 1 0` on the module switches. +- Bus: XIAO `SDA` / `SCL`, shared with the PCF8574 at `0x20`. +- Address used by Zephyr: `0x24`. +- Reader initialization verifies the PN532 firmware response and configures SAM + normal mode plus bounded passive-target retries. +- Disabling scan sends the PN532 `PowerDown` command with I2C wake enabled. +- If the reader is absent, local keypad access, persistent storage, and BLE + continue operating; UART reports the NFC initialization/start error. diff --git a/docs/hardware/Commande temporaire SIG.svg b/docs/hardware/Commande temporaire SIG.svg index e87c92d..3a95730 100644 --- a/docs/hardware/Commande temporaire SIG.svg +++ b/docs/hardware/Commande temporaire SIG.svg @@ -19,17 +19,17 @@ - OpenParcelBox — commande temporaire de SIG avec S8550 + S8050 - Seeed Studio XIAO BLE nRF52840 • serrure KR-S79 • stack 8×AAA ≈ 13 V + OpenParcelBox — temporary SIG driver with S8550 + S8050 + Seeed Studio XIAO BLE nRF52840 • KR-S79 lock • 8×AAA pack ≈ 13 V XIAO BLE nRF52840 - D7 — commande serrure + D9 — lock command GND - Alimentation USB-C pendant le développement + USB-C power during development @@ -67,7 +67,7 @@ - GND commun + Common GND @@ -105,34 +105,34 @@ - SERRURE + LOCK KERONG KR-S79_20-JST - SIG jaune + Yellow SIG - + rouge → +13 V + Red + → +13 V - − orange → GND + Orange − → GND - Fonctionnement - D7 = LOW : Q2 bloqué → R3 remonte la base de Q1 à +13 V → Q1 bloqué → SIG non alimenté. - D7 = HIGH : Q2 conduit → courant limité par R2 → base de Q1 abaissée d’environ 0,7 V - sous son émetteur → Q1 conduit → environ +13 V appliqué sur SIG. - R4 maintient Q2 bloqué pendant le démarrage ou si D7 est flottante. - Important : ne jamais relier directement D7 à la base du S8550. + Operation + D9 = LOW: Q2 off → R3 pulls the Q1 base to +13 V → Q1 off → SIG not powered. + D9 = HIGH: Q2 conducts → R2 limits current → Q1 base about 0.7 V below its emitter + → Q1 conducts → approximately +13 V is applied to SIG. + R4 keeps Q2 off during startup or if D9 is floating. + Important: never connect D9 directly to the S8550 base. - Vérifications avant câblage - • Identifier physiquement E, B et C de chaque transistor avec la fiche du fabricant ou un multimètre. - • Les boîtiers S8050/S8550 ne garantissent pas tous le même ordre de broches. - • Vérifier la polarité du stack : rouge = +13 V, noir = GND. - • Relier le GND du XIAO au GND du stack avant d’activer D7. - • Tester d’abord avec une impulsion courte (≈100 ms) et mesurer SIG au multimètre. - Ce schéma ne suppose aucun brochage physique gauche/centre/droite. - \ No newline at end of file + Checks before wiring + • Identify E, B, and C on each transistor from its datasheet or with a multimeter. + • S8050/S8550 packages do not all guarantee the same pin order. + • Check pack polarity: red = +13 V, black = GND. + • Connect XIAO GND to pack GND before driving D9. + • Firmware applies a 150 ms pulse; measure SIG with a multimeter. + This diagram assumes no physical left/center/right transistor pin order. + diff --git a/firmware/README.md b/firmware/README.md index bc4945b..2c63d84 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -11,11 +11,15 @@ The firmware is responsible for: - Keypad management. - Access code handling. - Lock mechanism control. -- Battery monitoring. -- Zigbee communication. -- OTA updates. -- Home Assistant integration. - Audible and visual feedback. +- PN532 NFC badge reading. +- Secure BLE administration. + +Planned responsibilities: + +- Battery monitoring and low-power system modes. +- Zigbee and Home Assistant integration. +- Secure firmware updates. --- @@ -85,7 +89,8 @@ Active buzzer control through a GPIO-driven transistor. ### Lock Control -KR-S79 unlock command through a 100 ms active-high pulse on XIAO pin `D9`. +KR-S79 unlock command through a 150 ms active-high pulse on XIAO pin `D9`. +The output is configured inactive and never pulses during initialization. ### Lock State @@ -98,9 +103,9 @@ unlock codes. ### NFC Tags -Persistent storage for NFC tag UIDs and the application-level scan-mode API. - -The XIAO nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware. Passive badge UID reading will require a dedicated NFC reader backend. +Persistent storage for NFC tag UIDs and a PN532 passive-badge reader on the +shared I2C bus at address `0x24`. The first keypad press opens one fixed +15-second scan window; the reader remains in power-down mode otherwise. ### Bluetooth @@ -135,13 +140,14 @@ Implemented: - Active buzzer feedback on accepted key presses. - Long success beep on valid access code. - Three short beeps on invalid access code. +- One short reminder beep every 2 seconds while `COM/NC` reports open. - Persistent six-digit access code storage. - Persistent NFC tag UID storage with default development UID `60:4F:E2:B5`. - BLE administration service for clock sync, direct opening, access-code management, NFC tag management, and state readback. - One-time access codes removed automatically after first successful keypad use. - Runtime lock-opening logs with phone-synchronized timestamps when available. -- NFC credential scan-mode API retained while production polling remains - temporarily disabled pending a dedicated reader backend. +- PN532 UID polling, mobile enrollment, stored-tag unlock, history, and + I2C-wakeable power-down outside fixed 15-second scan windows. - Lock command pulse on XIAO pin `D9`. - Lock state feedback through the KR-S79 `COM/NC` contact. - Door opened / closed debug output. @@ -151,6 +157,6 @@ In progress or planned: - Production keypad mapping. - Battery monitoring. -- Dedicated NFC reader backend for passive badge UID detection. +- Physical PN532 range/interference validation and production reader layout. - Zigbee. - OTA updates. diff --git a/firmware/app/include/board_config.h b/firmware/app/include/board_config.h index 4962906..c21bb95 100644 --- a/firmware/app/include/board_config.h +++ b/firmware/app/include/board_config.h @@ -24,6 +24,11 @@ #define GPIO_EXPANDER_I2C_NODE DT_NODELABEL(i2c1) #define GPIO_EXPANDER_ADDRESS 0x20 +/* PN532 NFC reader (7-bit I2C address; NXP documents 0x48 including R/W). */ + +#define PN532_I2C_NODE DT_NODELABEL(i2c1) +#define PN532_I2C_ADDRESS 0x24 + /* Buzzer */ #define BUZZER_GPIO_NODE DT_NODELABEL(gpio0) diff --git a/firmware/app/openparcelbox.uf2 b/firmware/app/openparcelbox.uf2 index 31b59b0..0b98c40 100644 Binary files a/firmware/app/openparcelbox.uf2 and b/firmware/app/openparcelbox.uf2 differ diff --git a/firmware/app/src/lock_control.c b/firmware/app/src/lock_control.c index 1628706..aa91f63 100644 --- a/firmware/app/src/lock_control.c +++ b/firmware/app/src/lock_control.c @@ -17,7 +17,7 @@ #include #include -#define LOCK_CONTROL_OPEN_PULSE_MS 100 +#define LOCK_CONTROL_OPEN_PULSE_MS 150 /* D9 is declared in the application devicetree overlay as GPIO_ACTIVE_HIGH. */ static const struct gpio_dt_spec lock_control_gpio = @@ -62,7 +62,8 @@ int lock_control_open(void) { return ret; } - printk("Lock control GPIO active set: %d\n", ret); + printk("Lock control GPIO active for %d ms\n", + LOCK_CONTROL_OPEN_PULSE_MS); k_msleep(LOCK_CONTROL_OPEN_PULSE_MS); diff --git a/firmware/app/src/lock_control.h b/firmware/app/src/lock_control.h index 2d9184f..188bd9b 100644 --- a/firmware/app/src/lock_control.h +++ b/firmware/app/src/lock_control.h @@ -22,7 +22,7 @@ int lock_control_init(void); /** * @brief Send an unlock pulse to the lock. * - * Drives D9 high for 100 ms, then returns it low. + * Drives D9 high for 150 ms, then returns it low. * * @return 0 on success, negative value on error. */ diff --git a/firmware/app/src/main.c b/firmware/app/src/main.c index 8b2c8c8..ee2de42 100644 --- a/firmware/app/src/main.c +++ b/firmware/app/src/main.c @@ -31,6 +31,7 @@ #define KEYPAD_RETRY_MS 1000 #define SERVICE_INIT_STACK_SIZE 4096 #define SERVICE_INIT_PRIORITY 8 +#define NFC_SCAN_WINDOW_MS 15000 #define LOCK_OPEN_BEEP_INTERVAL_MS 2000 #define INVALID_CODE_LED_MS 1000 #define INVALID_CODE_BEEP_COUNT 3 @@ -87,6 +88,46 @@ static void signal_invalid_code(void) { k_msleep(INVALID_CODE_LED_MS - INVALID_CODE_SEQUENCE_MS); } +static void print_nfc_uid(const uint8_t *uid, size_t uid_length) { + printf("NFC detected:"); + for (size_t i = 0; i < uid_length; i++) { + printf("%s%02X", i == 0 ? " " : ":", uid[i]); + } + printf("\n"); +} + +static bool start_nfc_scan(int64_t *deadline_ms) { + if (nfc_scan_is_enabled()) { + return true; + } + + int ret = nfc_scan_enable(); + if (ret < 0) { + printf("NFC scan start failed: %d\n", ret); + return false; + } + + *deadline_ms = k_uptime_get() + NFC_SCAN_WINDOW_MS; + printf("Scan NFC: ON\n"); + if (led_ready) led_set_blue(); + return true; +} + +static void stop_nfc_scan(int64_t *deadline_ms, bool door_open) { + if (!nfc_scan_is_enabled()) { + *deadline_ms = 0; + return; + } + + int ret = nfc_scan_disable(); + if (ret < 0) { + printf("NFC power-down failed: %d\n", ret); + } + *deadline_ms = 0; + printf("Scan NFC: OFF\n"); + set_idle_led(door_open); +} + static int request_lock_open(const char *source, const char *actor) { if (!lock_control_ready) { printf("Lock open rejected: control GPIO unavailable\n"); @@ -154,7 +195,7 @@ static int initialize_persistent_modules(void) { if (ret < 0 && first_error == 0) first_error = ret; ret = nfc_init(); printf("Startup nfc=%d\n", ret); - if (ret < 0 && first_error == 0) first_error = ret; + /* A missing optional reader must not disable persistence, keypad, or BLE. */ return first_error; } @@ -210,6 +251,8 @@ int main(void) { int64_t reset_button_pressed_since_ms = 0; int64_t identity_reset_blink_until_ms = 0; int64_t next_identity_reset_blink_ms = 0; + int64_t nfc_scan_deadline_ms = 0; + bool nfc_enrollment_pending = false; /* * These two initializers are RAM-only. They guarantee that keypad access and @@ -253,6 +296,23 @@ int main(void) { while (1) { char key; + if (opb_ble_take_nfc_enrollment()) { + if (nfc_scan_is_enabled()) { + stop_nfc_scan(&nfc_scan_deadline_ms, door_open); + } + nfc_enrollment_pending = true; + if (!start_nfc_scan(&nfc_scan_deadline_ms)) { + nfc_enrollment_pending = false; + opb_ble_set_nfc_enrollment_error("reader_unavailable"); + } else { + printf("NFC enrollment scan started\n"); + } + } + if (opb_ble_take_nfc_enrollment_cancel()) { + nfc_enrollment_pending = false; + stop_nfc_scan(&nfc_scan_deadline_ms, door_open); + } + if (!keypad_ready && k_uptime_get() >= next_keypad_retry_ms) { keypad_ready = keypad_init() == 0; next_keypad_retry_ms = k_uptime_get() + KEYPAD_RETRY_MS; @@ -307,6 +367,7 @@ int main(void) { } if (door_open && k_uptime_get() >= next_open_beep_ms) { + printf("Door open reminder beep\n"); beep_key(); next_open_beep_ms = k_uptime_get() + LOCK_OPEN_BEEP_INTERVAL_MS; } @@ -331,6 +392,64 @@ int main(void) { } } + if (nfc_scan_is_enabled() && + k_uptime_get() >= nfc_scan_deadline_ms) { + if (nfc_enrollment_pending) { + nfc_enrollment_pending = false; + opb_ble_set_nfc_enrollment_error("timeout"); + } + stop_nfc_scan(&nfc_scan_deadline_ms, door_open); + } + + if (nfc_scan_is_enabled()) { + uint8_t uid[NFC_TAG_UID_MAX_LENGTH]; + size_t uid_length; + + bool uid_detected = + nfc_read_detected_uid(uid, sizeof(uid), &uid_length); + if (k_uptime_get() >= nfc_scan_deadline_ms) { + /* Never accept a response that completed after the fixed deadline. */ + if (nfc_enrollment_pending) { + nfc_enrollment_pending = false; + opb_ble_set_nfc_enrollment_error("timeout"); + } + stop_nfc_scan(&nfc_scan_deadline_ms, door_open); + } else if (uid_detected) { + char tag_name[NFC_TAG_NAME_MAX_LENGTH + 1] = ""; + print_nfc_uid(uid, uid_length); + + if (nfc_enrollment_pending) { + nfc_enrollment_pending = false; + if (nfc_tags_find(uid, uid_length, tag_name)) { + printf("NFC enrollment rejected: tag already exists\n"); + signal_invalid_code(); + opb_ble_set_nfc_enrollment_error("already_exists"); + } else { + printf("NFC enrollment tag detected\n"); + beep_success(); + opb_ble_set_nfc_enrollment_uid(uid, uid_length); + } + stop_nfc_scan(&nfc_scan_deadline_ms, door_open); + } else if (nfc_tags_find(uid, uid_length, tag_name)) { + printf("NFC valid\n"); + beep_success(); + if (request_lock_open("nfc", tag_name) == 0) { + if (atomic_get(&persistent_services_ready) != 0) { + (void)open_history_append(OPEN_HISTORY_NFC_TAG, tag_name, + opb_clock_now_ms()); + } + opb_ble_notify_state_changed(); + } + stop_nfc_scan(&nfc_scan_deadline_ms, + lock_state_ready ? lock_state_is_open() : false); + } else { + printf("NFC not valid\n"); + signal_invalid_code(); + if (nfc_scan_is_enabled() && led_ready) led_set_blue(); + } + } + } + key = keypad_ready ? keypad_get_key() : 0; if (key == 0) { k_msleep(MAIN_LOOP_DELAY_MS); @@ -339,6 +458,9 @@ int main(void) { printf("Key pressed: %c\n", key); + /* Only the first key press opens the fixed 15-second NFC window. */ + (void)start_nfc_scan(&nfc_scan_deadline_ms); + if (key == '*' || key == '#' || key == 'B') { entered_length = 0; beep_key(); @@ -377,6 +499,8 @@ int main(void) { (void)access_codes_consume_if_one_time(entered_code, entered_length); opb_ble_notify_state_changed(); + stop_nfc_scan(&nfc_scan_deadline_ms, + lock_state_ready ? lock_state_is_open() : false); } } else { printf("Invalid access code\n"); diff --git a/firmware/app/src/nfc.c b/firmware/app/src/nfc.c index 44fbf69..529ecd1 100644 --- a/firmware/app/src/nfc.c +++ b/firmware/app/src/nfc.c @@ -4,39 +4,287 @@ * OpenParcelBox * Copyright (c) 2026 * - * NFC scan mode interface. + * PN532 NFC reader over I2C. */ #include "nfc.h" -#include +#include "board_config.h" +#include +#include + +#include +#include +#include +#include #include -static bool scan_enabled; +#define PN532_HOST_TO_PN532 0xd4 +#define PN532_PN532_TO_HOST 0xd5 +#define PN532_COMMAND_GET_FIRMWARE_VERSION 0x02 +#define PN532_COMMAND_SAM_CONFIGURATION 0x14 +#define PN532_COMMAND_POWER_DOWN 0x16 +#define PN532_COMMAND_RF_CONFIGURATION 0x32 +#define PN532_COMMAND_IN_LIST_PASSIVE_TARGET 0x4a +#define PN532_RESPONSE_TIMEOUT_MS 250 +#define PN532_WAKE_DELAY_MS 5 +#define PN532_READY_POLL_MS 5 +#define PN532_FRAME_MAX 40 +#define PN532_I2C_WAKEUP_ENABLE 0x80 +#define PN532_THREAD_STACK_SIZE 2048 +#define PN532_THREAD_PRIORITY 9 +#define PN532_IDLE_POLL_MS 20 + +static const struct device *i2c_dev = DEVICE_DT_GET(PN532_I2C_NODE); +static bool reader_ready; +static atomic_t scan_enabled; +static uint8_t last_uid[NFC_TAG_UID_MAX_LENGTH]; +static size_t last_uid_length; +static uint8_t pending_uid[NFC_TAG_UID_MAX_LENGTH]; +static size_t pending_uid_length; +static struct k_mutex command_mutex; +static struct k_mutex uid_mutex; +static K_THREAD_STACK_DEFINE(pn532_thread_stack, PN532_THREAD_STACK_SIZE); +static struct k_thread pn532_thread; + +static void pn532_thread_entry(void *unused1, void *unused2, void *unused3); + +static int pn532_wait_ready(int timeout_ms) { + int64_t deadline = k_uptime_get() + timeout_ms; + uint8_t status; + + do { + int ret = i2c_read(i2c_dev, &status, sizeof(status), PN532_I2C_ADDRESS); + if (ret == 0 && status == 0x01) return 0; + k_msleep(PN532_READY_POLL_MS); + } while (k_uptime_get() < deadline); + return -ETIMEDOUT; +} + +static int pn532_read_frame(uint8_t expected_response, uint8_t *payload, + size_t payload_capacity, size_t *payload_length) { + uint8_t frame[PN532_FRAME_MAX]; + uint8_t checksum = 0; + int ret = i2c_read(i2c_dev, frame, sizeof(frame), PN532_I2C_ADDRESS); + if (ret < 0) return ret; + if (frame[0] != 0x01 || frame[1] != 0x00 || frame[2] != 0x00 || + frame[3] != 0xff) return -EIO; + + size_t data_length = frame[4]; + if (data_length < 2 || data_length + 8 > sizeof(frame) || + (uint8_t)(frame[4] + frame[5]) != 0 || + frame[6] != PN532_PN532_TO_HOST || frame[7] != expected_response) { + return -EIO; + } + for (size_t i = 0; i < data_length; i++) { + checksum = (uint8_t)(checksum + frame[6 + i]); + } + if ((uint8_t)(checksum + frame[6 + data_length]) != 0) return -EIO; + + size_t response_payload_length = data_length - 2; + if (response_payload_length > payload_capacity) return -EMSGSIZE; + if (response_payload_length > 0 && payload != NULL) { + memcpy(payload, &frame[8], response_payload_length); + } + if (payload_length != NULL) *payload_length = response_payload_length; + return 0; +} + +static int pn532_command(uint8_t command, const uint8_t *parameters, + size_t parameter_length, uint8_t *response, + size_t response_capacity, size_t *response_length) { + static const uint8_t expected_ack[] = {0x01, 0x00, 0x00, 0xff, + 0x00, 0xff, 0x00}; + uint8_t frame[PN532_FRAME_MAX]; + uint8_t ack[sizeof(expected_ack)]; + size_t data_length = parameter_length + 2; + uint8_t checksum = PN532_HOST_TO_PN532; + if (parameter_length + 9 > sizeof(frame)) return -EMSGSIZE; + + frame[0] = 0x00; + frame[1] = 0x00; + frame[2] = 0xff; + frame[3] = (uint8_t)data_length; + frame[4] = (uint8_t)(0U - data_length); + frame[5] = PN532_HOST_TO_PN532; + frame[6] = command; + if (parameter_length > 0) memcpy(&frame[7], parameters, parameter_length); + checksum = (uint8_t)(checksum + command); + for (size_t i = 0; i < parameter_length; i++) { + checksum = (uint8_t)(checksum + parameters[i]); + } + frame[7 + parameter_length] = (uint8_t)(0U - checksum); + frame[8 + parameter_length] = 0x00; + + int ret = i2c_write(i2c_dev, frame, parameter_length + 9, + PN532_I2C_ADDRESS); + if (ret < 0) return ret; + ret = pn532_wait_ready(PN532_RESPONSE_TIMEOUT_MS); + if (ret < 0) return ret; + ret = i2c_read(i2c_dev, ack, sizeof(ack), PN532_I2C_ADDRESS); + if (ret < 0 || memcmp(ack, expected_ack, sizeof(ack)) != 0) return -EIO; + ret = pn532_wait_ready(PN532_RESPONSE_TIMEOUT_MS); + if (ret < 0) return ret; + return pn532_read_frame((uint8_t)(command + 1), response, + response_capacity, response_length); +} + +static int pn532_configure_reader(void) { + static const uint8_t sam_parameters[] = {0x01, 0x14, 0x01}; + static const uint8_t retry_parameters[] = {0x05, 0xff, 0x01, 0x01}; + uint8_t response[8]; + size_t response_length; + int ret = pn532_command(PN532_COMMAND_SAM_CONFIGURATION, sam_parameters, + sizeof(sam_parameters), response, sizeof(response), + &response_length); + if (ret < 0) return ret; + return pn532_command(PN532_COMMAND_RF_CONFIGURATION, retry_parameters, + sizeof(retry_parameters), response, sizeof(response), + &response_length); +} int nfc_init(void) { - scan_enabled = false; + uint8_t response[8]; + size_t response_length; + atomic_clear(&scan_enabled); + reader_ready = false; + last_uid_length = 0; + pending_uid_length = 0; + k_mutex_init(&command_mutex); + k_mutex_init(&uid_mutex); + if (!device_is_ready(i2c_dev)) return -ENODEV; + + int ret = pn532_command(PN532_COMMAND_GET_FIRMWARE_VERSION, NULL, 0, + response, sizeof(response), &response_length); + if (ret < 0 || response_length < 4) return ret < 0 ? ret : -EIO; + ret = pn532_configure_reader(); + if (ret < 0) return ret; + reader_ready = true; + ret = nfc_scan_disable(); + if (ret < 0) return ret; + + k_tid_t thread_id = + k_thread_create(&pn532_thread, pn532_thread_stack, + K_THREAD_STACK_SIZEOF(pn532_thread_stack), + pn532_thread_entry, NULL, NULL, NULL, + PN532_THREAD_PRIORITY, 0, K_NO_WAIT); + if (thread_id == NULL) return -ENOMEM; + (void)k_thread_name_set(&pn532_thread, "pn532"); return 0; } int nfc_scan_enable(void) { - scan_enabled = true; + uint8_t status; + if (!reader_ready) return -ENODEV; + if (atomic_get(&scan_enabled) != 0) return 0; + + k_mutex_lock(&command_mutex, K_FOREVER); + /* Addressing the PN532 wakes it from I2C power-down mode. */ + (void)i2c_read(i2c_dev, &status, sizeof(status), PN532_I2C_ADDRESS); + k_msleep(PN532_WAKE_DELAY_MS); + int ret = pn532_configure_reader(); + k_mutex_unlock(&command_mutex); + if (ret < 0) return ret; + last_uid_length = 0; + k_mutex_lock(&uid_mutex, K_FOREVER); + pending_uid_length = 0; + k_mutex_unlock(&uid_mutex); + atomic_set(&scan_enabled, 1); return 0; } int nfc_scan_disable(void) { - scan_enabled = false; - return 0; + static const uint8_t parameters[] = {PN532_I2C_WAKEUP_ENABLE}; + uint8_t response[4]; + size_t response_length; + atomic_clear(&scan_enabled); + last_uid_length = 0; + k_mutex_lock(&uid_mutex, K_FOREVER); + pending_uid_length = 0; + k_mutex_unlock(&uid_mutex); + if (!reader_ready) return 0; + k_mutex_lock(&command_mutex, K_FOREVER); + int ret = pn532_command(PN532_COMMAND_POWER_DOWN, parameters, + sizeof(parameters), response, sizeof(response), + &response_length); + k_mutex_unlock(&command_mutex); + return ret; } -bool nfc_scan_is_enabled(void) { return scan_enabled; } +bool nfc_scan_is_enabled(void) { return atomic_get(&scan_enabled) != 0; } + +static bool pn532_poll_uid(uint8_t *uid, size_t uid_capacity, + size_t *uid_length) { + static const uint8_t parameters[] = {0x01, 0x00}; + uint8_t response[24]; + size_t response_length; + if (atomic_get(&scan_enabled) == 0 || uid == NULL || uid_length == NULL) { + return false; + } + + int ret = pn532_command(PN532_COMMAND_IN_LIST_PASSIVE_TARGET, parameters, + sizeof(parameters), response, sizeof(response), + &response_length); + if (ret < 0 || response_length < 1 || response[0] == 0) { + last_uid_length = 0; + return false; + } + if (response_length < 6) return false; + + size_t detected_length = response[5]; + if (detected_length == 0 || detected_length > NFC_TAG_UID_MAX_LENGTH || + detected_length > uid_capacity || response_length < 6 + detected_length) { + return false; + } + if (last_uid_length == detected_length && + memcmp(last_uid, &response[6], detected_length) == 0) return false; + + memcpy(uid, &response[6], detected_length); + memcpy(last_uid, &response[6], detected_length); + last_uid_length = detected_length; + *uid_length = detected_length; + return true; +} + +static void pn532_thread_entry(void *unused1, void *unused2, void *unused3) { + uint8_t uid[NFC_TAG_UID_MAX_LENGTH]; + size_t uid_length; + + ARG_UNUSED(unused1); + ARG_UNUSED(unused2); + ARG_UNUSED(unused3); + + while (1) { + if (atomic_get(&scan_enabled) == 0) { + k_msleep(PN532_IDLE_POLL_MS); + continue; + } + + k_mutex_lock(&command_mutex, K_FOREVER); + bool detected = pn532_poll_uid(uid, sizeof(uid), &uid_length); + k_mutex_unlock(&command_mutex); + if (!detected || atomic_get(&scan_enabled) == 0) continue; + + k_mutex_lock(&uid_mutex, K_FOREVER); + memcpy(pending_uid, uid, uid_length); + pending_uid_length = uid_length; + k_mutex_unlock(&uid_mutex); + } +} bool nfc_read_detected_uid(uint8_t *uid, size_t uid_capacity, size_t *uid_length) { - ARG_UNUSED(uid); - ARG_UNUSED(uid_capacity); - ARG_UNUSED(uid_length); + if (uid == NULL || uid_length == NULL) return false; - return false; + k_mutex_lock(&uid_mutex, K_FOREVER); + if (pending_uid_length == 0 || pending_uid_length > uid_capacity) { + k_mutex_unlock(&uid_mutex); + return false; + } + memcpy(uid, pending_uid, pending_uid_length); + *uid_length = pending_uid_length; + pending_uid_length = 0; + k_mutex_unlock(&uid_mutex); + return true; } diff --git a/firmware/app/src/opb_ble.c b/firmware/app/src/opb_ble.c index 29c11a9..52ca4aa 100644 --- a/firmware/app/src/opb_ble.c +++ b/firmware/app/src/opb_ble.c @@ -58,8 +58,11 @@ static char state_json[OPB_BLE_STATE_MAX]; static size_t state_snapshot_length; static size_t state_page_start; static bool state_page_active; -static char enrollment_name[NFC_TAG_NAME_MAX_LENGTH + 1]; static bool enrollment_requested; +static bool enrollment_cancel_requested; +static bool enrollment_active; +static char enrollment_uid[3 * NFC_TAG_UID_MAX_LENGTH]; +static char enrollment_error[32]; static struct bt_conn *authenticated_conn; static struct app_identity authenticated_identity; static bool authenticated_identity_valid; @@ -396,7 +399,15 @@ static size_t build_state_json(void) { } } - append_text(state_json, sizeof(state_json), &offset, "],\"history\":["); + append_formatted( + state_json, sizeof(state_json), &offset, + "],\"nfc_enrollment\":{\"active\":%s,\"uid\":\"%s\",\"error\":\"%s\"},\"history\":[", + authenticated_identity.role == APP_IDENTITY_ADMIN && enrollment_active + ? "true" + : "false", + authenticated_identity.role == APP_IDENTITY_ADMIN ? enrollment_uid : "", + authenticated_identity.role == APP_IDENTITY_ADMIN ? enrollment_error + : ""); first = true; for (size_t i = 0; i < open_history_count(); i++) { struct open_history_event event; @@ -706,15 +717,26 @@ static int handle_command(struct bt_conn *conn, const char *json) { } if (strcmp(command, "start_nfc_enrollment") == 0) { - if (identity.role != APP_IDENTITY_ADMIN || - !json_get_string(json, "name", enrollment_name, - sizeof(enrollment_name))) { + if (identity.role != APP_IDENTITY_ADMIN) { return -EACCES; } + enrollment_uid[0] = '\0'; + enrollment_error[0] = '\0'; + enrollment_active = true; enrollment_requested = true; return 0; } + if (strcmp(command, "cancel_nfc_enrollment") == 0) { + if (identity.role != APP_IDENTITY_ADMIN) return -EACCES; + enrollment_active = false; + enrollment_requested = false; + enrollment_cancel_requested = true; + enrollment_uid[0] = '\0'; + enrollment_error[0] = '\0'; + return 0; + } + if (strcmp(command, "add_guest") == 0) { char name[APP_IDENTITY_NAME_MAX_LENGTH + 1]; char guest_key[APP_IDENTITY_KEY_HEX_LENGTH + 1]; @@ -753,16 +775,42 @@ static int handle_command(struct bt_conn *conn, const char *json) { return -ENOTSUP; } -bool opb_ble_take_nfc_enrollment(char *name, size_t name_size) { - if (!enrollment_requested || name == NULL || name_size == 0) { - return false; - } - strncpy(name, enrollment_name, name_size - 1); - name[name_size - 1] = '\0'; +bool opb_ble_take_nfc_enrollment(void) { + if (!enrollment_requested) return false; enrollment_requested = false; return true; } +bool opb_ble_take_nfc_enrollment_cancel(void) { + if (!enrollment_cancel_requested) return false; + enrollment_cancel_requested = false; + return true; +} + +void opb_ble_set_nfc_enrollment_uid(const uint8_t *uid, size_t uid_length) { + size_t offset = 0; + enrollment_uid[0] = '\0'; + for (size_t i = 0; uid != NULL && i < uid_length; i++) { + int written = snprintf(enrollment_uid + offset, + sizeof(enrollment_uid) - offset, "%s%02X", + i == 0 ? "" : ":", uid[i]); + if (written < 0 || (size_t)written >= sizeof(enrollment_uid) - offset) break; + offset += (size_t)written; + } + enrollment_active = false; + enrollment_error[0] = '\0'; + opb_ble_notify_state_changed(); +} + +void opb_ble_set_nfc_enrollment_error(const char *error) { + enrollment_active = false; + enrollment_uid[0] = '\0'; + strncpy(enrollment_error, error == NULL ? "reader_error" : error, + sizeof(enrollment_error) - 1); + enrollment_error[sizeof(enrollment_error) - 1] = '\0'; + opb_ble_notify_state_changed(); +} + static ssize_t command_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, uint16_t offset, uint8_t flags) { diff --git a/firmware/app/src/opb_ble.h b/firmware/app/src/opb_ble.h index 128f328..e11c9a6 100644 --- a/firmware/app/src/opb_ble.h +++ b/firmware/app/src/opb_ble.h @@ -39,7 +39,10 @@ bool opb_ble_get_unix_time_ms(int64_t *unix_ms); * @brief Notify connected clients that the readable state changed. */ void opb_ble_notify_state_changed(void); -bool opb_ble_take_nfc_enrollment(char *name, size_t name_size); +bool opb_ble_take_nfc_enrollment(void); +bool opb_ble_take_nfc_enrollment_cancel(void); +void opb_ble_set_nfc_enrollment_uid(const uint8_t *uid, size_t uid_length); +void opb_ble_set_nfc_enrollment_error(const char *error); void opb_ble_clear_bonds_after_reset(void); #endif /* OPB_BLE_H */ diff --git a/hardware/BOM.md b/hardware/BOM.md index 2e94af9..2346d18 100644 --- a/hardware/BOM.md +++ b/hardware/BOM.md @@ -41,13 +41,13 @@ The original keypad PCB is replaced by a new PCB compatible with the original fr The nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware and is not a standalone active reader for passive badges. -Badge UID reading requires a dedicated NFC reader circuit on the production PCB. +Badge UID reading uses a PN532 I2C module during development. A production PCB +implementation still needs placement, range, antenna, and interference validation. | Description | Quantity | Status | | --- | --- | --- | -| NFC reader IC | 1 | To select | -| NFC reader antenna, 13.56 MHz | 1 | To design | -| NFC reader matching network | 1 | To design | +| PN532 NFC/RFID I2C module with antenna | 1 | Integrated for development | +| Production NFC reader implementation | 1 | To design from PN532 validation | --- @@ -57,8 +57,9 @@ The lock opens when a positive 8 to 24 V pulse is applied to the `SIG` input. | Reference | Description | Quantity | Status | | --- | --- | --- | --- | -| Logic-level P-channel MOSFET or equivalent high-side driver | `SIG` input driver | 1 | To select | -| Bias resistors | According to schematic | TBD | To define | +| S8050 NPN + S8550 PNP stage | Development `SIG` high-side driver | 1 each | Integrated for development | +| Bias resistors | Development transistor stage | 4 | Integrated for development | +| Production high-side driver | Final `SIG` input driver | 1 | To select after validation | --- @@ -138,7 +139,7 @@ The original PCB LEDs are not considered reusable in the current plan. ### NFC - Do not rely on the nRF52840 NFCT peripheral for passive badge UID reading. -- Select a dedicated NFC reader circuit for badge unlock. +- Validate the PN532 module for badge-unlock range and interference. - Integrate and tune the NFC reader antenna on the production PCB. --- @@ -147,7 +148,7 @@ The original PCB LEDs are not considered reusable in the current plan. High priority: -- NFC reader IC selection. +- Production implementation based on PN532 validation. - NFC reader antenna design. - High-side lock driver selection. - Buck converter selection. diff --git a/hardware/README.md b/hardware/README.md index f975108..625fe31 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -2,15 +2,20 @@ This directory contains the hardware documentation for OpenParcelBox. +## Current Documentation + +- `BOM.md`: development and planned production components. +- `interfaces.md`: board-independent electrical interfaces. +- `pinout.md`: current XIAO development assignments. +- `reverse-engineering/`: validated Boks One and KR-S79 measurements. + ## Planned Content - KiCad schematics. - PCB files. - Gerber files. -- Bill of materials. - 3D models. - Mechanical drawings. -- Wiring documentation. -- Reverse-engineering notes. +- Production wiring and assembly documentation. The goal is to make the electronics fully reproducible while reusing as much of the original Boks One hardware as possible. diff --git a/hardware/interfaces.md b/hardware/interfaces.md index 6a515e2..9ef942a 100644 --- a/hardware/interfaces.md +++ b/hardware/interfaces.md @@ -30,7 +30,9 @@ The nRF52840 integrated NFC peripheral is NFCT hardware. It supports the tag-sid It is not a standalone active NFC reader for passive badges. -Credential badge reading therefore requires a dedicated NFC reader circuit on the production PCB. +Credential badge reading currently uses a PN532 module in I2C mode (`1 0`) at +the fixed 7-bit address `0x24`. It shares XIAO `SDA` and `SCL` with the PCF8574 +keypad expander at `0x20`. Functions: @@ -38,7 +40,9 @@ Functions: - Badge validation. - Unlock request trigger. -The reader IC, antenna, and matching network still need to be selected, designed, and validated. +The PN532 module and its integrated antenna provide the development reader. +Reader placement, range, interference, and the production PCB implementation +still need physical validation. --- @@ -46,15 +50,16 @@ The reader IC, antenna, and matching network still need to be selected, designed Command: -- Positive pulse on `SIG`. +- 150 ms positive pulse on `SIG` in the current development firmware. Validation: -The firmware must not validate opening based on a fixed delay. +The current firmware monitors the `COM/NC` contact independently for open-state +LED, serial, and buzzer feedback. Correlating that transition with the command +pulse and reporting a timeout is planned; opening must not ultimately be +validated from a fixed delay alone. -Opening validation relies on the `COM/NC` contact state change. - -Sequence: +Target validation sequence: ```text SIG command diff --git a/hardware/pinout.md b/hardware/pinout.md index 59d3ee8..c71b674 100644 --- a/hardware/pinout.md +++ b/hardware/pinout.md @@ -23,9 +23,12 @@ Seeed Studio XIAO BLE nRF52840 | Active buzzer control | Output | D0 | GPIO signal to transistor driver | | Lock NC feedback | Input | D7 | KR-S79 NC contact, internal pull-up enabled | | Lock COM feedback reference | Output | D8 | KR-S79 COM contact, driven low for NC continuity detection | -| Lock SIG command | Output | D9 | Active-high GPIO pulse to S8050/S8550 driver, no PWM | +| Lock SIG command | Output | D9 | 150 ms active-high GPIO pulse to S8050/S8550 driver, no PWM | +| Internal identity reset | Input | P1.11 | Active low with internal pull-up; button to GND | | PCF8574 I2C SDA | I/O | SDA | Development keypad GPIO expander | | PCF8574 I2C SCL | Output | SCL | Development keypad GPIO expander | +| PN532 I2C SDA | I/O | SDA | Shared bus, fixed 7-bit address `0x24` | +| PN532 I2C SCL | Output | SCL | Shared bus, module switches `1 0` | | RGB LED red | Output | Board LED alias `led0` | On-board validation LED | | RGB LED green | Output | Board LED alias `led1` | On-board validation LED | | RGB LED blue | Output | Board LED alias `led2` | On-board validation LED | diff --git a/hardware/reverse-engineering/reverse-engineering.md b/hardware/reverse-engineering/reverse-engineering.md index 5bdd50f..fe688e0 100644 --- a/hardware/reverse-engineering/reverse-engineering.md +++ b/hardware/reverse-engineering/reverse-engineering.md @@ -172,7 +172,9 @@ To be completed. The lock is controlled by the Seeed Studio XIAO BLE nRF52840. -Selected principle: +The current development firmware generates a 150 ms positive `SIG` pulse and +monitors `COM/NC` independently for open-state feedback. The remaining target +for command-result validation is: 1. Generate a positive pulse on `SIG`. 2. Start a timeout. diff --git a/mobile-app/app/README.md b/mobile-app/app/README.md index 5d3ce04..dfa3dcb 100644 --- a/mobile-app/app/README.md +++ b/mobile-app/app/README.md @@ -91,9 +91,13 @@ unused record when needed. modify permanent codes. Random generation excludes every currently active permanent and temporary value. Administrators can also edit permanent codes through the six-digit numeric editor; choosing an existing temporary value - requires confirmation before converting it to permanent. + requires confirmation before converting it to permanent. Every permanent or + temporary code row has a local clipboard action before the edit action; it + remains available while the box is offline. - NFC tag listing, renaming, deletion, manual UID entry, phone NFC scanning, - and enrollment through the box reader. + and a box-reader enrollment modal that scans for 15 seconds, keeps errors + visible, asks for a name only after detecting a new UID, and refreshes the + authoritative tag list after validation. - Named guest creation, invitation QR display, and revocation. - Firmware clock synchronization after connection. - Language override, encrypted backup/restore, factory reset, and local removal diff --git a/mobile-app/app/lib/app_strings.dart b/mobile-app/app/lib/app_strings.dart index e043547..e061a58 100644 --- a/mobile-app/app/lib/app_strings.dart +++ b/mobile-app/app/lib/app_strings.dart @@ -34,6 +34,7 @@ class AppStrings { 'add': {'fr': 'Ajouter', 'en': 'Add'}, 'delete': {'fr': 'Supprimer', 'en': 'Delete'}, 'edit': {'fr': 'Modifier', 'en': 'Edit'}, + 'copy_code': {'fr': 'Copier le code', 'en': 'Copy code'}, 'cancel': {'fr': 'Annuler', 'en': 'Cancel'}, 'save': {'fr': 'Enregistrer', 'en': 'Save'}, 'scan': {'fr': 'Scanner', 'en': 'Scan'}, @@ -129,6 +130,31 @@ class AppStrings { 'tag_name': {'fr': 'Nom du tag', 'en': 'Tag name'}, 'tag_uid': {'fr': 'UID du tag', 'en': 'Tag UID'}, 'pairing_mode': {'fr': 'Mode appairage', 'en': 'Pairing mode'}, + 'nfc_pairing_title': { + 'fr': 'Appairage d’un tag NFC', + 'en': 'Pair an NFC tag', + }, + 'nfc_pairing_wait': { + 'fr': + 'Présentez un nouveau tag au lecteur de la box dans les 15 secondes.', + 'en': 'Hold a new tag near the box reader within 15 seconds.', + }, + 'nfc_pairing_detected': { + 'fr': 'Tag détecté. Choisissez un nom avant de l’autoriser.', + 'en': 'Tag detected. Choose a name before authorizing it.', + }, + 'nfc_pairing_timeout': { + 'fr': 'Aucun tag n’a été détecté dans les 15 secondes.', + 'en': 'No tag was detected within 15 seconds.', + }, + 'nfc_pairing_exists': { + 'fr': 'Ce tag est déjà présent dans la liste des tags autorisés.', + 'en': 'This tag is already in the authorized tag list.', + }, + 'nfc_reader_unavailable': { + 'fr': 'Le lecteur NFC de la box est indisponible.', + 'en': 'The box NFC reader is unavailable.', + }, 'add_manually': {'fr': 'Ajouter manuellement', 'en': 'Add manually'}, 'nfc_unavailable': { 'fr': 'Le NFC est indisponible sur ce téléphone.', diff --git a/mobile-app/app/lib/ble_controller.dart b/mobile-app/app/lib/ble_controller.dart index 2c688ad..e0d2e3a 100644 --- a/mobile-app/app/lib/ble_controller.dart +++ b/mobile-app/app/lib/ble_controller.dart @@ -90,6 +90,9 @@ class OpenParcelBoxBleController extends ChangeNotifier { bool isScanning = false; bool isBusy = false; bool isAuthenticated = false; + bool nfcEnrollmentActive = false; + String? nfcEnrollmentUid; + String? nfcEnrollmentError; String status = 'disconnected'; String? errorMessage; @@ -719,11 +722,11 @@ class OpenParcelBoxBleController extends ChangeNotifier { }), ); - Future startNfcEnrollment(String name) => send( - OpenParcelBoxCommand('start_nfc_enrollment', { - 'name': name.trim(), - }), - ); + Future startNfcEnrollment() => + send(const OpenParcelBoxCommand('start_nfc_enrollment')); + + Future cancelNfcEnrollment() => + send(const OpenParcelBoxCommand('cancel_nfc_enrollment')); Future removeNfcTag(NfcTag tag) => send( OpenParcelBoxCommand('remove_nfc_tag', {'uid': tag.uid}), @@ -932,6 +935,18 @@ class OpenParcelBoxBleController extends ChangeNotifier { void _applyState(Map state, {bool notify = true}) { _clearCollections(); + final enrollment = state['nfc_enrollment']; + if (enrollment is Map) { + nfcEnrollmentActive = enrollment['active'] == true; + final uid = enrollment['uid']; + nfcEnrollmentUid = uid is String && uid.isNotEmpty ? uid : null; + final error = enrollment['error']; + nfcEnrollmentError = error is String && error.isNotEmpty ? error : null; + } else { + nfcEnrollmentActive = false; + nfcEnrollmentUid = null; + nfcEnrollmentError = null; + } for (final item in (state['codes'] as List? ?? const [])) { if (item is! Map || item['code'] is! String || @@ -1041,6 +1056,9 @@ class OpenParcelBoxBleController extends ChangeNotifier { nfcTags.clear(); history.clear(); guests.clear(); + nfcEnrollmentActive = false; + nfcEnrollmentUid = null; + nfcEnrollmentError = null; } String _generateUniqueCode() { diff --git a/mobile-app/app/lib/main.dart b/mobile-app/app/lib/main.dart index a6d5fcb..1322111 100644 --- a/mobile-app/app/lib/main.dart +++ b/mobile-app/app/lib/main.dart @@ -607,37 +607,44 @@ class _OpenParcelBoxHomeState extends State { (code) => _DataRowCard( icon: Icons.password, title: code.code, - actions: hasModifyPermission - ? [ - if (kind == CredentialKind.permanent) - IconButton( - onPressed: canModify - ? () async { - final changed = await _editPermanentCode( - code, - ); - if (changed) { - setModalState(() {}); - } - } - : null, - icon: const Icon(Icons.edit_outlined), - tooltip: strings.text('edit_code'), - ), - IconButton( - onPressed: canModify - ? () async { - await _guarded( - () => _controller.removeCode(code), - ); + actions: [ + IconButton( + key: ValueKey('copy-code-${code.id}'), + onPressed: () => + Clipboard.setData(ClipboardData(text: code.code)), + icon: const Icon(Icons.copy_outlined), + tooltip: strings.text('copy_code'), + ), + if (hasModifyPermission) ...[ + if (kind == CredentialKind.permanent) + IconButton( + onPressed: canModify + ? () async { + final changed = await _editPermanentCode( + code, + ); + if (changed) { setModalState(() {}); } - : null, - icon: const Icon(Icons.delete_outline), - tooltip: strings.text('delete'), - ), - ] - : const [], + } + : null, + icon: const Icon(Icons.edit_outlined), + tooltip: strings.text('edit_code'), + ), + IconButton( + onPressed: canModify + ? () async { + await _guarded( + () => _controller.removeCode(code), + ); + setModalState(() {}); + } + : null, + icon: const Icon(Icons.delete_outline), + tooltip: strings.text('delete'), + ), + ], + ], ), ), ], @@ -831,12 +838,13 @@ class _OpenParcelBoxHomeState extends State { } Future _addNfcFromBox(StateSetter refreshModal) async { - final name = await _askText( - strings.text('tag_name'), - description: strings.text('pairing_name_help'), + final added = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => + NfcEnrollmentDialog(controller: _controller, strings: strings), ); - if (name != null) { - await _guarded(() => _controller.startNfcEnrollment(name)); + if (added == true && mounted) { refreshModal(() {}); } } @@ -2006,6 +2014,162 @@ class _EmptyRow extends StatelessWidget { ); } +class NfcEnrollmentDialog extends StatefulWidget { + const NfcEnrollmentDialog({ + required this.controller, + required this.strings, + super.key, + }); + + final OpenParcelBoxBleController controller; + final AppStrings strings; + + @override + State createState() => _NfcEnrollmentDialogState(); +} + +class _NfcEnrollmentDialogState extends State { + final TextEditingController _nameController = TextEditingController(); + String? _localError; + bool _saving = false; + bool _completed = false; + + @override + void initState() { + super.initState(); + unawaited(_start()); + } + + Future _start() async { + try { + await widget.controller.startNfcEnrollment(); + } catch (error) { + if (mounted) setState(() => _localError = '$error'); + } + } + + String _firmwareError(String error) => switch (error) { + 'timeout' => widget.strings.text('nfc_pairing_timeout'), + 'already_exists' => widget.strings.text('nfc_pairing_exists'), + 'reader_unavailable' => widget.strings.text('nfc_reader_unavailable'), + _ => error, + }; + + Future _cancel() async { + try { + await widget.controller.cancelNfcEnrollment(); + } catch (_) { + // Closing the local modal must remain possible after a disconnect. + } + if (mounted) Navigator.of(context).pop(false); + } + + Future _save(String uid) async { + final name = _nameController.text.trim(); + if (name.isEmpty || _saving) return; + setState(() { + _saving = true; + _localError = null; + }); + try { + await widget.controller.addNfcTag(uid, name); + _completed = true; + if (mounted) Navigator.of(context).pop(true); + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _localError = '$error'; + }); + } + } + } + + @override + void dispose() { + _nameController.dispose(); + if (!_completed && widget.controller.nfcEnrollmentActive) { + unawaited(widget.controller.cancelNfcEnrollment().catchError((_) {})); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: widget.controller, + builder: (context, child) { + final uid = widget.controller.nfcEnrollmentUid; + final firmwareError = widget.controller.nfcEnrollmentError; + final error = + _localError ?? + (firmwareError == null ? null : _firmwareError(firmwareError)); + + return AlertDialog( + key: const ValueKey('nfc-enrollment-modal'), + title: Text(widget.strings.text('nfc_pairing_title')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (uid == null && error == null) ...[ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 18), + Text(widget.strings.text('nfc_pairing_wait')), + ], + if (uid != null) ...[ + Text(widget.strings.text('nfc_pairing_detected')), + const SizedBox(height: 8), + Text(uid, key: const ValueKey('nfc-enrollment-uid')), + const SizedBox(height: 12), + TextField( + key: const ValueKey('nfc-enrollment-name'), + controller: _nameController, + autofocus: true, + maxLength: 31, + decoration: InputDecoration( + labelText: widget.strings.text('tag_name'), + ), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _save(uid), + ), + ], + if (error != null) ...[ + Text( + error, + key: const ValueKey('nfc-enrollment-error'), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: _saving ? null : _cancel, + child: Text( + error == null + ? widget.strings.text('cancel') + : widget.strings.text('close'), + ), + ), + if (uid != null) + FilledButton( + key: const ValueKey('nfc-enrollment-save'), + onPressed: _saving || _nameController.text.trim().isEmpty + ? null + : () => _save(uid), + child: _saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.strings.text('save')), + ), + ], + ); + }, + ); +} + class _RegistrationStatus extends StatelessWidget { const _RegistrationStatus({ required this.error, diff --git a/mobile-app/app/test/widget_test.dart b/mobile-app/app/test/widget_test.dart index 8c2925d..b5d56bc 100644 --- a/mobile-app/app/test/widget_test.dart +++ b/mobile-app/app/test/widget_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:app/backup_service.dart'; +import 'package:app/app_strings.dart'; import 'package:app/ble_controller.dart'; import 'package:app/main.dart'; import 'package:app/models.dart'; @@ -531,6 +532,82 @@ void main() { controller.dispose(); }); + test('NFC enrollment state and commands use the two-step box flow', () async { + const box = SavedBox( + remoteId: 'AA:BB', + name: 'Front gate', + identityKey: '1234', + role: BoxRole.administrator, + ); + final controller = OpenParcelBoxBleController( + store: _MemoryStore( + boxes: const [box], + cachedStates: >{ + box.identityKey: { + 'nfc_enrollment': { + 'active': false, + 'uid': '60:4F:E2:B5', + 'error': '', + }, + }, + }, + ), + reconnectOnInitialize: false, + ); + await controller.initialize(); + expect(controller.nfcEnrollmentUid, '60:4F:E2:B5'); + expect(controller.nfcEnrollmentError, isNull); + controller.dispose(); + + final recording = _RecordingController(random: _SequenceRandom([])); + await recording.startNfcEnrollment(); + expect(recording.lastCommand?.name, 'start_nfc_enrollment'); + expect(recording.lastCommand?.payload, isEmpty); + await recording.cancelNfcEnrollment(); + expect(recording.lastCommand?.name, 'cancel_nfc_enrollment'); + recording.dispose(); + }); + + testWidgets('NFC enrollment keeps errors visible then shows naming field', ( + WidgetTester tester, + ) async { + final controller = _RecordingController(random: _SequenceRandom([])) + ..nfcEnrollmentError = 'timeout'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NfcEnrollmentDialog( + controller: controller, + strings: AppStrings(const Locale('en')), + ), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('nfc-enrollment-modal')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('nfc-enrollment-error')), + findsOneWidget, + ); + expect(find.text('No tag was detected within 15 seconds.'), findsOneWidget); + + controller + ..nfcEnrollmentError = null + ..nfcEnrollmentUid = '60:4F:E2:B5' + ..notifyListeners(); + await tester.pump(); + expect( + find.byKey(const ValueKey('nfc-enrollment-name')), + findsOneWidget, + ); + expect(find.text('60:4F:E2:B5'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + testWidgets('administrator compact actions share one bottom row', ( WidgetTester tester, ) async { @@ -689,6 +766,88 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('permanent and temporary codes can be copied', ( + WidgetTester tester, + ) async { + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = + (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MyApp( + store: _MemoryStore( + boxes: const [ + SavedBox( + remoteId: 'AA:BB', + name: 'Front gate', + identityKey: 'ADMIN', + role: BoxRole.administrator, + ), + ], + cachedStates: >{ + 'ADMIN': { + 'codes': >[ + { + 'slot': 0, + 'code': '123456', + 'kind': 'permanent', + }, + { + 'slot': 1, + 'code': '654321', + 'kind': 'one_time', + }, + ], + }, + }, + ), + splashDuration: Duration.zero, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('permanent-code-action')), + ); + await tester.pumpAndSettle(); + final copyButton = find.byTooltip('Copy code'); + expect(copyButton, findsOneWidget); + expect( + tester.getRect(copyButton).left, + lessThan(tester.getRect(find.byTooltip('Edit code')).left), + ); + await tester.tap(copyButton); + await tester.pump(); + expect(clipboardText, '123456'); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('temporary-code-action')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Copy code')); + await tester.pump(); + expect(clipboardText, '654321'); + expect(tester.takeException(), isNull); + }); + testWidgets('registration opens the styled setup modal', ( WidgetTester tester, ) async {