docs: add project description and server setup instructions to README
This commit is contained in:
1
server/app/__init__.py
Normal file
1
server/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Demeter IoT Management Server."""
|
||||
98
server/app/coap_bridge.py
Normal file
98
server/app/coap_bridge.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Demeter Server — CoAP-to-REST Bridge
|
||||
|
||||
Proxies HTTP requests to CoAP endpoints on ESP devices, allowing
|
||||
the dashboard and external tools to query devices via standard HTTP.
|
||||
|
||||
Routes:
|
||||
GET /api/devices/{device_id}/coap/{resource_path} — proxy CoAP GET
|
||||
PUT /api/devices/{device_id}/coap/{resource_path} — proxy CoAP PUT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["coap-bridge"])
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
return request.app.state.store
|
||||
|
||||
|
||||
def _get_observer(request: Request):
|
||||
return request.app.state.observer
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}/coap/{resource_path:path}")
|
||||
async def coap_proxy_get(
|
||||
device_id: str,
|
||||
resource_path: str,
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Proxy a CoAP GET request to an ESP device.
|
||||
|
||||
Example: GET /api/devices/esp32-plant-01/coap/sensors/temperature
|
||||
→ CoAP GET coap://192.168.1.100:5683/sensors/temperature
|
||||
"""
|
||||
store = _get_store(request)
|
||||
observer = _get_observer(request)
|
||||
|
||||
device = await store.get_device(device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
|
||||
|
||||
try:
|
||||
result = await observer.coap_get(
|
||||
device.config.ip, device.config.port, resource_path
|
||||
)
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"resource": resource_path,
|
||||
"method": "GET",
|
||||
"response": result,
|
||||
}
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/devices/{device_id}/coap/{resource_path:path}")
|
||||
async def coap_proxy_put(
|
||||
device_id: str,
|
||||
resource_path: str,
|
||||
body: dict[str, Any],
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Proxy a CoAP PUT request to an ESP device.
|
||||
|
||||
Example: PUT /api/devices/esp32-plant-01/coap/config/interval
|
||||
Body: {"interval": 10}
|
||||
→ CoAP PUT coap://192.168.1.100:5683/config/interval
|
||||
"""
|
||||
store = _get_store(request)
|
||||
observer = _get_observer(request)
|
||||
|
||||
device = await store.get_device(device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
|
||||
|
||||
try:
|
||||
result = await observer.coap_put(
|
||||
device.config.ip, device.config.port, resource_path, body
|
||||
)
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"resource": resource_path,
|
||||
"method": "PUT",
|
||||
"response": result,
|
||||
}
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
313
server/app/coap_observer.py
Normal file
313
server/app/coap_observer.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Demeter Server — CoAP Observer Client
|
||||
|
||||
Wraps aiocoap to manage Observe subscriptions to ESP sensor nodes.
|
||||
Each subscription receives push notifications when sensor values change,
|
||||
updating the in-memory DeviceStore.
|
||||
|
||||
Also provides one-shot CoAP GET/PUT for the REST-to-CoAP bridge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import aiocoap
|
||||
from aiocoap import Code, Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device_store import DeviceStore
|
||||
from .metrics import MetricsCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoapObserverClient:
|
||||
"""
|
||||
Manages CoAP Observe subscriptions and one-shot requests.
|
||||
|
||||
Lifecycle:
|
||||
client = CoapObserverClient(store, metrics)
|
||||
await client.startup()
|
||||
await client.subscribe("esp32-plant-01", "192.168.1.100", 5683, "sensors/temperature")
|
||||
...
|
||||
await client.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: DeviceStore,
|
||||
metrics: Optional[MetricsCollector] = None,
|
||||
request_timeout: float = 10.0,
|
||||
reconnect_base: float = 5.0,
|
||||
reconnect_max: float = 60.0,
|
||||
):
|
||||
self._store = store
|
||||
self._metrics = metrics
|
||||
self._context: Optional[aiocoap.Context] = None
|
||||
self._subscriptions: dict[tuple[str, str], asyncio.Task] = {}
|
||||
self._request_timeout = request_timeout
|
||||
self._reconnect_base = reconnect_base
|
||||
self._reconnect_max = reconnect_max
|
||||
|
||||
async def startup(self) -> None:
|
||||
"""Create the aiocoap client context."""
|
||||
self._context = await aiocoap.Context.create_client_context()
|
||||
logger.info("CoAP client context created")
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Cancel all subscriptions and close the context."""
|
||||
logger.info("Shutting down CoAP observer client...")
|
||||
|
||||
# Cancel all subscription tasks
|
||||
for key, task in self._subscriptions.items():
|
||||
task.cancel()
|
||||
logger.debug("Cancelled subscription %s", key)
|
||||
self._subscriptions.clear()
|
||||
|
||||
# Close context
|
||||
if self._context:
|
||||
await self._context.shutdown()
|
||||
self._context = None
|
||||
|
||||
logger.info("CoAP observer client shut down")
|
||||
|
||||
# ── Observe Subscriptions ──
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
device_id: str,
|
||||
ip: str,
|
||||
port: int,
|
||||
resource_uri: str,
|
||||
) -> None:
|
||||
"""
|
||||
Start an Observe subscription to a device resource.
|
||||
|
||||
Runs as a background task with automatic reconnection on failure.
|
||||
"""
|
||||
key = (device_id, resource_uri)
|
||||
if key in self._subscriptions and not self._subscriptions[key].done():
|
||||
logger.debug("Already subscribed to %s/%s", device_id, resource_uri)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._observe_loop(device_id, ip, port, resource_uri),
|
||||
name=f"observe-{device_id}-{resource_uri}",
|
||||
)
|
||||
self._subscriptions[key] = task
|
||||
logger.info("Started Observe subscription: %s/%s", device_id, resource_uri)
|
||||
|
||||
async def unsubscribe(self, device_id: str, resource_uri: str) -> None:
|
||||
"""Cancel a specific subscription."""
|
||||
key = (device_id, resource_uri)
|
||||
task = self._subscriptions.pop(key, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
logger.info("Unsubscribed from %s/%s", device_id, resource_uri)
|
||||
|
||||
async def _observe_loop(
|
||||
self,
|
||||
device_id: str,
|
||||
ip: str,
|
||||
port: int,
|
||||
resource_uri: str,
|
||||
) -> None:
|
||||
"""
|
||||
Observe loop with exponential backoff on failure.
|
||||
|
||||
Keeps reconnecting until cancelled.
|
||||
"""
|
||||
backoff = self._reconnect_base
|
||||
|
||||
while True:
|
||||
try:
|
||||
await self._run_observation(device_id, ip, port, resource_uri)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Observe cancelled: %s/%s", device_id, resource_uri)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Observe failed for %s/%s: %s — retrying in %.0fs",
|
||||
device_id, resource_uri, e, backoff,
|
||||
)
|
||||
await self._store.mark_offline(device_id)
|
||||
if self._metrics:
|
||||
self._metrics.record_coap_error(device_id, type(e).__name__)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
backoff = min(backoff * 2, self._reconnect_max)
|
||||
else:
|
||||
# Observation ended normally (shouldn't happen) — reset backoff
|
||||
backoff = self._reconnect_base
|
||||
|
||||
async def _run_observation(
|
||||
self,
|
||||
device_id: str,
|
||||
ip: str,
|
||||
port: int,
|
||||
resource_uri: str,
|
||||
) -> None:
|
||||
"""Execute a single Observe session."""
|
||||
uri = f"coap://{ip}:{port}/{resource_uri}"
|
||||
request = Message(code=Code.GET, uri=uri, observe=0)
|
||||
|
||||
logger.debug("Sending Observe GET to %s", uri)
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self._context.request(request).response,
|
||||
timeout=self._request_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise ConnectionError(f"Timeout connecting to {uri}")
|
||||
|
||||
# Process the initial response
|
||||
await self._handle_notification(device_id, resource_uri, response)
|
||||
await self._store.mark_online(device_id)
|
||||
|
||||
if self._metrics:
|
||||
self._metrics.record_coap_request(device_id)
|
||||
|
||||
# Reset backoff on successful connection
|
||||
logger.info("Observe active: %s/%s", device_id, resource_uri)
|
||||
|
||||
# Process subsequent notifications
|
||||
observation = self._context.request(Message(code=Code.GET, uri=uri, observe=0))
|
||||
|
||||
async for notification in observation.observation:
|
||||
await self._handle_notification(device_id, resource_uri, notification)
|
||||
if self._metrics:
|
||||
self._metrics.record_coap_request(device_id)
|
||||
|
||||
async def _handle_notification(
|
||||
self,
|
||||
device_id: str,
|
||||
resource_uri: str,
|
||||
response: Message,
|
||||
) -> None:
|
||||
"""Parse a CoAP response/notification and update the store."""
|
||||
try:
|
||||
payload = json.loads(response.payload.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.warning(
|
||||
"Invalid payload from %s/%s: %s", device_id, resource_uri, e
|
||||
)
|
||||
return
|
||||
|
||||
# Extract value and unit from the ESP JSON format
|
||||
# ESP sends: {"device": "...", "<resource_key>": value, "unit": "..."}
|
||||
value = None
|
||||
unit = payload.get("unit", "")
|
||||
|
||||
# Map resource URIs to JSON keys
|
||||
resource_key_map = {
|
||||
"sensors/soil_moisture": "soil_moisture",
|
||||
"sensors/temperature": "temperature",
|
||||
"sensors/water_level": "water_level",
|
||||
"events/trigger": "trigger",
|
||||
}
|
||||
|
||||
key = resource_key_map.get(resource_uri)
|
||||
if key and key in payload:
|
||||
value = payload[key]
|
||||
else:
|
||||
# Fallback: try "value" key or use full payload
|
||||
value = payload.get("value", payload)
|
||||
|
||||
await self._store.update_reading(device_id, resource_uri, value, unit)
|
||||
|
||||
if self._metrics:
|
||||
self._metrics.update_sensor_metric(device_id, resource_uri, value, unit)
|
||||
|
||||
logger.debug(
|
||||
"Notification: %s/%s = %s %s", device_id, resource_uri, value, unit
|
||||
)
|
||||
|
||||
# ── One-Shot Requests (for CoAP bridge) ──
|
||||
|
||||
async def coap_get(
|
||||
self, ip: str, port: int, resource_uri: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send a one-shot CoAP GET and return the parsed JSON payload.
|
||||
|
||||
Raises ConnectionError on timeout, ValueError on invalid response.
|
||||
"""
|
||||
uri = f"coap://{ip}:{port}/{resource_uri}"
|
||||
request = Message(code=Code.GET, uri=uri)
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self._context.request(request).response,
|
||||
timeout=self._request_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise ConnectionError(f"CoAP GET timeout: {uri}")
|
||||
|
||||
if not response.payload:
|
||||
return {"status": "ok", "code": str(response.code)}
|
||||
|
||||
try:
|
||||
return json.loads(response.payload.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
raise ValueError(f"Invalid JSON from {uri}")
|
||||
|
||||
async def coap_put(
|
||||
self, ip: str, port: int, resource_uri: str, payload: dict
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send a one-shot CoAP PUT with a JSON payload.
|
||||
|
||||
Raises ConnectionError on timeout, ValueError on invalid response.
|
||||
"""
|
||||
uri = f"coap://{ip}:{port}/{resource_uri}"
|
||||
request = Message(
|
||||
code=Code.PUT,
|
||||
uri=uri,
|
||||
payload=json.dumps(payload).encode("utf-8"),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self._context.request(request).response,
|
||||
timeout=self._request_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise ConnectionError(f"CoAP PUT timeout: {uri}")
|
||||
|
||||
if not response.payload:
|
||||
return {"status": "ok", "code": str(response.code)}
|
||||
|
||||
try:
|
||||
return json.loads(response.payload.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
raise ValueError(f"Invalid JSON from {uri}")
|
||||
|
||||
# ── Status ──
|
||||
|
||||
@property
|
||||
def active_subscriptions(self) -> int:
|
||||
"""Number of currently active subscription tasks."""
|
||||
return sum(1 for t in self._subscriptions.values() if not t.done())
|
||||
|
||||
def subscription_status(self) -> dict[str, str]:
|
||||
"""Return status of each subscription (running/done/cancelled)."""
|
||||
result = {}
|
||||
for (dev_id, uri), task in self._subscriptions.items():
|
||||
key = f"{dev_id}/{uri}"
|
||||
if task.cancelled():
|
||||
result[key] = "cancelled"
|
||||
elif task.done():
|
||||
exc = task.exception()
|
||||
result[key] = f"failed: {exc}" if exc else "done"
|
||||
else:
|
||||
result[key] = "running"
|
||||
return result
|
||||
99
server/app/config.py
Normal file
99
server/app/config.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Demeter Server — Configuration
|
||||
|
||||
Loads settings from environment variables and the device registry
|
||||
from config/devices.yaml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Paths ──
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
|
||||
|
||||
# ── Device Config Models ──
|
||||
|
||||
class ResourceConfig(BaseModel):
|
||||
"""A single CoAP resource on a device."""
|
||||
uri: str
|
||||
name: str
|
||||
type: str = "periodic" # "periodic" or "event"
|
||||
|
||||
|
||||
class DeviceConfig(BaseModel):
|
||||
"""A single ESP sensor node."""
|
||||
id: str
|
||||
name: str
|
||||
ip: str
|
||||
port: int = 5683
|
||||
enabled: bool = True
|
||||
resources: list[ResourceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DevicesConfig(BaseModel):
|
||||
"""Top-level device registry loaded from YAML."""
|
||||
devices: list[DeviceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Application Settings ──
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Server settings loaded from environment variables."""
|
||||
|
||||
# FastAPI
|
||||
app_title: str = "Demeter IoT Server"
|
||||
app_version: str = "0.1.0"
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# Loki
|
||||
loki_url: Optional[str] = None # e.g. "http://localhost:3100/loki/api/v1/push"
|
||||
|
||||
# CoAP
|
||||
coap_request_timeout: float = 10.0 # seconds
|
||||
coap_reconnect_base: float = 5.0 # base backoff seconds
|
||||
coap_reconnect_max: float = 60.0 # max backoff seconds
|
||||
|
||||
# Device config path
|
||||
devices_config_path: str = str(CONFIG_DIR / "devices.yaml")
|
||||
|
||||
model_config = {"env_prefix": "DEMETER_", "env_file": ".env"}
|
||||
|
||||
|
||||
def load_devices_config(path: str | Path | None = None) -> DevicesConfig:
|
||||
"""Load and validate the device registry from YAML."""
|
||||
if path is None:
|
||||
path = CONFIG_DIR / "devices.yaml"
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
logger.warning("Device config not found at %s, using empty registry", path)
|
||||
return DevicesConfig(devices=[])
|
||||
|
||||
with open(path) as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if raw is None:
|
||||
return DevicesConfig(devices=[])
|
||||
|
||||
return DevicesConfig.model_validate(raw)
|
||||
|
||||
|
||||
# Singleton settings instance
|
||||
settings = Settings()
|
||||
101
server/app/dashboard.py
Normal file
101
server/app/dashboard.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Demeter Server — Dashboard Routes
|
||||
|
||||
Serves the web UI using Jinja2 templates with DaisyUI (Tailwind CSS).
|
||||
Auto-refresh is handled by periodic fetch() calls in the templates.
|
||||
|
||||
Routes:
|
||||
GET / — redirect to dashboard
|
||||
GET /dashboard — main device overview
|
||||
GET /dashboard/devices/{device_id} — device detail page
|
||||
GET /dashboard/api/readings — JSON readings for auto-refresh
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .config import TEMPLATES_DIR
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
return request.app.state.store
|
||||
|
||||
|
||||
def _get_observer(request: Request):
|
||||
return request.app.state.observer
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
"""Redirect root to dashboard."""
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Main dashboard showing all devices and their latest readings."""
|
||||
store = _get_store(request)
|
||||
observer = _get_observer(request)
|
||||
|
||||
devices = await store.get_all_devices()
|
||||
settings = request.app.state.settings
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"devices": devices,
|
||||
"server_version": settings.app_version,
|
||||
"active_subscriptions": observer.active_subscriptions,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/devices/{device_id}", response_class=HTMLResponse)
|
||||
async def device_detail(device_id: str, request: Request):
|
||||
"""Detailed view for a single device."""
|
||||
store = _get_store(request)
|
||||
|
||||
device = await store.get_device(device_id)
|
||||
if device is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"devices": await store.get_all_devices(),
|
||||
"server_version": request.app.state.settings.app_version,
|
||||
"active_subscriptions": _get_observer(request).active_subscriptions,
|
||||
"timestamp": time.time(),
|
||||
"error": f"Device {device_id!r} not found",
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"device_detail.html",
|
||||
{
|
||||
"device": device,
|
||||
"server_version": request.app.state.settings.app_version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/api/readings")
|
||||
async def dashboard_readings(request: Request) -> dict[str, Any]:
|
||||
"""JSON endpoint for dashboard auto-refresh polling."""
|
||||
store = _get_store(request)
|
||||
snapshot = await store.snapshot()
|
||||
return {
|
||||
"devices": snapshot,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
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
|
||||
90
server/app/devices_api.py
Normal file
90
server/app/devices_api.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Demeter Server — REST API Endpoints
|
||||
|
||||
Provides JSON endpoints for querying device metadata and sensor
|
||||
readings from the in-memory store.
|
||||
|
||||
Routes:
|
||||
GET /api/devices — list all devices + latest readings
|
||||
GET /api/devices/{device_id} — single device detail
|
||||
GET /api/devices/{device_id}/readings — sensor readings only
|
||||
GET /api/status — server status summary
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["devices"])
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
"""Retrieve the DeviceStore from app state."""
|
||||
return request.app.state.store
|
||||
|
||||
|
||||
def _get_observer(request: Request):
|
||||
"""Retrieve the CoapObserverClient from app state."""
|
||||
return request.app.state.observer
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
async def list_devices(request: Request) -> dict[str, Any]:
|
||||
"""List all registered devices with their latest readings."""
|
||||
store = _get_store(request)
|
||||
snapshot = await store.snapshot()
|
||||
return {
|
||||
"devices": list(snapshot.values()),
|
||||
"count": len(snapshot),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}")
|
||||
async def get_device(device_id: str, request: Request) -> dict[str, Any]:
|
||||
"""Get a single device's full state."""
|
||||
store = _get_store(request)
|
||||
device = await store.get_device(device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
|
||||
return device.to_dict()
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}/readings")
|
||||
async def get_readings(device_id: str, request: Request) -> dict[str, Any]:
|
||||
"""Get sensor readings for a specific device."""
|
||||
store = _get_store(request)
|
||||
device = await store.get_device(device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"online": device.online,
|
||||
"readings": {
|
||||
uri: reading.to_dict()
|
||||
for uri, reading in device.readings.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def server_status(request: Request) -> dict[str, Any]:
|
||||
"""Server status: subscription count, device overview."""
|
||||
store = _get_store(request)
|
||||
observer = _get_observer(request)
|
||||
|
||||
devices = await store.get_all_devices()
|
||||
online = sum(1 for d in devices if d.online)
|
||||
|
||||
return {
|
||||
"server": "demeter",
|
||||
"version": request.app.state.settings.app_version,
|
||||
"devices_total": len(devices),
|
||||
"devices_online": online,
|
||||
"active_subscriptions": observer.active_subscriptions,
|
||||
"subscriptions": observer.subscription_status(),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
64
server/app/logging_config.py
Normal file
64
server/app/logging_config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Demeter Server — Logging Configuration
|
||||
|
||||
Sets up structured logging with optional Loki integration.
|
||||
Falls back to console-only logging if Loki is unreachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def setup_logging(loki_url: Optional[str] = None, debug: bool = False) -> None:
|
||||
"""
|
||||
Configure logging for the Demeter server.
|
||||
|
||||
Args:
|
||||
loki_url: Loki push endpoint (e.g., "http://localhost:3100/loki/api/v1/push").
|
||||
If None, logs go to console only.
|
||||
debug: Enable DEBUG level logging.
|
||||
"""
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
|
||||
# Root logger
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
|
||||
# Console handler
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
fmt = logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)-8s %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
console.setFormatter(fmt)
|
||||
root.addHandler(console)
|
||||
|
||||
# Loki handler (optional)
|
||||
if loki_url:
|
||||
try:
|
||||
import logging_loki
|
||||
|
||||
loki_handler = logging_loki.LokiHandler(
|
||||
url=loki_url,
|
||||
tags={"app": "demeter-server"},
|
||||
version="1",
|
||||
)
|
||||
loki_handler.setLevel(level)
|
||||
root.addHandler(loki_handler)
|
||||
logging.getLogger(__name__).info("Loki logging enabled at %s", loki_url)
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).warning(
|
||||
"python-logging-loki not installed, Loki logging disabled"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Failed to connect to Loki at %s: %s", loki_url, e
|
||||
)
|
||||
|
||||
# Quiet noisy libraries
|
||||
logging.getLogger("aiocoap").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
140
server/app/main.py
Normal file
140
server/app/main.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Demeter Server — FastAPI Application
|
||||
|
||||
Entry point for the Demeter IoT management server. Manages the
|
||||
lifecycle of the aiocoap observer client and FastAPI HTTP server
|
||||
on a shared asyncio event loop.
|
||||
|
||||
Run with:
|
||||
cd server
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .coap_bridge import router as coap_bridge_router
|
||||
from .coap_observer import CoapObserverClient
|
||||
from .config import Settings, load_devices_config
|
||||
from .dashboard import router as dashboard_router
|
||||
from .device_store import DeviceStore
|
||||
from .devices_api import router as devices_api_router
|
||||
from .logging_config import setup_logging
|
||||
from .metrics import MetricsCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Manage startup and shutdown of the CoAP observer client.
|
||||
|
||||
Startup:
|
||||
1. Configure logging (console + optional Loki)
|
||||
2. Load device registry from YAML
|
||||
3. Initialize the in-memory store
|
||||
4. Create the aiocoap client context
|
||||
5. Subscribe to all enabled device resources
|
||||
|
||||
Shutdown:
|
||||
1. Cancel all CoAP subscriptions
|
||||
2. Close the aiocoap context
|
||||
"""
|
||||
settings: Settings = app.state.settings
|
||||
metrics: MetricsCollector = app.state.metrics
|
||||
|
||||
# ── Logging ──
|
||||
setup_logging(loki_url=settings.loki_url, debug=settings.debug)
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(" Demeter IoT Server v%s", settings.app_version)
|
||||
logger.info("=" * 50)
|
||||
|
||||
# ── Device Config ──
|
||||
devices_config = load_devices_config(settings.devices_config_path)
|
||||
enabled = [d for d in devices_config.devices if d.enabled]
|
||||
logger.info("Loaded %d devices (%d enabled)", len(devices_config.devices), len(enabled))
|
||||
|
||||
# ── Store ──
|
||||
store = DeviceStore(devices_config)
|
||||
app.state.store = store
|
||||
|
||||
# ── Metrics ──
|
||||
metrics.set_server_info(settings.app_version)
|
||||
|
||||
# ── CoAP Observer ──
|
||||
observer = CoapObserverClient(
|
||||
store=store,
|
||||
metrics=metrics,
|
||||
request_timeout=settings.coap_request_timeout,
|
||||
reconnect_base=settings.coap_reconnect_base,
|
||||
reconnect_max=settings.coap_reconnect_max,
|
||||
)
|
||||
app.state.observer = observer
|
||||
|
||||
await observer.startup()
|
||||
|
||||
# Subscribe to all enabled device resources
|
||||
for device in enabled:
|
||||
for resource in device.resources:
|
||||
await observer.subscribe(
|
||||
device_id=device.id,
|
||||
ip=device.ip,
|
||||
port=device.port,
|
||||
resource_uri=resource.uri,
|
||||
)
|
||||
|
||||
total_subs = sum(len(d.resources) for d in enabled)
|
||||
logger.info("Subscribed to %d resources across %d devices", total_subs, len(enabled))
|
||||
metrics.set_active_subscriptions(total_subs)
|
||||
|
||||
logger.info("Server ready — dashboard at http://%s:%d/dashboard", settings.host, settings.port)
|
||||
|
||||
yield
|
||||
|
||||
# ── Shutdown ──
|
||||
logger.info("Shutting down...")
|
||||
await observer.shutdown()
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
settings = Settings()
|
||||
metrics = MetricsCollector()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_title,
|
||||
version=settings.app_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Store settings and metrics on app state (available before lifespan)
|
||||
app.state.settings = settings
|
||||
app.state.metrics = metrics
|
||||
|
||||
# ── Routers ──
|
||||
app.include_router(devices_api_router)
|
||||
app.include_router(coap_bridge_router)
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
# ── Prometheus /metrics ──
|
||||
@app.get("/metrics", tags=["monitoring"], include_in_schema=False)
|
||||
async def prometheus_metrics():
|
||||
return Response(
|
||||
content=metrics.generate(),
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Module-level app instance for uvicorn
|
||||
app = create_app()
|
||||
131
server/app/metrics.py
Normal file
131
server/app/metrics.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Demeter Server — Prometheus Metrics
|
||||
|
||||
Defines gauges and counters for sensor readings, device status,
|
||||
and CoAP communication. Exposes a /metrics endpoint for Prometheus
|
||||
scraping.
|
||||
|
||||
Uses a dedicated CollectorRegistry to avoid conflicts when creating
|
||||
multiple app instances (e.g., in tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from prometheus_client import (
|
||||
CollectorRegistry,
|
||||
Counter,
|
||||
Gauge,
|
||||
Info,
|
||||
generate_latest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetricsCollector:
|
||||
"""Manages all Prometheus metrics for the Demeter server."""
|
||||
|
||||
def __init__(self, registry: CollectorRegistry | None = None) -> None:
|
||||
self.registry = registry or CollectorRegistry()
|
||||
|
||||
# Server info
|
||||
self.server_info = Info(
|
||||
"demeter_server",
|
||||
"Demeter IoT management server information",
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Sensor readings (the core metric)
|
||||
self.sensor_reading = Gauge(
|
||||
"demeter_sensor_reading",
|
||||
"Latest sensor reading value",
|
||||
["device", "resource", "unit"],
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Device online status
|
||||
self.device_online = Gauge(
|
||||
"demeter_device_online",
|
||||
"Whether the device is online (1) or offline (0)",
|
||||
["device"],
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Last reading timestamp (unix epoch)
|
||||
self.last_reading_ts = Gauge(
|
||||
"demeter_last_reading_timestamp_seconds",
|
||||
"Unix timestamp of the last reading received",
|
||||
["device", "resource"],
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# CoAP request counters
|
||||
self.coap_requests = Counter(
|
||||
"demeter_coap_requests_total",
|
||||
"Total CoAP requests/notifications received",
|
||||
["device"],
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
self.coap_errors = Counter(
|
||||
"demeter_coap_errors_total",
|
||||
"Total CoAP communication errors",
|
||||
["device", "error_type"],
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Active subscriptions
|
||||
self.active_subscriptions = Gauge(
|
||||
"demeter_active_subscriptions",
|
||||
"Number of active CoAP Observe subscriptions",
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
def set_server_info(self, version: str) -> None:
|
||||
"""Set server version info."""
|
||||
self.server_info.info({"version": version})
|
||||
|
||||
def update_sensor_metric(
|
||||
self,
|
||||
device_id: str,
|
||||
resource_uri: str,
|
||||
value: Any,
|
||||
unit: str,
|
||||
) -> None:
|
||||
"""Update the sensor reading gauge from a notification."""
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
# Non-numeric values (e.g., trigger state) — store as 0/1
|
||||
numeric = float(value) if isinstance(value, (int, bool)) else 0.0
|
||||
|
||||
self.sensor_reading.labels(
|
||||
device=device_id, resource=resource_uri, unit=unit
|
||||
).set(numeric)
|
||||
|
||||
self.last_reading_ts.labels(
|
||||
device=device_id, resource=resource_uri
|
||||
).set_to_current_time()
|
||||
|
||||
def set_device_online(self, device_id: str, online: bool) -> None:
|
||||
"""Update device online/offline status."""
|
||||
self.device_online.labels(device=device_id).set(1 if online else 0)
|
||||
|
||||
def record_coap_request(self, device_id: str) -> None:
|
||||
"""Increment CoAP request counter."""
|
||||
self.coap_requests.labels(device=device_id).inc()
|
||||
|
||||
def record_coap_error(self, device_id: str, error_type: str) -> None:
|
||||
"""Increment CoAP error counter."""
|
||||
self.coap_errors.labels(device=device_id, error_type=error_type).inc()
|
||||
|
||||
def set_active_subscriptions(self, count: int) -> None:
|
||||
"""Update the active subscription gauge."""
|
||||
self.active_subscriptions.set(count)
|
||||
|
||||
def generate(self) -> bytes:
|
||||
"""Generate Prometheus metrics output from this collector's registry."""
|
||||
return generate_latest(self.registry)
|
||||
Reference in New Issue
Block a user