"""Devices: which dashboards a device shows, and how it gets a credential. A wall tablet has no keyboard, so it cannot log in. It asks for a code instead, shows it on the wall, and somebody with an account types that code into the device's page to say which device it is, or picks the request from the list of devices waiting on this network. The device polls, collects the credential the approval minted, and never asks again. A request from the list carries a two-character check that the device shows beside its code, so whoever picks it can tell it is the screen in front of them: behind some proxies every address looks private. A screen hanging somewhere this instance is not reachable from does the same thing through the portal, which forwards the two pairing calls down the tunnel without a session and mints the credential itself when the approval comes. Anyone on the internet can ask the portal for a code, so a request that came that way is adopted by its code only. The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards that device was assigned, the messages their widgets bind to, the messages the device nodes in published flows wire its own ports to, and nothing else. Deleting the device revokes it, and so does unpairing, which bumps the device's nonce and leaves what it shows as it was. That holds for a credential the portal minted too, which carries the nonce the device had when it paired. Once paired, a device says what it has with ``PUT /devices/{id}/report``: its board, its screen and the ports it serves, which the device node's ports are checked against. ``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what openpanel firmware from before devices calls, and stay as aliases until every device has updated. """ from __future__ import annotations import asyncio import logging import secrets import time from contextlib import suppress from datetime import datetime, timedelta from typing import Any, cast import httpx import orjson import redis from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel, Field, ValidationError, field_validator from fluksio.api.deps import ( CAMERA_IS_SUPERUSERS, CurrentUser, DeviceStoreDep, get_current_active_superuser, get_current_user, ) from fluksio.api.routes.oauth import too_many from fluksio.cloud import config as cloud_config from fluksio.core import security from fluksio.core.config import settings from fluksio.flow.controller import RebuildBusy from fluksio.flow.devices import ( DeviceBinding, DeviceDef, DeviceExists, DeviceIo, DeviceNotFound, DeviceStore, is_lan, presence, read_report, ) from fluksio.flow.events import event_bus from fluksio.flow.live import ICE_TTL_S, ice_servers from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S from fluksio.flow.store import StaleVersion from fluksio.models import Message, User logger = logging.getLogger(__name__) #: Gated per route rather than on the router: the pairing endpoints are the #: only unauthenticated ones in the app, because a device with no credential is #: the whole point of them. router = APIRouter(prefix="/devices", tags=["devices"]) #: The calls the firmware makes today, on the same handlers. panels_router = APIRouter(prefix="/panels", tags=["devices"], include_in_schema=False) #: Long, because a device is a screen somebody hung once and left running, with #: no keyboard to sign in again with. Same reasoning as a remote worker's. TOKEN_DAYS = 365 #: How long a device's code is worth typing. Long enough to walk to a computer. PAIR_TTL = 600 #: No I/O/0/1: the code is read off a wall and typed on a keyboard. CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" CODE_LENGTH = 6 #: The check a device shows beside its code, to compare with the pending list. CHECK_LENGTH = 2 #: Never more waiting than this from each way in, this network or the portal: #: codes are free to ask for and cost memory. The oldest of the same way in #: makes room for a new one, so a flood cannot lock a device out, and one #: through the portal never pushes out a device waiting here. MAX_PENDING = 50 #: Codes one address may ask for per window, here. A device asks once per #: code, every ten minutes, and again after a failure. Through the portal the #: hub limits each address and the instance by the address it sees, since #: here every such call arrives off the tunnel and would share one bucket. PAIR_PER_ADDRESS = 5 PAIR_WINDOW_S = 60.0 #: Ids a device cannot take: the routes beside ``/{device_id}``. RESERVED = {"pair", "pending"} #: How much of what a device says about itself is kept. HELLO_LENGTH = 64 #: The most a capability report may weigh. A panel with every header pin #: reported is a few kB. REPORT_MAX = 16 * 1024 #: The most it may weigh as kept, with each named ``config_schemas`` entry put #: in place: thirty pins naming one schema send it once and keep it thirty #: times. The P4 panel's is about 7 kB. REPORT_STORED_MAX = 64 * 1024 class _Pending(BaseModel): """A device waiting to be told what it is.""" #: Held by the device, never shown. secret: str #: Names the request in the pending list, so it can be adopted without #: the code being handed to anyone. request: str = "" #: Shown by the device and beside its entry in the list. Not a secret. check: str = "" #: Wall-clock rather than monotonic: an entry outlives the process that #: minted it. expires: float token: str = "" device_id: str = "" #: What the request looked like, shown to whoever approves it so they can #: tell the screen in the hall from one they were not expecting. #: Self-reported and worth what that is worth. device: str = "" name: str = "" board: str = "" firmware: str = "" #: Whether it came down the tunnel. A device that reached the portal #: cannot reach this instance, so its credential has to be minted where it #: can collect it. remote: bool = False #: Whether it may be adopted from the list without its code: asked from a #: private network, not through the portal. lan: bool = False #: The entry itself, one key per code, expiring on its own. _PENDING_KEY = "panels:pair" #: An index of the live codes, so :data:`MAX_PENDING` means the same thing to #: every worker. Scored by expiry, which is how it is pruned. _PENDING_INDEX = "panels:pair:__codes__" class _PendingStore: """The codes waiting for somebody to say what they are. In Redis when there is one, so a code minted before a restart, or by another process, is still found. Without one there is one process by definition (the pip install, and the tests), and a dictionary is the same thing for it. """ def __init__(self) -> None: self._local: dict[str, _Pending] = {} self._redis: redis.Redis | None = ( redis.Redis( host=settings.REDIS_HOST, port=settings.REDIS_PORT, decode_responses=True, # A silent Redis must fail this request, not hold it open. socket_timeout=SOCKET_TIMEOUT_S, socket_connect_timeout=CONNECT_TIMEOUT_S, health_check_interval=30, ) if settings.REDIS_HOST else None ) def _key(self, code: str) -> str: return f"{_PENDING_KEY}:{code}" def _codes(self) -> list[str]: """The live codes, oldest first, the expired ones dropped first. Oldest first because every code lives as long: the index is scored by expiry, and the dictionary keeps the order codes were added in. """ now = time.time() if self._redis is None: for code in [c for c, e in self._local.items() if e.expires < now]: del self._local[code] return list(self._local) self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now) return list(cast("list[str]", self._redis.zrange(_PENDING_INDEX, 0, -1))) def make_room(self, room: int, remote: bool) -> None: """Drop the oldest codes asked for the same way, through the portal or not, until fewer than ``room`` of them are left.""" same = [ code for code in self._codes() if (entry := self.get(code)) is not None and entry.remote == remote ] for code in same[: max(0, len(same) - room + 1)]: self.drop(code) def count(self) -> int: return len(self._codes()) def waiting(self) -> list[tuple[str, _Pending]]: """Every code nobody has approved yet, with its entry.""" found = [] for code in self._codes(): entry = self.get(code) if entry is not None and not entry.token: found.append((code, entry)) return found def add(self, code: str, entry: _Pending) -> bool: """Claim this code, or say that somebody already holds it.""" if self._redis is None: if code in self._local: return False self._local[code] = entry return True if not self._redis.set( self._key(code), entry.model_dump_json(), ex=PAIR_TTL, nx=True ): return False self._redis.zadd(_PENDING_INDEX, {code: entry.expires}) return True def get(self, code: str) -> _Pending | None: if self._redis is None: entry = self._local.get(code) else: raw = cast("str | None", self._redis.get(self._key(code))) entry = _Pending.model_validate_json(raw) if raw else None return entry if entry is not None and entry.expires >= time.time() else None def save(self, code: str, entry: _Pending) -> None: """Write an approval back, without moving what it expires at.""" if self._redis is None: self._local[code] = entry return self._redis.set( self._key(code), entry.model_dump_json(), ex=max(1, int(entry.expires - time.time())), ) def drop(self, code: str) -> None: if self._redis is None: self._local.pop(code, None) return self._redis.delete(self._key(code)) self._redis.zrem(_PENDING_INDEX, code) _pending = _PendingStore() class PairHello(BaseModel): """What a device may say about itself when it asks for a code.""" name: str = "" board: str = "" firmware: str = "" @field_validator("name", "board", "firmware", mode="before") @classmethod def _trim(cls, value: Any) -> str: # Cut rather than refused: a device that says too much should still # get its code. return str(value or "").strip()[:HELLO_LENGTH] class PairStarted(BaseModel): """What the device puts on the wall, and what it polls with.""" code: str #: Held by the device, never shown. Without it the code alone would let #: anyone who reads it off the wall collect the credential first. secret: str #: Shown beside the code. The pending list shows it next to this request, #: so whoever adopts it from there can tell it is this screen. check: str = "" expires_in: int = PAIR_TTL class PairStatus(BaseModel): """Nothing yet, or the credential and the device it is for.""" access_token: str | None = None device: str | None = None #: The same id, for firmware from before devices. panel: str | None = None class PairRequest(BaseModel): """Adopt the device showing ``code``, or the pending ``request``.""" code: str = "" request: str = "" class PendingDevice(BaseModel): """Who is asking, as far as the request itself says.""" #: What ``POST /devices/{id}/pair`` takes to adopt it without the code. request: str = "" #: What the device shows beside its code, to compare before adopting it. check: str = "" device: str name: str = "" board: str = "" firmware: str = "" remote: bool = False #: Through the portal or from outside this network: only its code adopts #: it, since anyone can put a request there. needs_code: bool = True expires_in: int = 0 def _pending_public(entry: _Pending) -> PendingDevice: return PendingDevice( request=entry.request, check=entry.check, device=entry.device, name=entry.name, board=entry.board, firmware=entry.firmware, remote=entry.remote, needs_code=not entry.lan, expires_in=max(0, int(entry.expires - time.time())), ) class DeviceCreate(BaseModel): title: str = "" class DeviceDocument(DeviceDef): """A device as it reads itself: what it shows, and what its ports carry. ``io`` is worked out from the device nodes in published flows, in a fixed order, so an unchanged state reads back as the same bytes. """ #: Each port the flows wire, and the message it publishes or follows. io: list[DeviceIo] = Field(default_factory=list) #: The same ports by the flow and node holding them. bindings: list[DeviceBinding] = Field(default_factory=list) class DevicePublic(DeviceDocument): """A device as the Devices tab lists it, with whether it is there.""" online: bool = False #: The last request it made, since this instance started. last_seen: datetime | None = None #: What the device says it has, as it last said so. None until it reports. report: dict[str, Any] | None = None reported_at: datetime | None = None class DevicesPublic(BaseModel): """The devices, and the address a device should be pointed at. The address is the server's own, because the browser's origin is not a reliable answer: an admin working through the portal is on the portal's origin, and this one is for a screen on this network. """ data: list[DevicePublic] = Field(default_factory=list) count: int = 0 #: Whatever this instance was told it is reachable at, the setting the #: password-reset links are built from too. frontend_host: str = "" class IceServer(BaseModel): """One STUN or TURN server, as `RTCPeerConnection` takes it.""" urls: list[str] username: str | None = None credential: str | None = None class IceServers(BaseModel): ice_servers: list[IceServer] = Field(default_factory=list) #: Seconds the TURN credentials stay good. ttl: int = 0 def _describe(request: Request) -> str: """A line naming the device behind a pairing request. # ponytail: the raw user agent, trimmed. Parse it into "iPad · Safari" if # it reads badly on the approval screen. """ agent = request.headers.get("user-agent", "").strip()[:120] # Resolved from X-Forwarded-For by the proxy-headers middleware, which is # what makes a call replayed off the tunnel show the device's address and # stops a device naming any address it likes. address = request.client.host if request.client else "" return " · ".join(part for part in (agent or "Unknown device", address) if part) def _mint_at_hub(device_id: str, nonce: int) -> str: """Ask the portal for this device's credential. A device that arrived through the portal cannot reach this instance, so a token this instance signed would be one it could never present: the portal verifies what crosses its tunnel against its own key. It mints, we say which device and its nonce, and the checks here decide the rest: the nonce is held against the device's as for a credential minted here. """ config = cloud_config.load() if config is None: raise HTTPException( status_code=409, detail="That device came through a portal this instance is no " "longer enrolled with", ) try: response = httpx.post( f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/", headers={"Authorization": f"Bearer {config.token}"}, json={"panel": device_id, "pnc": nonce}, timeout=15.0, ) except httpx.HTTPError as exc: raise HTTPException( status_code=502, detail=f"Could not reach the portal: {exc}" ) from exc if response.status_code != 200: raise HTTPException( status_code=502, detail=f"The portal refused to mint a credential ({response.status_code})", ) token: str = response.json()["access_token"] return token def _not_found(device_id: str) -> HTTPException: return HTTPException(status_code=404, detail=f"No device named {device_id!r}") def _document(device: DeviceDef, store: DeviceStore) -> DeviceDocument: return DeviceDocument( **device.model_dump(), io=store.io(device.id), bindings=store.bindings(device.id), ) #: A recheck waiting for a rebuild to finish. One is enough: it reads the #: devices as they are once it runs. _rechecks: set[asyncio.Task[None]] = set() async def _recheck(request: Request) -> None: """Validate the flows again: a device node may name this device. The device is saved either way, so a busy engine is not this request's failure. The check waits for the rebuild in the background instead, since one that started before this change may have read the devices before it. """ controller = getattr(request.app.state, "flow_controller", None) if controller is None: return try: await controller.recheck() except RebuildBusy: if not _rechecks: task = asyncio.create_task(controller.recheck(wait=None)) _rechecks.add(task) task.add_done_callback(_rechecks.discard) def _changed() -> None: """Tell every device socket that what it shows may have moved. An empty name says that much and no more: every screen listening rescopes and refetches, which is also where an unpaired one meets its 401. """ event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()}) # -- the device itself, before it holds anything ----------------------------- def start_pairing(request: Request, body: PairHello | None = None) -> Any: """A device asks to be adopted. Unauthenticated, by necessity. All this hands out is a code that means nothing until somebody with an account approves it, so the worst an unwelcome caller achieves is an entry that expires ten minutes later. Reachable from the internet when this instance is enrolled with a portal, which is what the per-address limit, the cap and the portal's own limits are between. At the cap the oldest request makes room, so a flood delays a device rather than locking it out. """ remote = cloud_config.via_portal(request.headers) if not remote and too_many(request, "pair", PAIR_PER_ADDRESS, PAIR_WINDOW_S): raise HTTPException( status_code=429, detail="Too many pairing codes asked for from here. Try again shortly.", headers={"Retry-After": str(int(PAIR_WINDOW_S))}, ) _pending.make_room(MAX_PENDING, remote) hello = body or PairHello() entry = _Pending( secret=secrets.token_urlsafe(16), request=secrets.token_urlsafe(9), check="".join(secrets.choice(CODE_ALPHABET) for _ in range(CHECK_LENGTH)), expires=time.time() + PAIR_TTL, device=_describe(request), name=hello.name, board=hello.board, firmware=hello.firmware, remote=remote, lan=not remote and is_lan(request.client.host if request.client else ""), ) # Claiming the code is what settles a collision, rather than looking # first: between the look and the write sits another worker doing the same. for _ in range(10): code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) if _pending.add(code, entry): break else: raise HTTPException(status_code=503, detail="Could not mint a pairing code") return PairStarted(code=code, secret=entry.secret, check=entry.check) def poll_pairing(code: str, secret: str = "") -> Any: """Has anyone claimed this device yet? Answers the same 404 for a code that never existed, one that expired and one polled with the wrong secret: a caller reading a code off a wall learns nothing by guessing. """ entry = _pending.get(code) if entry is None or not secrets.compare_digest(entry.secret, secret): raise HTTPException( status_code=404, detail="No pairing is waiting on that code" ) if not entry.token: return PairStatus() # Handed over once. A credential left lying in the store is a second copy # of it, and the device has the only one it needs. _pending.drop(code) return PairStatus( access_token=entry.token, device=entry.device_id, panel=entry.device_id ) router.add_api_route( "/pair", start_pairing, methods=["POST"], response_model=PairStarted ) router.add_api_route( "/pair/{code}", poll_pairing, methods=["GET"], response_model=PairStatus ) panels_router.add_api_route( "/pair", start_pairing, methods=["POST"], response_model=PairStarted ) panels_router.add_api_route( "/pair/{code}", poll_pairing, methods=["GET"], response_model=PairStatus ) # -- people ------------------------------------------------------------------ @router.get("/", response_model=DevicesPublic, dependencies=[Depends(get_current_user)]) async def read_devices(store: DeviceStoreDep) -> Any: """Every device, what each one shows, and whether it is connected.""" def gather() -> list[DevicePublic]: found = [] for device in store.list(): state = store.state(device.id) found.append( DevicePublic( **_document(device, store).model_dump(), online=presence.online(device.id), last_seen=presence.last_seen(device.id), report=state.report, reported_at=state.reported_at, ) ) return found data = await run_in_threadpool(gather) return DevicesPublic( data=data, count=len(data), frontend_host=settings.FRONTEND_HOST.rstrip("/"), ) @router.get( "/pending", response_model=list[PendingDevice], dependencies=[Depends(get_current_active_superuser)], ) def read_pending() -> Any: """The devices asking for a code right now, as they describe themselves.""" waiting = sorted(_pending.waiting(), key=lambda item: -item[1].expires) return [_pending_public(entry) for _, entry in waiting] @router.get( "/pair/{code}/device", response_model=PendingDevice, dependencies=[Depends(get_current_active_superuser)], ) def pending_device(code: str) -> Any: """What is waiting on this code, before anyone says what it is.""" entry = _pending.get(code.strip().upper()) if entry is None: raise HTTPException(status_code=404, detail="No device is waiting on that code") return _pending_public(entry) @router.post( "/{device_id}", response_model=DeviceDef, dependencies=[Depends(get_current_active_superuser)], ) async def create_device( device_id: str, store: DeviceStoreDep, request: Request, body: DeviceCreate | None = None, ) -> Any: """Add a device. It shows nothing until it is given dashboards.""" if device_id in RESERVED: raise HTTPException(status_code=422, detail=f"{device_id!r} is reserved") try: device = DeviceDef(id=device_id, title=(body.title if body else "").strip()) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) try: created = await run_in_threadpool(store.create, device) except DeviceExists: raise HTTPException(status_code=409, detail=f"{device_id!r} already exists") await _recheck(request) return created @router.get( "/{device_id}", response_model=DeviceDocument, dependencies=[Depends(get_current_user)], ) async def read_device(device_id: str, store: DeviceStoreDep) -> Any: """One device, read by a person or by the device holding its credential. The definition and what the flows wire its ports to, nothing about this moment: a device compares what it reads with what it is showing, and a field that changes on every request would rebuild its screen each time. Declared after the pairing routes and ``/pending``, so neither is mistaken for a device of that name. """ device = await run_in_threadpool(store.find, device_id) if device is None: raise _not_found(device_id) return await run_in_threadpool(_document, device, store) @router.get( "/{device_id}/ice", response_model=IceServers, response_model_exclude_none=True, ) async def read_ice(device_id: str, store: DeviceStoreDep, user: CurrentUser) -> Any: """Where a browser looks for a way to this device's live view. STUN and TURN servers, the TURN ones with credentials of the caller's own that expire. Empty when none are set, which is enough on one network. Only for whoever may see the device's camera (``DeviceDef.viewers``). """ device = await run_in_threadpool(store.find, device_id) if device is None: raise _not_found(device_id) if not user.is_superuser and device.viewers != "members": raise HTTPException(status_code=403, detail=CAMERA_IS_SUPERUSERS) servers = [IceServer(**server) for server in ice_servers(str(user.id))] return IceServers(ice_servers=servers, ttl=ICE_TTL_S) panels_router.add_api_route( "/{device_id}", read_device, methods=["GET"], response_model=DeviceDocument, dependencies=[Depends(get_current_user)], ) @router.put( "/{device_id}/report", response_model=Message, dependencies=[Depends(get_current_user)], ) async def put_report(device_id: str, request: Request, store: DeviceStoreDep) -> Any: """A device says what it has: its board, its screen, its ports. Only the device itself, through its own credential, which ``_device_may`` holds to its own id. The report replaces the one before it, and the flows are validated again, since a device node's ports are checked against it. The first report of a touch screen makes the device touch friendly, as the switch would have been set by hand. Later ones leave the setting to whoever changed it since, and so does the first report of a device paired before pairings were noted: its switch was set by hand already. """ device: DeviceDef | None = getattr(request.state, "device", None) if device is None or device.id != device_id: raise HTTPException( status_code=403, detail="Only the device itself reports what it has" ) raw = bytearray() async for chunk in request.stream(): raw += chunk if len(raw) > REPORT_MAX: raise HTTPException( status_code=413, detail=f"A report is at most {REPORT_MAX // 1024} KB", ) try: report, left_out = read_report(bytes(raw)) except ValidationError as exc: raise HTTPException( status_code=422, detail=exc.errors( include_url=False, include_input=False, include_context=False ), ) if len(orjson.dumps(report.stored())) > REPORT_STORED_MAX: raise HTTPException( status_code=413, detail=f"A report is at most {REPORT_STORED_MAX // 1024} KB once " "its config_schemas are put in place", ) for part in left_out: logger.warning("Device '%s' reported %s, left out", device_id, part) first = await run_in_threadpool(store.report, device_id, report) if ( first and report.display and report.display.touch and not device.touch and (await run_in_threadpool(store.state, device_id)).paired_at ): with suppress(StaleVersion, DeviceNotFound): await run_in_threadpool( store.write, device.model_copy(update={"touch": True}), device.version, ) _changed() await _recheck(request) said = f"{len(report.ports)} ports reported" return Message(message=f"{said}, {len(left_out)} left out" if left_out else said) @router.put( "/{device_id}", response_model=DeviceDef, dependencies=[Depends(get_current_active_superuser)], ) async def save_device(device_id: str, body: DeviceDef, store: DeviceStoreDep) -> Any: """Save a device. Takes effect on its next read. The body carries the ``version`` it was based on, and a save someone else has moved past is refused with 409. """ if body.id != device_id: raise HTTPException(status_code=422, detail="The id in the body must match") try: saved = await run_in_threadpool(store.write, body, body.version) except DeviceNotFound: raise _not_found(device_id) except StaleVersion as exc: raise HTTPException( status_code=409, detail={ "message": "Someone else saved this device first", "current_version": exc.current, }, ) _changed() return saved @router.delete( "/{device_id}", response_model=Message, dependencies=[Depends(get_current_active_superuser)], ) async def delete_device(device_id: str, store: DeviceStoreDep, request: Request) -> Any: """Remove the device and refuse its credential, wherever it was minted here.""" try: await run_in_threadpool(store.delete, device_id) except DeviceNotFound: raise _not_found(device_id) await _recheck(request) _changed() return Message(message=f"Deleted {device_id}") def _adopt( code: str, entry: _Pending, device_id: str, user: User, store: DeviceStore ) -> str: """Mint the credential the device behind ``entry`` collects on its next poll. Either one carries the device's nonce, so unpairing or deleting the device refuses it. One minted here also names the approver, for the record: the device acts as itself and not as that account, so deleting the account leaves the device paired. """ nonce = store.nonce(device_id) if entry.remote: entry.token = _mint_at_hub(device_id, nonce) else: entry.token = security.create_panel_token( device_id, user.id, timedelta(days=TOKEN_DAYS), nonce ) entry.device_id = device_id _pending.save(code, entry) store.paired(device_id) return f"Paired {entry.name or entry.device} with {device_id}" @router.post( "/{device_id}/pair", response_model=Message, dependencies=[Depends(get_current_active_superuser)], ) def approve_pairing( device_id: str, body: PairRequest, current_user: CurrentUser, store: DeviceStoreDep ) -> Any: """Say which device the one showing this code, or making this request, is.""" if store.find(device_id) is None: raise _not_found(device_id) if body.code.strip(): code = body.code.strip().upper() entry = _pending.get(code) if entry is None: raise HTTPException( status_code=404, detail="No device is waiting on that code. Check it again.", ) elif body.request: match = next( ( (c, e) for c, e in _pending.waiting() if secrets.compare_digest(e.request, body.request) ), None, ) if match is None: raise HTTPException( status_code=404, detail="That device is no longer waiting" ) code, entry = match if not entry.lan: raise HTTPException( status_code=403, detail="This request did not come from your network. Enter the " "code the device shows instead.", ) else: raise HTTPException(status_code=422, detail="Send a code or a request") return Message(message=_adopt(code, entry, device_id, current_user, store)) @router.post( "/{device_id}/unpair", response_model=Message, dependencies=[Depends(get_current_active_superuser)], ) async def unpair_device(device_id: str, store: DeviceStoreDep) -> Any: """Stop honouring this device's credential, and keep the device. The screen hanging there is refused on its next request and goes back to showing a pairing code, while its dashboards and their arrangement stay. The same holds for one the portal minted. An older portal minted without the nonce, and such a credential stands only while the nonce is still 0, so this refuses it too. """ if await run_in_threadpool(store.find, device_id) is None: raise _not_found(device_id) await run_in_threadpool(store.bump, device_id) # The screen is still holding a socket. One event and it refetches, which # is where it meets the 401 that sends it back to the pairing code. _changed() return Message(message=f"Unpaired {device_id}")