docs: add project description and server setup instructions to README

This commit is contained in:
2026-03-21 18:25:35 +00:00
parent c81815a83d
commit 6115a065c7
36 changed files with 4003 additions and 0 deletions

1
server/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Demeter IoT Management Server."""

98
server/app/coap_bridge.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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(),
}

View 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
View 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
View 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)

View File

@@ -0,0 +1,39 @@
# Demeter Device Registry
# Maps ESP sensor nodes to their CoAP resources.
# Edit this file to add/remove devices for your deployment.
devices:
- id: "esp32-plant-01"
name: "Plant Monitor - Bedroom"
ip: "192.168.1.100"
port: 5683
enabled: true
resources:
- uri: "sensors/soil_moisture"
name: "Soil Moisture"
type: "periodic"
- uri: "sensors/temperature"
name: "Temperature"
type: "periodic"
- uri: "sensors/water_level"
name: "Water Level"
type: "periodic"
- uri: "events/trigger"
name: "Trigger Events"
type: "event"
- id: "esp32-aquarium-01"
name: "Aquarium Monitor"
ip: "192.168.1.101"
port: 5683
enabled: true
resources:
- uri: "sensors/temperature"
name: "Water Temperature"
type: "periodic"
- uri: "sensors/water_level"
name: "Tank Level"
type: "periodic"
- uri: "events/trigger"
name: "Float Switch"
type: "event"

36
server/pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "demeter-server"
version = "0.1.0"
description = "Demeter IoT Management Server — CoAP observer + FastAPI dashboard"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = [
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"aiocoap>=0.4.8",
"pyyaml>=6.0",
"prometheus-client>=0.19.0",
"jinja2>=3.1.2",
"python-logging-loki>=0.3.1",
"pydantic-settings>=2.1.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.25.0",
]
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Demeter IoT{% endblock %}</title>
<!-- Tailwind CSS + DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-base-200">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/dashboard" class="btn btn-ghost text-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
Demeter
</a>
</div>
<div class="flex-none gap-2">
<a href="/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
<a href="/api/status" class="btn btn-ghost btn-sm">Status</a>
<a href="/docs" class="btn btn-ghost btn-sm">API Docs</a>
<!-- Theme toggle -->
<label class="swap swap-rotate btn btn-ghost btn-circle">
<input type="checkbox" id="themeToggle" />
<!-- Sun icon -->
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
</svg>
<!-- Moon icon -->
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Z"/>
</svg>
</label>
</div>
</div>
<!-- Main content -->
<main class="container mx-auto p-4 max-w-7xl">
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-8">
<div>
<p>Demeter IoT Server v{{ server_version }} — CoAP + FastAPI</p>
</div>
</footer>
<!-- Theme toggle script -->
<script>
const toggle = document.getElementById('themeToggle');
const html = document.documentElement;
// Load saved theme
const saved = localStorage.getItem('demeter-theme');
if (saved === 'light') {
html.setAttribute('data-theme', 'light');
toggle.checked = true;
}
toggle.addEventListener('change', () => {
const theme = toggle.checked ? 'light' : 'dark';
html.setAttribute('data-theme', theme);
localStorage.setItem('demeter-theme', theme);
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}Dashboard — Demeter IoT{% endblock %}
{% block content %}
<!-- Stats bar -->
<div class="stats shadow w-full mb-6 bg-base-100">
<div class="stat">
<div class="stat-title">Devices</div>
<div class="stat-value" id="stat-total">{{ devices|length }}</div>
<div class="stat-desc">Registered</div>
</div>
<div class="stat">
<div class="stat-title">Online</div>
<div class="stat-value text-success" id="stat-online">{{ devices|selectattr('online')|list|length }}</div>
<div class="stat-desc">Connected via CoAP</div>
</div>
<div class="stat">
<div class="stat-title">Subscriptions</div>
<div class="stat-value text-info" id="stat-subs">{{ active_subscriptions }}</div>
<div class="stat-desc">Active Observe</div>
</div>
</div>
<!-- Device cards grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="devices-grid">
{% for device in devices %}
<div class="card bg-base-100 shadow-xl" id="card-{{ device.config.id }}">
<div class="card-body">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="card-title text-lg">{{ device.config.name }}</h2>
{% if device.online %}
<div class="badge badge-success gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"/>
</svg>
Online
</div>
{% else %}
<div class="badge badge-error gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"/>
</svg>
Offline
</div>
{% endif %}
</div>
<p class="text-sm opacity-60">{{ device.config.ip }}:{{ device.config.port }} &middot; {{ device.config.id }}</p>
<div class="divider my-1"></div>
<!-- Sensor readings -->
{% if device.readings %}
{% for uri, reading in device.readings.items() %}
<div class="flex justify-between items-center py-1">
<span class="text-sm font-medium">
{% if 'soil_moisture' in uri %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" /></svg>
Soil Moisture
{% elif 'temperature' in uri %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
Temperature
{% elif 'water_level' in uri %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /></svg>
Water Level
{% elif 'trigger' in uri %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Trigger
{% else %}
{{ uri }}
{% endif %}
</span>
<span class="font-mono text-lg {% if reading.stale %}opacity-40{% endif %}">
{% if reading.value is not none %}
{{ reading.value }}
<span class="text-xs opacity-60">{{ reading.unit }}</span>
{% else %}
<span class="opacity-40"></span>
{% endif %}
</span>
</div>
{% if 'soil_moisture' in uri or 'water_level' in uri %}
<progress
class="progress {% if 'soil_moisture' in uri %}progress-success{% else %}progress-info{% endif %} w-full h-2"
value="{{ reading.value or 0 }}"
max="100">
</progress>
{% endif %}
{% endfor %}
{% else %}
<p class="text-sm opacity-40 italic">No readings yet</p>
{% endif %}
<!-- Card actions -->
<div class="card-actions justify-end mt-2">
<a href="/dashboard/devices/{{ device.config.id }}" class="btn btn-sm btn-outline">Details</a>
</div>
</div>
</div>
{% endfor %}
{% if not devices %}
<div class="col-span-full">
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>No devices configured. Edit <code>config/devices.yaml</code> and restart the server.</span>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh readings every 5 seconds
setInterval(async () => {
try {
const resp = await fetch('/dashboard/api/readings');
const data = await resp.json();
// Update online count
let online = 0;
for (const [id, dev] of Object.entries(data.devices)) {
if (dev.online) online++;
// Update readings in each card
// (Full re-render would be more robust, but this is POC)
}
const statOnline = document.getElementById('stat-online');
if (statOnline) statOnline.textContent = online;
} catch (e) {
console.warn('Auto-refresh failed:', e);
}
}, 5000);
</script>
{% endblock %}

View File

@@ -0,0 +1,164 @@
{% extends "base.html" %}
{% block title %}{{ device.config.name }} — Demeter IoT{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li>{{ device.config.name }}</li>
</ul>
</div>
<!-- Device header -->
<div class="flex items-center gap-4 mb-6">
<h1 class="text-3xl font-bold">{{ device.config.name }}</h1>
{% if device.online %}
<div class="badge badge-success badge-lg">Online</div>
{% else %}
<div class="badge badge-error badge-lg">Offline</div>
{% endif %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Device info card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Device Information</h2>
<table class="table table-sm">
<tbody>
<tr>
<td class="font-medium opacity-60">Device ID</td>
<td class="font-mono">{{ device.config.id }}</td>
</tr>
<tr>
<td class="font-medium opacity-60">IP Address</td>
<td class="font-mono">{{ device.config.ip }}:{{ device.config.port }}</td>
</tr>
<tr>
<td class="font-medium opacity-60">Status</td>
<td>
{% if device.online %}
<span class="text-success">Connected</span>
{% else %}
<span class="text-error">Disconnected</span>
{% endif %}
</td>
</tr>
<tr>
<td class="font-medium opacity-60">Resources</td>
<td>{{ device.config.resources|length }} configured</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CoAP Bridge card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">CoAP Bridge</h2>
<p class="text-sm opacity-60 mb-2">Query the device directly via the HTTP-to-CoAP proxy.</p>
<div class="space-y-2">
{% for resource in device.config.resources %}
<div class="flex items-center justify-between">
<span class="font-mono text-sm">{{ resource.uri }}</span>
<button class="btn btn-xs btn-outline"
onclick="coapGet('{{ device.config.id }}', '{{ resource.uri }}')">
GET
</button>
</div>
{% endfor %}
<div class="flex items-center justify-between">
<span class="font-mono text-sm">device/info</span>
<button class="btn btn-xs btn-outline"
onclick="coapGet('{{ device.config.id }}', 'device/info')">
GET
</button>
</div>
</div>
<!-- Response display -->
<div class="mt-4">
<pre id="coap-response" class="bg-base-200 p-3 rounded-lg text-sm font-mono overflow-x-auto min-h-[60px] opacity-60">
Click GET to query a resource...</pre>
</div>
</div>
</div>
</div>
<!-- Sensor readings -->
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">Sensor Readings</h2>
{% if device.readings %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Resource</th>
<th>Value</th>
<th>Unit</th>
<th>Age</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for uri, reading in device.readings.items() %}
<tr>
<td class="font-mono text-sm">{{ uri }}</td>
<td class="font-mono text-lg">
{% if reading.value is not none %}
{{ reading.value }}
{% else %}
<span class="opacity-40"></span>
{% endif %}
</td>
<td>{{ reading.unit }}</td>
<td>{{ reading.age_seconds()|round(0)|int }}s ago</td>
<td>
{% if reading.stale %}
<div class="badge badge-warning badge-sm">Stale</div>
{% else %}
<div class="badge badge-success badge-sm">Fresh</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-40 italic">No readings received yet. The device may be offline or not yet subscribed.</p>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function coapGet(deviceId, resource) {
const pre = document.getElementById('coap-response');
pre.textContent = 'Fetching...';
pre.classList.remove('opacity-60');
try {
const resp = await fetch(`/api/devices/${deviceId}/coap/${resource}`);
const data = await resp.json();
pre.textContent = JSON.stringify(data, null, 2);
if (!resp.ok) {
pre.classList.add('text-error');
} else {
pre.classList.remove('text-error');
}
} catch (e) {
pre.textContent = `Error: ${e.message}`;
pre.classList.add('text-error');
}
}
</script>
{% endblock %}

0
server/tests/__init__.py Normal file
View File

98
server/tests/conftest.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Shared fixtures for Demeter server tests.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
from app.config import DeviceConfig, DevicesConfig, ResourceConfig, Settings
from app.device_store import DeviceStore
from app.metrics import MetricsCollector
@pytest.fixture
def sample_devices_config() -> DevicesConfig:
"""A minimal device config for testing."""
return DevicesConfig(
devices=[
DeviceConfig(
id="test-plant-01",
name="Test Plant",
ip="192.168.1.100",
port=5683,
enabled=True,
resources=[
ResourceConfig(uri="sensors/soil_moisture", name="Soil Moisture", type="periodic"),
ResourceConfig(uri="sensors/temperature", name="Temperature", type="periodic"),
ResourceConfig(uri="events/trigger", name="Trigger", type="event"),
],
),
DeviceConfig(
id="test-aquarium-01",
name="Test Aquarium",
ip="192.168.1.101",
port=5683,
enabled=True,
resources=[
ResourceConfig(uri="sensors/temperature", name="Water Temp", type="periodic"),
],
),
DeviceConfig(
id="test-disabled",
name="Disabled Device",
ip="192.168.1.102",
port=5683,
enabled=False,
resources=[],
),
]
)
@pytest.fixture
def store(sample_devices_config: DevicesConfig) -> DeviceStore:
"""An initialized DeviceStore with sample devices."""
return DeviceStore(sample_devices_config)
@pytest.fixture
def mock_observer() -> MagicMock:
"""A mock CoapObserverClient for API tests."""
observer = MagicMock()
observer.active_subscriptions = 3
observer.subscription_status.return_value = {
"test-plant-01/sensors/soil_moisture": "running",
"test-plant-01/sensors/temperature": "running",
"test-aquarium-01/sensors/temperature": "running",
}
observer.coap_get = AsyncMock(return_value={
"device": "test-plant-01",
"temperature": 24.5,
"unit": "celsius",
})
observer.coap_put = AsyncMock(return_value={"interval": 10})
return observer
@pytest.fixture
def app(store: DeviceStore, mock_observer: MagicMock):
"""FastAPI app with mocked dependencies."""
from app.main import create_app
test_app = create_app()
test_app.state.store = store
test_app.state.observer = mock_observer
test_app.state.settings = Settings()
return test_app
@pytest.fixture
def client(app) -> TestClient:
"""FastAPI TestClient (no lifespan — dependencies are mocked)."""
return TestClient(app, raise_server_exceptions=False)

View File

@@ -0,0 +1,108 @@
"""
Tests for the in-memory DeviceStore.
"""
from __future__ import annotations
import asyncio
import pytest
from app.device_store import DeviceStore, SensorReading
@pytest.mark.asyncio
async def test_get_all_devices(store: DeviceStore):
"""All registered devices are returned."""
devices = await store.get_all_devices()
assert len(devices) == 3
@pytest.mark.asyncio
async def test_get_enabled_devices(store: DeviceStore):
"""Only enabled devices are returned."""
devices = await store.get_enabled_devices()
assert len(devices) == 2
ids = {d.config.id for d in devices}
assert "test-disabled" not in ids
@pytest.mark.asyncio
async def test_get_device_by_id(store: DeviceStore):
"""Lookup by device ID."""
device = await store.get_device("test-plant-01")
assert device is not None
assert device.config.name == "Test Plant"
missing = await store.get_device("nonexistent")
assert missing is None
@pytest.mark.asyncio
async def test_get_device_by_ip(store: DeviceStore):
"""Lookup by IP address."""
device = await store.get_device_by_ip("192.168.1.100")
assert device is not None
assert device.config.id == "test-plant-01"
@pytest.mark.asyncio
async def test_update_reading(store: DeviceStore):
"""Recording a reading updates value and marks device online."""
await store.update_reading("test-plant-01", "sensors/soil_moisture", 42.5, "percent")
device = await store.get_device("test-plant-01")
assert device.online is True
assert "sensors/soil_moisture" in device.readings
assert device.readings["sensors/soil_moisture"].value == 42.5
assert device.readings["sensors/soil_moisture"].unit == "percent"
@pytest.mark.asyncio
async def test_update_reading_unknown_device(store: DeviceStore):
"""Readings for unknown devices are silently ignored."""
await store.update_reading("unknown-device", "sensors/x", 99, "unit")
device = await store.get_device("unknown-device")
assert device is None
@pytest.mark.asyncio
async def test_mark_offline(store: DeviceStore):
"""Marking offline sets flag and marks readings stale."""
await store.update_reading("test-plant-01", "sensors/temperature", 24.0, "celsius")
await store.mark_offline("test-plant-01")
device = await store.get_device("test-plant-01")
assert device.online is False
assert device.readings["sensors/temperature"].stale is True
@pytest.mark.asyncio
async def test_mark_online(store: DeviceStore):
"""Marking online resets the flag."""
await store.mark_offline("test-plant-01")
await store.mark_online("test-plant-01")
device = await store.get_device("test-plant-01")
assert device.online is True
@pytest.mark.asyncio
async def test_snapshot(store: DeviceStore):
"""Snapshot returns a serializable dict of all devices."""
await store.update_reading("test-plant-01", "sensors/temperature", 23.5, "celsius")
snap = await store.snapshot()
assert "test-plant-01" in snap
assert snap["test-plant-01"]["readings"]["sensors/temperature"]["value"] == 23.5
def test_sensor_reading_age():
"""SensorReading.age_seconds returns meaningful values."""
import time
reading = SensorReading(value=42, unit="percent", timestamp=time.time() - 10)
assert 9.5 < reading.age_seconds() < 11.0
stale = SensorReading(value=0, timestamp=0)
assert stale.age_seconds() == float("inf")

View File

@@ -0,0 +1,133 @@
"""
Tests for the REST API endpoints.
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from app.device_store import DeviceStore
class TestDevicesApi:
"""Tests for /api/devices endpoints."""
def test_list_devices(self, client: TestClient):
"""GET /api/devices returns all devices."""
resp = client.get("/api/devices")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
assert len(data["devices"]) == 3
def test_get_device(self, client: TestClient):
"""GET /api/devices/{id} returns device detail."""
resp = client.get("/api/devices/test-plant-01")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "test-plant-01"
assert data["name"] == "Test Plant"
def test_get_device_not_found(self, client: TestClient):
"""GET /api/devices/{id} returns 404 for missing device."""
resp = client.get("/api/devices/nonexistent")
assert resp.status_code == 404
def test_get_readings_empty(self, client: TestClient):
"""GET /api/devices/{id}/readings returns empty when no data."""
resp = client.get("/api/devices/test-plant-01/readings")
assert resp.status_code == 200
data = resp.json()
assert data["device_id"] == "test-plant-01"
assert data["readings"] == {}
def test_get_readings_with_data(self, client: TestClient, store: DeviceStore):
"""GET /api/devices/{id}/readings returns stored readings."""
# Populate store
loop = asyncio.get_event_loop()
loop.run_until_complete(
store.update_reading("test-plant-01", "sensors/temperature", 25.0, "celsius")
)
resp = client.get("/api/devices/test-plant-01/readings")
assert resp.status_code == 200
data = resp.json()
assert "sensors/temperature" in data["readings"]
assert data["readings"]["sensors/temperature"]["value"] == 25.0
def test_server_status(self, client: TestClient):
"""GET /api/status returns server info."""
resp = client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert data["server"] == "demeter"
assert data["devices_total"] == 3
assert "active_subscriptions" in data
class TestCoapBridge:
"""Tests for /api/devices/{id}/coap/ bridge endpoints."""
def test_coap_get(self, client: TestClient):
"""GET bridge proxies CoAP request and returns response."""
resp = client.get("/api/devices/test-plant-01/coap/sensors/temperature")
assert resp.status_code == 200
data = resp.json()
assert data["device_id"] == "test-plant-01"
assert data["method"] == "GET"
assert "response" in data
def test_coap_get_not_found(self, client: TestClient):
"""GET bridge returns 404 for missing device."""
resp = client.get("/api/devices/missing/coap/sensors/temperature")
assert resp.status_code == 404
def test_coap_put(self, client: TestClient):
"""PUT bridge proxies CoAP request."""
resp = client.put(
"/api/devices/test-plant-01/coap/config/interval",
json={"interval": 10},
)
assert resp.status_code == 200
data = resp.json()
assert data["method"] == "PUT"
class TestMetricsEndpoint:
"""Tests for the /metrics Prometheus endpoint."""
def test_metrics_returns_text(self, client: TestClient):
"""GET /metrics returns Prometheus text format."""
resp = client.get("/metrics")
assert resp.status_code == 200
assert "text/plain" in resp.headers["content-type"]
# Should contain our metric names
assert "demeter_server_info" in resp.text
class TestDashboard:
"""Tests for dashboard HTML routes."""
def test_root_redirects(self, client: TestClient):
"""GET / redirects to /dashboard."""
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 307
assert "/dashboard" in resp.headers["location"]
def test_dashboard_renders(self, client: TestClient):
"""GET /dashboard returns HTML."""
resp = client.get("/dashboard")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
assert "Demeter" in resp.text
def test_dashboard_api_readings(self, client: TestClient):
"""GET /dashboard/api/readings returns JSON."""
resp = client.get("/dashboard/api/readings")
assert resp.status_code == 200
data = resp.json()
assert "devices" in data
assert "timestamp" in data