7.3 KiB
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
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:
ESP32 -> L484 API -> Home Assistant webhook/service call -> lock.unlock
Alternative setup:
ESP32 -> L484 API -> Z-Wave JS API/MQTT -> Kwikset unlock
Home Assistant is likely easier to operate and inspect.
Access Flow
- Member taps NFC card on the PN532 reader.
- ESP32 reads the card credential.
- ESP32 sends the credential to the local L484 backend.
- Backend checks:
- card exists,
- card is active,
- member exists,
- member is paid/active,
- member is not suspended/revoked/expired.
- Backend records an access log.
- If allowed, the backend calls the local Home Assistant webhook.
- Home Assistant/Z-Wave JS sends unlock to the Kwikset 918 through the Zooz stick.
Controller API
The ESP32 calls:
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:
{
"allow": true,
"reason": "active_member_card",
"member": {
"membershipId": "L484-2026-XXXXXX",
"fullName": "Member Name",
"status": "active"
}
}
Denied response:
{
"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:
Breadboard layout:
Clean schematic:
Typical wiring:
- PN532
VCC-> ESP323V3 - PN532
GND-> ESP32GND - 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
- Plug Zooz ZST39 LR into the on-prem server.
- Add it to Home Assistant or Z-Wave JS.
- Pair the Kwikset Home Connect 918 with S2 security if supported.
- Confirm manual
lock.unlockworks from Home Assistant/Z-Wave JS. - Create a local-only webhook/service path that unlocks only after L484 backend approval.
- Set
HOME_ASSISTANT_UNLOCK_WEBHOOK_URLin 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:
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:
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:
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:
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:
- Copy
firmware/esp32-pn532-door/include/l484_door_config.example.htol484_door_config.hand fill in local values. - Flash ESP32 and confirm PN532 card reads over serial.
- Pair Zooz ZST39 LR and Kwikset 918 in Home Assistant/Z-Wave JS.
- Create the Home Assistant webhook automation and set
HOME_ASSISTANT_UNLOCK_WEBHOOK_URL. - Test with one development member, one active card, and one suspended member.