""" Demeter Server — In-Memory Device Store Maintains the device registry (from YAML config) and a cache of the latest sensor readings received via CoAP Observe notifications. No database — this is intentionally simple for the POC. """ from __future__ import annotations import asyncio import logging import time from dataclasses import dataclass, field from typing import Any, Optional from .config import DeviceConfig, DevicesConfig logger = logging.getLogger(__name__) @dataclass class SensorReading: """A single sensor reading with metadata.""" value: Any unit: str = "" timestamp: float = 0.0 stale: bool = False def age_seconds(self) -> float: """Seconds since this reading was recorded.""" if self.timestamp == 0: return float("inf") return time.time() - self.timestamp def to_dict(self) -> dict: return { "value": self.value, "unit": self.unit, "timestamp": self.timestamp, "stale": self.stale, "age_seconds": round(self.age_seconds(), 1), } @dataclass class DeviceState: """Runtime state for a single device.""" config: DeviceConfig online: bool = False last_seen: float = 0.0 readings: dict[str, SensorReading] = field(default_factory=dict) def to_dict(self) -> dict: return { "id": self.config.id, "name": self.config.name, "ip": self.config.ip, "port": self.config.port, "enabled": self.config.enabled, "online": self.online, "last_seen": self.last_seen, "readings": { uri: reading.to_dict() for uri, reading in self.readings.items() }, } class DeviceStore: """ Thread-safe in-memory store for device metadata and sensor readings. Designed for single-process asyncio usage (FastAPI + aiocoap). """ def __init__(self, devices_config: DevicesConfig): self._lock = asyncio.Lock() self._devices: dict[str, DeviceState] = {} for dev in devices_config.devices: self._devices[dev.id] = DeviceState(config=dev) logger.info( "Registered device %s (%s) at %s:%d with %d resources", dev.id, dev.name, dev.ip, dev.port, len(dev.resources), ) # ── Queries ── async def get_device(self, device_id: str) -> Optional[DeviceState]: """Get a single device state, or None if not found.""" async with self._lock: return self._devices.get(device_id) async def get_all_devices(self) -> list[DeviceState]: """Get all registered devices.""" async with self._lock: return list(self._devices.values()) async def get_enabled_devices(self) -> list[DeviceState]: """Get only enabled devices (for subscription).""" async with self._lock: return [d for d in self._devices.values() if d.config.enabled] async def get_device_by_ip(self, ip: str) -> Optional[DeviceState]: """Look up a device by IP address.""" async with self._lock: for dev in self._devices.values(): if dev.config.ip == ip: return dev return None async def snapshot(self) -> dict[str, dict]: """Return a JSON-serializable snapshot of all devices + readings.""" async with self._lock: return { dev_id: state.to_dict() for dev_id, state in self._devices.items() } # ── Updates ── async def update_reading( self, device_id: str, resource_uri: str, value: Any, unit: str = "", ) -> None: """Record a new sensor reading from a CoAP notification.""" async with self._lock: state = self._devices.get(device_id) if state is None: logger.warning("Reading for unknown device %s, ignoring", device_id) return now = time.time() state.readings[resource_uri] = SensorReading( value=value, unit=unit, timestamp=now, ) state.online = True state.last_seen = now logger.debug( "Updated %s/%s = %s %s", device_id, resource_uri, value, unit, ) async def mark_online(self, device_id: str) -> None: """Mark a device as online (e.g., on successful subscription).""" async with self._lock: state = self._devices.get(device_id) if state: state.online = True state.last_seen = time.time() async def mark_offline(self, device_id: str) -> None: """Mark a device as offline (e.g., on subscription failure).""" async with self._lock: state = self._devices.get(device_id) if state: state.online = False # Mark all readings as stale for reading in state.readings.values(): reading.stale = True async def mark_reading_stale( self, device_id: str, resource_uri: str ) -> None: """Mark a specific reading as stale (e.g., Max-Age expired).""" async with self._lock: state = self._devices.get(device_id) if state and resource_uri in state.readings: state.readings[resource_uri].stale = True