feat: access extension — NFC door access via boltcards SUN + Home Assistant

Promoted from the door-portal scratch repo to its own repo for install via the
aiolabs catalog. Authenticates a tapped Bolt Card via boltcards /verify,
authorizes against per-door grants, and fires the door's local Home Assistant
webhook to unlock a Z-Wave lock. Fails closed; logs every attempt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-07 22:47:41 +02:00
commit 40564f06ec
22 changed files with 986 additions and 0 deletions

56
services.py Normal file
View file

@ -0,0 +1,56 @@
import httpx
from lnbits.settings import settings
from .models import Door
def _boltcards_base(door: Door) -> str:
"""Where to SUN-verify the tapped card.
Defaults to this same on-prem LNbits instance's boltcards extension. The
`access` extension runs in the same process/host, so the loopback base URL
is reachable and keeps verification on the LAN.
"""
if door.boltcards_base_url:
return door.boltcards_base_url.rstrip("/")
return f"{settings.lnbits_baseurl.rstrip('/')}/boltcards/api/v1"
async def verify_card(door: Door, external_id: str, p: str, c: str) -> bool:
"""Authenticate the tap by delegating the NTAG424 SUN check to boltcards.
Calls the boltcards `/verify` endpoint (aiolabs fork 1.1.1-aio.2): a
side-effect-light SUN check that confirms a genuine, non-replayed tap and
returns `{"authenticated": true, ...}` WITHOUT `/scan`'s spend semantics
(no withdrawRequest, no daily-limit, no "hit"). It still advances the SUN
counter server-side, so a captured p/c can't be replayed.
"""
url = f"{_boltcards_base(door)}/verify/{external_id}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, params={"p": p, "c": c}, timeout=5.0)
data = resp.json()
except Exception:
return False
return isinstance(data, dict) and data.get("authenticated") is True
async def trigger_unlock(door: Door, external_id: str) -> bool:
"""Fire the door's local Home Assistant webhook (→ lock.unlock → Z-Wave).
Returns True only on a 2xx. Empty webhook URL means "authorize + log only"
(bring-up mode) and is treated as a successful no-op unlock.
"""
if not door.ha_webhook_url:
return True
timeout = max(door.unlock_timeout_ms, 250) / 1000.0
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
door.ha_webhook_url,
json={"doorId": door.id, "doorName": door.name, "externalId": external_id},
timeout=timeout,
)
return resp.is_success
except Exception:
return False