docs: add project description and server setup instructions to README
This commit is contained in:
177
server/app/device_store.py
Normal file
177
server/app/device_store.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user