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>
56 lines
2.1 KiB
Python
56 lines
2.1 KiB
Python
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
|