Files
sapien/docs/nfc-door.md

253 lines
7.3 KiB
Markdown

# NFC Door Plan: Kwikset Home Connect 918 + Zooz ZST39 LR
This plan is for adding L484 member-card access to the existing **Kwikset Home Connect 918** lock.
The Kwikset 918 is a Z-Wave lock. It does not read NFC cards directly, so the NFC reader is only the credential scanner. The actual unlock command must be sent through a Z-Wave controller.
## Hardware
- Lock: Kwikset Home Connect 918.
- NFC reader: PN532 NFC V3 Module Reader/Writer board.
- Reader controller: ESP32 dev board.
- Z-Wave coordinator: Zooz 800 Series Z-Wave Long Range S2 USB Stick ZST39 LR.
- On-prem host: a local server running the L484 backend plus either Home Assistant or Z-Wave JS.
## Architecture
```text
Member NFC card
-> PN532 reader
-> ESP32 over I2C/SPI
-> L484 backend /api/access/check
-> allow/deny decision and access log
-> local Z-Wave bridge
-> Zooz ZST39 LR
-> Kwikset Home Connect 918 unlock command
```
The ESP32 should not directly unlock the Kwikset. It should only read the card and ask the local backend whether access is allowed.
## Local Services
Run these on the on-prem server:
- L484 backend and admin app.
- Z-Wave JS or Home Assistant with Z-Wave JS integration.
- Zooz ZST39 LR USB stick paired to the Kwikset 918.
The simplest operational setup is Home Assistant:
```text
ESP32 -> L484 API -> Home Assistant webhook/service call -> lock.unlock
```
Alternative setup:
```text
ESP32 -> L484 API -> Z-Wave JS API/MQTT -> Kwikset unlock
```
Home Assistant is likely easier to operate and inspect.
## Access Flow
1. Member taps NFC card on the PN532 reader.
2. ESP32 reads the card credential.
3. ESP32 sends the credential to the local L484 backend.
4. Backend checks:
- card exists,
- card is active,
- member exists,
- member is paid/active,
- member is not suspended/revoked/expired.
5. Backend records an access log.
6. If allowed, the backend calls the local Home Assistant webhook.
7. Home Assistant/Z-Wave JS sends unlock to the Kwikset 918 through the Zooz stick.
## Controller API
The ESP32 calls:
```http
POST /api/access/check
X-Controller-Token: <ACCESS_CONTROLLER_TOKEN>
Content-Type: application/json
{
"doorId": "front-door",
"cardCredential": "<secret read from NFC card>"
}
```
Allowed response:
```json
{
"allow": true,
"reason": "active_member_card",
"member": {
"membershipId": "L484-2026-XXXXXX",
"fullName": "Member Name",
"status": "active"
}
}
```
Denied response:
```json
{
"allow": false,
"reason": "member_suspended",
"member": {
"membershipId": "L484-2026-XXXXXX",
"fullName": "Member Name",
"status": "suspended"
}
}
```
## PN532 to ESP32
Start with I2C.
Photo overlay using the supplied board pictures:
![ESP32 to PN532 photo wiring overlay](assets/esp32-pn532-photo-overlay.svg)
Breadboard layout:
![ESP32 to PN532 breadboard wiring diagram](assets/esp32-pn532-breadboard.svg)
Clean schematic:
![ESP32 to PN532 wiring diagram](assets/esp32-pn532-wiring.svg)
Typical wiring:
- PN532 `VCC` -> ESP32 `3V3`
- PN532 `GND` -> ESP32 `GND`
- PN532 `SDA` -> ESP32 GPIO 21
- PN532 `SCL` -> ESP32 GPIO 22
- PN532 `IRQ` -> ESP32 GPIO 4
- PN532 `RSTO/RST` -> ESP32 GPIO 5
If reads are unreliable, switch to SPI and keep the cable short.
## Card Credential Model
Do not rely on raw UID-only cards for production.
Preferred first version:
- Write a random card secret to a writable NFC card/tag.
- Register that secret in the L484 admin panel when issuing the card.
- Backend stores only `HMAC_SHA256(secret, ACCESS_HMAC_KEY)`.
- ESP32 reads and submits the secret.
Raw UID can be retained as a fallback identifier for testing, but it is cloneable and should not be the production access credential.
## Z-Wave / Kwikset Setup
1. Plug Zooz ZST39 LR into the on-prem server.
2. Add it to Home Assistant or Z-Wave JS.
3. Pair the Kwikset Home Connect 918 with S2 security if supported.
4. Confirm manual `lock.unlock` works from Home Assistant/Z-Wave JS.
5. Create a local-only webhook/service path that unlocks only after L484 backend approval.
6. Set `HOME_ASSISTANT_UNLOCK_WEBHOOK_URL` in the L484 backend environment.
The unlock call should stay on the LAN. Do not expose Home Assistant or Z-Wave JS publicly for this.
## Home Assistant Webhook
Use a Home Assistant automation webhook as the backend target. The webhook should call `lock.unlock` for the Kwikset 918 entity.
Example automation shape:
```yaml
alias: L484 approved member unlock
trigger:
- platform: webhook
webhook_id: l484-approved-unlock-random-secret
allowed_methods:
- POST
local_only: true
action:
- service: lock.unlock
target:
entity_id: lock.kwikset_918
mode: single
```
Set the L484 backend value to the local webhook URL:
```bash
HOME_ASSISTANT_UNLOCK_WEBHOOK_URL=http://homeassistant.local:8123/api/webhook/l484-approved-unlock-random-secret
```
The backend posts member/card metadata to the webhook body for future logging or notifications.
## Security Rules
- Keep ESP32, power conversion, and wiring inside.
- Mount only the PN532 reader outside, or protect the wiring if the reader must be outside.
- Use HTTPS or a trusted isolated LAN between ESP32 and backend where practical.
- Use a strong `ACCESS_CONTROLLER_TOKEN`.
- Use a strong `ACCESS_HMAC_KEY`.
- Fail locked if backend, Wi-Fi, Home Assistant, or Z-Wave is unavailable.
- Log every allow/deny attempt.
- Do not store raw card secrets in backend data.
- Do not put Nostr nsec keys on cards.
## Failure Behavior
- If card read fails: deny.
- If backend request times out: deny.
- If backend says deny: do not call Z-Wave.
- If Z-Wave unlock fails: log locally and deny/retry once only.
- If internet is down but LAN is up: system should still work because all services are local.
## Development Access Tests
When `DEV_SEED_MEMBERS=true`, the backend seeds active and suspended members with matching test card credentials.
Example active-card request:
```bash
curl -X POST http://localhost:3001/api/access/check \
-H "Content-Type: application/json" \
-H "X-Controller-Token: $ACCESS_CONTROLLER_TOKEN" \
-d '{"doorId":"front-door","cardCredential":"dev-card-1-L484-2026-DEV07X"}'
```
Expected result: `allow: true`.
Example suspended-card request:
```bash
curl -X POST http://localhost:3001/api/access/check \
-H "Content-Type: application/json" \
-H "X-Controller-Token: $ACCESS_CONTROLLER_TOKEN" \
-d '{"doorId":"front-door","cardCredential":"dev-card-4-L484-2026-DEV10X"}'
```
Expected result: `allow: false`.
If `HOME_ASSISTANT_UNLOCK_WEBHOOK_URL` is configured, only the active controller request attempts the Home Assistant unlock webhook.
## Next Build Tasks
Completed in this repo:
- ESP32 firmware scaffold with configurable backend URL, controller token, door ID, and PN532 mode.
- Backend approved-access hook for Home Assistant/Z-Wave unlock.
- Decision: L484 backend is responsible for the final Home Assistant unlock call.
Remaining when hardware arrives:
1. Copy `firmware/esp32-pn532-door/include/l484_door_config.example.h` to `l484_door_config.h` and fill in local values.
2. Flash ESP32 and confirm PN532 card reads over serial.
3. Pair Zooz ZST39 LR and Kwikset 918 in Home Assistant/Z-Wave JS.
4. Create the Home Assistant webhook automation and set `HOME_ASSISTANT_UNLOCK_WEBHOOK_URL`.
5. Test with one development member, one active card, and one suspended member.