feat: hardware sensor anomalies feed the summon pipeline (Workstream K)

Adds backend/app/device_anomaly.py — per-(user_id, device_id, sensor_type)
rolling-baseline anomaly detection for continuous numeric sensors
(structurally modeled on telemetry.detect_wire_spike: min samples, an
absolute floor, 3-sigma + relative threshold, with per-sensor-type floors
since units vary wildly) plus a false->true state-transition detector for
discrete/boolean sensors like presence.

Adds a module-level active-session registry in app/ws.py
(register_active_session/unregister_active_session/get_active_session)
so hardware ingestion (a plain HTTP call, not a WS connection) can find a
user's live SeanceState.

process_device_reading_for_summon(user_id, device_id, sensor_type, value,
unit) is the self-contained entry point Workstream G's ingestion handler
will call into: classifies numeric vs. boolean, runs the reading through
the right detector, and on a genuine anomaly pushes it into the active
session via the existing _handle_anomaly path (source=sensor_type,
frequency=stable per-sensor-type constant, magnitude=deviation-from-
baseline or a fixed constant for boolean transitions) — reusing the full
existing signature/mint/Codex pipeline, no new mint logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-24 01:14:11 +00:00
parent cf817e5241
commit 004f66171f
3 changed files with 581 additions and 0 deletions

View File

@@ -88,6 +88,36 @@ class SeanceState:
last_wire_anomaly_at: float = 0.0
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
# user to their live SeanceState so hardware ingestion (a plain HTTP
# request, not a WS connection) can find "does this user have a séance
# open right now" and push a hardware anomaly into it. Single
# most-recent-session mapping — if a user somehow has two `/ws/session`
# tabs open concurrently, the newer one wins the registry slot; that's a
# reasonable v1 (see spec's Contract section) since a user's attention is
# realistically in one tab at a time. Plain in-process dict, same reasoning
# as everywhere else in this file: no external store needed at this scale,
# doesn't need to survive a restart.
_active_sessions: dict[uuid.UUID, "SeanceState"] = {}
def register_active_session(user_id: uuid.UUID, state: "SeanceState") -> None:
_active_sessions[user_id] = state
def unregister_active_session(user_id: uuid.UUID, state: "SeanceState") -> None:
# Only remove if it's still *this* state — guards against a rare
# overlap where an older session's disconnect cleanup runs after a
# newer session for the same user has already registered, which would
# otherwise wipe out the newer (still-live) registry entry.
if _active_sessions.get(user_id) is state:
del _active_sessions[user_id]
def get_active_session(user_id: uuid.UUID) -> "SeanceState | None":
return _active_sessions.get(user_id)
def serialize_entity(entity: Entity) -> dict:
return {
"id": str(entity.id),
@@ -432,6 +462,7 @@ async def session_socket(websocket: WebSocket) -> None:
session_id=contact_session.id,
client_ip=_client_ip(websocket),
)
register_active_session(user_id, state)
sender = asyncio.create_task(_sender(state, websocket))
await state.send_queue.put({"type": "session", "id": str(contact_session.id)})
@@ -468,6 +499,7 @@ async def session_socket(websocket: WebSocket) -> None:
except WebSocketDisconnect:
pass
finally:
unregister_active_session(user_id, state)
if state.ambient_task is not None:
state.ambient_task.cancel()
sender.cancel()