""" 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": "...", "": 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