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