Merge Workstream G: device pairing + ingestion + live broadcast

Resolved conflict in main.py: combined both workstreams' router imports
and registrations (device_router from G, inventory_router from C).
143/143 backend tests pass after cleaning stray pollution from an
earlier parallel workstream run against the shared test DB.
This commit is contained in:
Indiana
2026-07-24 14:57:10 +00:00
6 changed files with 780 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ from app.config import settings
from app.db import Base, async_session_maker, engine from app.db import Base, async_session_maker, engine
from app.routes.auth import router as auth_router from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router from app.routes.codex import router as codex_router
from app.routes.device import router as device_router
from app.routes.inventory import router as inventory_router from app.routes.inventory import router as inventory_router
from app.routes.shop import router as shop_router from app.routes.shop import router as shop_router
from app.session_cleanup import delete_expired_sessions from app.session_cleanup import delete_expired_sessions
@@ -65,6 +66,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Quantumancy", lifespan=lifespan) app = FastAPI(title="Quantumancy", lifespan=lifespan)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(codex_router) app.include_router(codex_router)
app.include_router(device_router)
app.include_router(inventory_router) app.include_router(inventory_router)
app.include_router(shop_router) app.include_router(shop_router)
app.include_router(ws_router) app.include_router(ws_router)

View File

@@ -1,5 +1,6 @@
from app.models.auth_session import AuthSession from app.models.auth_session import AuthSession
from app.models.contact_session import ContactSession from app.models.contact_session import ContactSession
from app.models.device import Device
from app.models.entity import Entity from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting from app.models.entity_sighting import EntitySighting
from app.models.event import Event from app.models.event import Event
@@ -13,6 +14,7 @@ __all__ = [
"User", "User",
"AuthSession", "AuthSession",
"ContactSession", "ContactSession",
"Device",
"Entity", "Entity",
"EntitySighting", "EntitySighting",
"Event", "Event",

View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Device(Base):
"""A paired hardware sensor node (ESP32-P4 etc.), belonging to exactly
one user. Authenticates telemetry via a bearer token whose hash — never
the raw value — is stored here, identical convention to
`AuthSession.token_hash` (see `app/models/auth_session.py`)."""
__tablename__ = "devices"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String(64))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
last_seen_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

@@ -0,0 +1,321 @@
"""Device pairing, telemetry ingestion, and the live device-feed dashboard
WebSocket (Workstream G of
docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md).
Scope boundary (per the spec): this module owns pairing, ingestion
auth/validation, `last_seen_at` bookkeeping, and the in-process dashboard
pub/sub broadcast. It does NOT feed readings into the séance/summon
pipeline — that's Workstream K, which extends `_process_reading` below.
"""
import asyncio
import contextlib
import uuid
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from fastapi import (
APIRouter,
Depends,
Header,
HTTPException,
Request,
WebSocket,
WebSocketDisconnect,
status,
)
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import async_session_maker as _default_session_maker
from app.db import get_db
from app.deps import SESSION_COOKIE_NAME, get_current_user
from app.models.auth_session import AuthSession, generate_session_token, hash_token
from app.models.device import Device
from app.models.user import User
from app.rate_limit import RateLimiter
router = APIRouter(tags=["device"])
# Alias so tests can swap in the NullPool test session maker — same pattern
# app.ws uses for its own session_maker (see backend/tests/conftest.py).
session_maker = _default_session_maker
MAX_TELEMETRY_BODY_BYTES = 16 * 1024
MAX_READINGS_PER_REQUEST = 64
# ~1 request/second sustained is generous for sensor telemetry; keyed
# per-device so one chatty/misbehaving node can't starve another.
telemetry_limiter = RateLimiter(max_requests=1, window_seconds=1)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class DeviceCreateIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
class Reading(BaseModel):
# sensor_type is intentionally free-form (not an enum) — "add all sorts
# of sensors, anything you can think of" per the project brief. Only
# shape is validated here, never a fixed sensor list.
sensor_type: str = Field(min_length=1, max_length=32)
value: float | list[float]
unit: str = Field(min_length=1, max_length=16)
metadata: dict = Field(default_factory=dict)
class TelemetryIn(BaseModel):
readings: list[Reading] = Field(min_length=1, max_length=MAX_READINGS_PER_REQUEST)
# ---------------------------------------------------------------------------
# REST: pairing (session-cookie authenticated)
# ---------------------------------------------------------------------------
@router.post("/api/device", status_code=status.HTTP_201_CREATED)
async def create_device(
payload: DeviceCreateIn,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Creates a device and returns the raw pairing token ONCE — it is
never stored or retrievable again, identical convention to
`generate_session_token()`/`hash_token()` in `models/auth_session.py`."""
raw_token, token_hash = generate_session_token()
device = Device(user_id=user.id, name=payload.name, token_hash=token_hash)
db.add(device)
await db.commit()
await db.refresh(device)
return {
"id": str(device.id),
"name": device.name,
"token": raw_token,
"created_at": device.created_at.isoformat(),
}
@router.get("/api/device")
async def list_devices(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
devices = (
await db.execute(
select(Device).where(Device.user_id == user.id).order_by(Device.created_at)
)
).scalars().all()
return {
"devices": [
{
"id": str(d.id),
"name": d.name,
"last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None,
}
for d in devices
]
}
# ---------------------------------------------------------------------------
# Device bearer auth — headless clients only, never the qm_session cookie.
# ---------------------------------------------------------------------------
async def _authenticate_device(
authorization: str | None = Header(default=None),
db: AsyncSession = Depends(get_db),
) -> Device:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing device bearer token")
raw_token = authorization.removeprefix("Bearer ").strip()
if not raw_token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing device bearer token")
# Look up by hash — the raw token is never compared or stored.
token_hash = hash_token(raw_token)
device = await db.scalar(select(Device).where(Device.token_hash == token_hash))
if device is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid device token")
return device
# ---------------------------------------------------------------------------
# In-process dashboard pub/sub: user_id -> live /ws/device-feed connections.
# Each connection gets its own send queue + single sender task, mirroring
# app.ws's SeanceState/_sender convention, so concurrent ingestion requests
# broadcasting to the same dashboard socket never interleave writes on it.
# ---------------------------------------------------------------------------
@dataclass
class _DashboardConnection:
user_id: uuid.UUID
send_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
_dashboard_connections: dict[uuid.UUID, list[_DashboardConnection]] = defaultdict(list)
async def _dashboard_sender(conn: _DashboardConnection, websocket: WebSocket) -> None:
"""The only task allowed to write to this dashboard socket."""
try:
while True:
message = await conn.send_queue.get()
await websocket.send_json(message)
except asyncio.CancelledError:
pass
async def _broadcast_reading(device: Device, reading: Reading, at: datetime) -> None:
connections = _dashboard_connections.get(device.user_id)
if not connections:
return # no dashboard open for this owner right now — not an error
frame = {
"type": "reading",
"device_id": str(device.id),
"sensor_type": reading.sensor_type,
"value": reading.value,
"unit": reading.unit,
"metadata": reading.metadata,
"at": at.isoformat(),
}
for conn in list(connections):
await conn.send_queue.put(frame)
# ---------------------------------------------------------------------------
# Ingestion
# ---------------------------------------------------------------------------
async def _process_reading(device: Device, reading: Reading) -> None:
"""Per-reading processing handoff point.
Workstream G (this file) intentionally leaves this a no-op: pairing,
bearer auth, payload validation/size caps, per-device rate limiting, and
the live dashboard broadcast (`_broadcast_reading`, above) are this
workstream's full scope.
Workstream K's summon-pipeline integration extends/replaces this
function to run per-(user_id, device_id, sensor_type) rolling-baseline
anomaly detection and, when a reading is flagged, push
`{"type": "anomaly", "source": <sensor_type>, "frequency": ...,
"magnitude": ...}` into the owning user's active `SeanceState.anomalies`
— the same path `_handle_anomaly` in `app/ws.py` already uses for the
browser-based modes (`wire`/`evp`/`radio`/`emf`). It is async so that
integration can await DB/session-registry work; called once per reading
in `ingest_telemetry` below, in submission order.
"""
return None
@router.post("/api/device/telemetry", status_code=status.HTTP_202_ACCEPTED)
async def ingest_telemetry(
request: Request,
device: Device = Depends(_authenticate_device),
db: AsyncSession = Depends(get_db),
):
if not telemetry_limiter.allow(str(device.id)):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS, "telemetry rate limit exceeded"
)
body = await request.body()
if len(body) > MAX_TELEMETRY_BODY_BYTES:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "telemetry payload too large"
)
try:
payload = TelemetryIn.model_validate_json(body)
except ValidationError:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "malformed telemetry payload"
)
device.last_seen_at = datetime.now(timezone.utc)
await db.commit()
at = datetime.now(timezone.utc)
for reading in payload.readings:
await _process_reading(device, reading)
await _broadcast_reading(device, reading, at)
return {"status": "received", "count": len(payload.readings)}
# ---------------------------------------------------------------------------
# Live dashboard WS — session-cookie authenticated (browser client, unlike
# the ingestion endpoint above).
# ---------------------------------------------------------------------------
async def _authenticate_dashboard(websocket: WebSocket) -> uuid.UUID | None:
raw_token = websocket.cookies.get(SESSION_COOKIE_NAME)
if raw_token is None:
return None
token_hash = hash_token(raw_token)
async with session_maker() as db:
result = await db.execute(
select(AuthSession).where(AuthSession.token_hash == token_hash)
)
session = result.scalar_one_or_none()
if session is None or session.expires_at < datetime.now(timezone.utc):
return None
return session.user_id
@router.websocket("/ws/device-feed")
async def device_feed_socket(websocket: WebSocket) -> None:
user_id = await _authenticate_dashboard(websocket)
if user_id is None:
await websocket.close(code=4401)
return
await websocket.accept()
conn = _DashboardConnection(user_id=user_id)
_dashboard_connections[user_id].append(conn)
sender = asyncio.create_task(_dashboard_sender(conn, websocket))
async with session_maker() as db:
devices = (
await db.execute(select(Device).where(Device.user_id == user_id))
).scalars().all()
await conn.send_queue.put(
{
"type": "devices",
"devices": [
{
"id": str(d.id),
"name": d.name,
"last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None,
}
for d in devices
],
}
)
try:
while True:
# The dashboard is receive-only in practice; this just blocks
# until disconnect while _dashboard_sender does all the writing.
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
sender.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sender
with contextlib.suppress(ValueError):
_dashboard_connections[user_id].remove(conn)
if not _dashboard_connections.get(user_id):
_dashboard_connections.pop(user_id, None)

View File

@@ -49,9 +49,11 @@ def sync_client(monkeypatch):
swapped: TestClient runs the lifespan on its own portal loop, and the swapped: TestClient runs the lifespan on its own portal loop, and the
pooled production engine would carry connections across loops.""" pooled production engine would carry connections across loops."""
import app.main as main_module import app.main as main_module
import app.routes.device as device_module
import app.ws as ws_module import app.ws as ws_module
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal) monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(device_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(main_module, "engine", test_engine) monkeypatch.setattr(main_module, "engine", test_engine)
with TestClient(app, base_url="https://testserver") as tc: with TestClient(app, base_url="https://testserver") as tc:
yield tc yield tc

View File

@@ -0,0 +1,426 @@
import hashlib
import pytest
from sqlalchemy import select
import app.routes.device as device_module
from app.models.device import Device
from app.rate_limit import RateLimiter
# ---------------------------------------------------------------------------
# Async-client helpers (REST-only tests)
# ---------------------------------------------------------------------------
async def _register_and_login(client, username="devowner"):
await client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
await client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
async def _pair_device(client, name="Sensor Node 1") -> dict:
response = await client.post("/api/device", json={"name": name})
assert response.status_code == 201
return response.json()
def _valid_reading(sensor_type="temperature", value=21.4, unit="c"):
return {"sensor_type": sensor_type, "value": value, "unit": unit, "metadata": {}}
# ---------------------------------------------------------------------------
# Pairing REST endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_device_requires_session_cookie(client):
response = await client.post("/api/device", json={"name": "Sensor Node"})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_create_device_returns_raw_token_once(client, db_session):
await _register_and_login(client, "pairer1")
body = await _pair_device(client, "Attic Node")
assert body["name"] == "Attic Node"
assert "token" in body and len(body["token"]) > 20
assert "id" in body and "created_at" in body
assert "token_hash" not in body
# The stored hash is the sha256 of the raw token — same convention as
# AuthSession.token_hash / hash_token().
device = await db_session.scalar(select(Device).where(Device.id == body["id"]))
assert device.token_hash == hashlib.sha256(body["token"].encode()).hexdigest()
@pytest.mark.asyncio
async def test_list_devices_never_exposes_token_or_hash(client):
await _register_and_login(client, "pairer2")
await _pair_device(client, "Basement Node")
response = await client.get("/api/device")
assert response.status_code == 200
devices = response.json()["devices"]
assert len(devices) == 1
assert devices[0]["name"] == "Basement Node"
assert devices[0]["last_seen_at"] is None
assert "token" not in devices[0]
assert "token_hash" not in devices[0]
@pytest.mark.asyncio
async def test_list_devices_requires_session_cookie(client):
response = await client.get("/api/device")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_devices_scoped_to_owner(client):
await _register_and_login(client, "pairer3a")
await _pair_device(client, "Owner A Node")
await client.post("/auth/logout")
await _register_and_login(client, "pairer3b")
response = await client.get("/api/device")
assert response.json()["devices"] == []
# ---------------------------------------------------------------------------
# Telemetry ingestion — auth
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_rejects_missing_bearer_token(client):
response = await client.post(
"/api/device/telemetry", json={"readings": [_valid_reading()]}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_telemetry_rejects_malformed_authorization_header(client):
response = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": "Token not-a-bearer-scheme"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_telemetry_rejects_unknown_token(client):
response = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": "Bearer totally-made-up-token"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_telemetry_accepts_valid_bearer_token(client):
await _register_and_login(client, "ingest1")
device = await _pair_device(client, "Living Room Node")
response = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 202
assert response.json()["count"] == 1
# ---------------------------------------------------------------------------
# Telemetry ingestion — payload validation (never a 500)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_rejects_oversized_body(client):
await _register_and_login(client, "ingest2")
device = await _pair_device(client, "Garage Node")
huge_reading = _valid_reading(sensor_type="temperature")
huge_reading["metadata"] = {"blob": "x" * 20_000}
response = await client.post(
"/api/device/telemetry",
json={"readings": [huge_reading]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 413
@pytest.mark.asyncio
async def test_telemetry_rejects_oversized_readings_array(client):
await _register_and_login(client, "ingest3")
device = await _pair_device(client, "Hallway Node")
readings = [_valid_reading(sensor_type=f"sensor{i}") for i in range(65)]
response = await client.post(
"/api/device/telemetry",
json={"readings": readings},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_garbage_sensor_type_type(client):
await _register_and_login(client, "ingest4")
device = await _pair_device(client, "Cellar Node")
bad = _valid_reading()
bad["sensor_type"] = 12345 # must be a string
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_garbage_value_type(client):
await _register_and_login(client, "ingest5")
device = await _pair_device(client, "Attic Node 2")
bad = _valid_reading()
bad["value"] = "not-a-number"
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_garbage_unit_type(client):
await _register_and_login(client, "ingest6")
device = await _pair_device(client, "Loft Node")
bad = _valid_reading()
bad["unit"] = {"nested": "dict"}
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_non_dict_metadata(client):
await _register_and_login(client, "ingest7")
device = await _pair_device(client, "Porch Node")
bad = _valid_reading()
bad["metadata"] = ["not", "a", "dict"]
response = await client.post(
"/api/device/telemetry",
json={"readings": [bad]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_telemetry_rejects_malformed_json_body(client):
await _register_and_login(client, "ingest8")
device = await _pair_device(client, "Yard Node")
response = await client.post(
"/api/device/telemetry",
content=b"{not valid json",
headers={
"Authorization": f"Bearer {device['token']}",
"Content-Type": "application/json",
},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# last_seen_at bookkeeping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_updates_last_seen_at(client):
await _register_and_login(client, "seenat1")
device = await _pair_device(client, "Cave Node")
before = await client.get("/api/device")
assert before.json()["devices"][0]["last_seen_at"] is None
await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device['token']}"},
)
after = await client.get("/api/device")
assert after.json()["devices"][0]["last_seen_at"] is not None
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_telemetry_rate_limited_per_device(client, monkeypatch):
monkeypatch.setattr(
device_module, "telemetry_limiter", RateLimiter(max_requests=1, window_seconds=60)
)
await _register_and_login(client, "ratelimited1")
device = await _pair_device(client, "Rate Limited Node")
headers = {"Authorization": f"Bearer {device['token']}"}
first = await client.post(
"/api/device/telemetry", json={"readings": [_valid_reading()]}, headers=headers
)
assert first.status_code == 202
second = await client.post(
"/api/device/telemetry", json={"readings": [_valid_reading()]}, headers=headers
)
assert second.status_code == 429
@pytest.mark.asyncio
async def test_telemetry_rate_limit_is_per_device_not_global(client, monkeypatch):
monkeypatch.setattr(
device_module, "telemetry_limiter", RateLimiter(max_requests=1, window_seconds=60)
)
await _register_and_login(client, "ratelimited2")
device_a = await _pair_device(client, "Node A")
device_b = await _pair_device(client, "Node B")
resp_a = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device_a['token']}"},
)
assert resp_a.status_code == 202
# Device B has spent nothing yet — its own bucket is untouched.
resp_b = await client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device_b['token']}"},
)
assert resp_b.status_code == 202
# ---------------------------------------------------------------------------
# Live dashboard WS — pub/sub fan-out (needs a synchronous client that can
# hold a WS connection open alongside plain HTTP calls, same as ws.py's
# tests use `sync_client` for).
# ---------------------------------------------------------------------------
def _sync_register_and_login(sync_client, username="dashuser"):
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
return sync_client.cookies.get("qm_session")
def _sync_pair_device(sync_client, name="Sync Node") -> dict:
response = sync_client.post("/api/device", json={"name": name})
assert response.status_code == 201
return response.json()
def _ws_feed_connect(sync_client, token):
# Same rationale as app.ws's tests: TestClient upgrades over ws://, so
# the jar withholds the Secure qm_session cookie — pass it explicitly.
return sync_client.websocket_connect(
"/ws/device-feed", headers={"cookie": f"qm_session={token}"}
)
def test_device_feed_requires_session_cookie(sync_client):
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/device-feed"):
pass
def test_device_feed_sends_device_list_on_connect(sync_client):
token = _sync_register_and_login(sync_client, "dashuser1")
device = _sync_pair_device(sync_client, "Feed Node")
with _ws_feed_connect(sync_client, token) as ws:
frame = ws.receive_json()
assert frame["type"] == "devices"
assert len(frame["devices"]) == 1
assert frame["devices"][0]["id"] == device["id"]
assert frame["devices"][0]["name"] == "Feed Node"
assert "token" not in frame["devices"][0]
def test_reading_posted_while_dashboard_connected_arrives_on_socket(sync_client):
token = _sync_register_and_login(sync_client, "dashuser2")
device = _sync_pair_device(sync_client, "Live Node")
with _ws_feed_connect(sync_client, token) as ws:
ws.receive_json() # initial "devices" frame
response = sync_client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading(sensor_type="presence", value=1, unit="bool")]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 202
reading_frame = ws.receive_json()
assert reading_frame["type"] == "reading"
assert reading_frame["device_id"] == device["id"]
assert reading_frame["sensor_type"] == "presence"
assert reading_frame["value"] == 1
assert reading_frame["unit"] == "bool"
assert reading_frame["metadata"] == {}
assert "at" in reading_frame
def test_reading_posted_for_device_with_no_dashboard_owner_does_not_error(sync_client):
_sync_register_and_login(sync_client, "dashuser3")
device = _sync_pair_device(sync_client, "Lonely Node")
# No /ws/device-feed connection is open for this user at all.
response = sync_client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device['token']}"},
)
assert response.status_code == 202
def test_device_feed_only_broadcasts_to_the_owning_user(sync_client):
token_a = _sync_register_and_login(sync_client, "dashuser4a")
_sync_pair_device(sync_client, "User A Node")
_sync_register_and_login(sync_client, "dashuser4b")
device_b = _sync_pair_device(sync_client, "User B Node")
with _ws_feed_connect(sync_client, token_a) as ws_a:
ws_a.receive_json() # devices frame for user A (empty-ish/own list)
sync_client.post(
"/api/device/telemetry",
json={"readings": [_valid_reading()]},
headers={"Authorization": f"Bearer {device_b['token']}"},
)
# User A's socket must not receive user B's device reading.
# WebSocketTestSession.receive_json() has no timeout param, so poll
# its underlying queue directly with a short timeout instead of
# blocking forever waiting for a frame that must never arrive.
import queue
with pytest.raises(queue.Empty):
ws_a._send_queue.get(timeout=0.3)