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

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