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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user