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:
commit
40564f06ec
22 changed files with 986 additions and 0 deletions
56
services.py
Normal file
56
services.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue