# 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)

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.
