Files
sapien/docs/nfc-door.md

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

  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:

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:

ESP32 to PN532 photo wiring overlay

Breadboard layout:

ESP32 to PN532 breadboard wiring diagram

Clean schematic:

ESP32 to PN532 wiring diagram

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:

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:

  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.