# 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: Content-Type: application/json { "doorId": "front-door", "cardCredential": "" } ``` 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.