From 6115a065c79c27c137287a4e70f6fa320dc021ec Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Sat, 21 Mar 2026 18:25:35 +0000 Subject: [PATCH] docs: add project description and server setup instructions to README --- .DS_Store | Bin 0 -> 14340 bytes README.md | 6 + docs/demeter-architecture.docx | Bin 0 -> 19538 bytes esp/.DS_Store | Bin 0 -> 6148 bytes esp/README.md | 77 +++++ esp/config.py | 58 ++++ esp/main.py | 339 ++++++++++++++++++ esp/microcoapy/__init__.py | 7 + esp/microcoapy/coap_macros.py | 112 ++++++ esp/microcoapy/coap_option.py | 10 + esp/microcoapy/coap_packet.py | 83 +++++ esp/microcoapy/coap_reader.py | 88 +++++ esp/microcoapy/coap_writer.py | 67 ++++ esp/microcoapy/microcoapy.py | 512 ++++++++++++++++++++++++++++ esp/microcoapy/observe_manager.py | 144 ++++++++ esp/sensors.py | 223 ++++++++++++ esp/tests/test_observe.py | 259 ++++++++++++++ server/app/__init__.py | 1 + server/app/coap_bridge.py | 98 ++++++ server/app/coap_observer.py | 313 +++++++++++++++++ server/app/config.py | 99 ++++++ server/app/dashboard.py | 101 ++++++ server/app/device_store.py | 177 ++++++++++ server/app/devices_api.py | 90 +++++ server/app/logging_config.py | 64 ++++ server/app/main.py | 140 ++++++++ server/app/metrics.py | 131 +++++++ server/config/devices.yaml | 39 +++ server/pyproject.toml | 36 ++ server/templates/base.html | 85 +++++ server/templates/dashboard.html | 141 ++++++++ server/templates/device_detail.html | 164 +++++++++ server/tests/__init__.py | 0 server/tests/conftest.py | 98 ++++++ server/tests/test_device_store.py | 108 ++++++ server/tests/test_devices_api.py | 133 ++++++++ 36 files changed, 4003 insertions(+) create mode 100644 .DS_Store create mode 100644 docs/demeter-architecture.docx create mode 100644 esp/.DS_Store create mode 100644 esp/README.md create mode 100644 esp/config.py create mode 100644 esp/main.py create mode 100644 esp/microcoapy/__init__.py create mode 100644 esp/microcoapy/coap_macros.py create mode 100644 esp/microcoapy/coap_option.py create mode 100644 esp/microcoapy/coap_packet.py create mode 100644 esp/microcoapy/coap_reader.py create mode 100644 esp/microcoapy/coap_writer.py create mode 100644 esp/microcoapy/microcoapy.py create mode 100644 esp/microcoapy/observe_manager.py create mode 100644 esp/sensors.py create mode 100644 esp/tests/test_observe.py create mode 100644 server/app/__init__.py create mode 100644 server/app/coap_bridge.py create mode 100644 server/app/coap_observer.py create mode 100644 server/app/config.py create mode 100644 server/app/dashboard.py create mode 100644 server/app/device_store.py create mode 100644 server/app/devices_api.py create mode 100644 server/app/logging_config.py create mode 100644 server/app/main.py create mode 100644 server/app/metrics.py create mode 100644 server/config/devices.yaml create mode 100644 server/pyproject.toml create mode 100644 server/templates/base.html create mode 100644 server/templates/dashboard.html create mode 100644 server/templates/device_detail.html create mode 100644 server/tests/__init__.py create mode 100644 server/tests/conftest.py create mode 100644 server/tests/test_device_store.py create mode 100644 server/tests/test_devices_api.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..200d5750c3c7e5a1fa2a8274f50a3770cea3db48 GIT binary patch literal 14340 zcmeHNeQX>@6`$YOiFcE2ymsuw**Ni~ZlDbgJ-be9Cv7;}mnNy*IQ2*Tky7XGc1c!U z@4B~l=Yk1Xi0`&S@D~({#6Nrp5lShLQjn0KZCWTGa6v(+e-tPm0)bShgoFfdcJ|Ka z%LNdEP`5kM&YO8}X7}dpd++yVZW&`3$eW#v#TjD?U4rTwRl6CB2`UPw3v|`eRl_Xi zvw73cIBCyw*oA;6l2;T>;1Fd!m*6! zp2%84KAG{H{G990>J6EclgT@&oYn6*WA@uD;??_^tmilz^5p;6PC zw@emn-%dN$bjr_Iu4%hxCKhZncXq}$EjMSs#kR7lsy{yNrsk~W#s=pVp}8 zDLL>B+Fot^Zre3g-ItuW)@4jMskG&&x+$NtPwO!z3sTLhJ|vk3w405oj5jw&zt~qB z&>`7xGWw0X#FK|per{yma>cK@C}l~K&G=_iS!-m$wX)%lI!fH&3Qi$YzrM>tP+7o+Zv;A9VVnHiV zXL}+p*(wG69juEb*)TiJvTTt(z&^@8#U5o(uqWAb?0NPg`xAQ^07nhhVFNa!6+6*} z7;eCR^q>z1Fo+SHz#W*uEKVbh3>@5pEON->9e6k1gZJV6_%J?(2k{9!giqs(co<*C zqxdGihiCBv{1BJ$OZ*Bi;*T7h^BS)4Eqp6);k)>?{5rmy_wqhIz=!xapXTHoqVN!f z*eTgB);vS46?dAK?!+Id?oO>okKHyjyms8_(k9_Ro12=qY>f%8>b+&4WP#FTi&k20 z`+>o)a+AQ|18beuOiG{uM0=FU#F0#=u=%dQI5f+e2N*j&|^7a(Z8{UEaRUXgQ=MG@VfpP{SJ?zLjot zQCI$37JiAnzxgys>^s*HTkP!W?Cps4_H>^+rz*|uHy#|Fz1x{{z5Bu?6?QVBiV^EkM;N#fP1q4&;4bVT4D2NgJcwj~fulG@7?>gq z6wx9}ks^-*-bonvUc$f+;3GklcqoVx593RCgrdVU6cwJu_wiG_fL{;}{sDjIgl>ET zZ{p2_gKa!UDA>;T^B$hy$M`6pT)FPe^nx(z++9@nIK?fAr2-FuQeR}8X@7=@ojvEkWk>@Gx#xnP8r|t@KS{(-@e+C z@#jiQuE4_8!=qlLfi+6bl z#JX?VUy;pyP-+-g%H~A2S6YvArgMz8$n#}yWkt4f+7V@WQI;2FdH+F{SME$vhDYao zYwxTh%J5d;V$Mq@<%u0rp5AeBc>DRM`pe;D&NKh1eE#f};ie*U49^Jv-+)VJCZ+R} zp1$sC$0z?1aB1@#?f?IGr{K~4{}uShQndeHL+9Yp{=aPh6E!0Z6g99;Hsa#@zr$6& z|1XYWEMAdg&?^ jk|jik=#r$=qH@(g0{r(s9f~r%*Gz_2I>mZT?f?G)WHyb+ literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 04549e7..fbee988 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # demeter +IOT Plant and Aquarium Monitoring System + +To Run the server: +cd server +pip install -e ".[dev]" +uvicorn app.main:app --reload --port 25572 \ No newline at end of file diff --git a/docs/demeter-architecture.docx b/docs/demeter-architecture.docx new file mode 100644 index 0000000000000000000000000000000000000000..36d9316f7ca88f03ce737b51f9e36d9710258d8e GIT binary patch literal 19538 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 bMR5s=DNv`df(s%k1}TOvAZrs)0|Ns9H|72L literal 0 HcmV?d00001 diff --git a/esp/.DS_Store b/esp/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5ae42e6c30ac0fe9e114f120aa1cbebd9db8575a GIT binary patch literal 6148 zcmeHK%Sr=55Ukb+fn0Ku9OnbP_yAR&sl)a*09k%)Z-F_0DP0lY8pfvk>La^|jH1{(x^1^<)@|Exd73Hr3h#JA$2j0$ z;}b7<#s+U_8CyK!6+7IZVRlVC(D1cC__R~ADiuftQh`(;6*yl7qDfC@=PN3%!Rh%#jm=e&)lzpy`#~YH##wY1XP!_RNyZZ_yL!lAbJ1* literal 0 HcmV?d00001 diff --git a/esp/README.md b/esp/README.md new file mode 100644 index 0000000..783b916 --- /dev/null +++ b/esp/README.md @@ -0,0 +1,77 @@ +# Demeter ESP - CoAP Sensor Node with Observe + +CoAP (RFC 7252) sensor node firmware for ESP32/ESP8266 running MicroPython, with CoAP Observe (RFC 7641) support for real-time push notifications. + +## Project Structure + +``` +demeter-esp/ +├── microcoapy/ # Extended microCoAPy library +│ ├── __init__.py +│ ├── coap_macros.py # Constants + COAP_OBSERVE option number +│ ├── coap_option.py # CoapOption (str-compatible) +│ ├── coap_packet.py # CoapPacket + setObserve(), getObserveValue(), setMaxAge() +│ ├── coap_reader.py # Packet parser (unchanged) +│ ├── coap_writer.py # Packet serializer (unchanged) +│ ├── microcoapy.py # Main Coap class + Observe server/client methods +│ └── observe_manager.py # Observer registry with per-resource tracking +├── config.py # WiFi, device ID, pin assignments, thresholds +├── sensors.py # Hardware abstraction for analog/digital sensors +├── main.py # Entry point: CoAP server + sensor loop +└── tests/ + └── test_observe.py # Observe extension tests (runs on CPython) +``` + +## CoAP Resources + +| URI Path | Method | Observable | Description | +|----------|--------|------------|-------------| +| `/sensors/soil_moisture` | GET | Yes (periodic) | Soil moisture 0–100% | +| `/sensors/temperature` | GET | Yes (periodic) | Temperature in °C | +| `/sensors/water_level` | GET | Yes (periodic) | Water level 0–100% | +| `/events/trigger` | GET | Yes (event-driven) | Digital input state change | +| `/device/info` | GET | No | Device metadata, uptime | +| `/config/interval` | GET, PUT | No | Read/set polling interval | + +## Observe Behavior + +- **Periodic sensors** (soil, temp, water): NON-confirmable notifications at configurable intervals. Only sent when value changes beyond a configurable threshold. +- **Trigger events**: CON-confirmable notifications sent immediately on GPIO state change via hardware interrupt. +- **Max observers**: 4 per resource, 8 total (configurable in `observe_manager.py`). +- **Deregistration**: Via Observe option value 1, or automatically on RST response. + +## Setup + +1. Flash MicroPython to your ESP32/ESP8266 +2. Edit `config.py` with your WiFi credentials, device ID, and pin assignments +3. Upload all files to the board (via `mpremote`, `ampy`, or Thonny) +4. The node starts automatically and listens on UDP port 5683 + +## Testing with aiocoap (from Demeter server) + +```bash +# Simple GET +aiocoap-client coap://ESP_IP/sensors/temperature + +# Observe subscription +aiocoap-client coap://ESP_IP/sensors/soil_moisture --observe + +# Set polling interval to 10 seconds +echo '{"interval": 10}' | aiocoap-client coap://ESP_IP/config/interval -m PUT +``` + +## Running Tests + +```bash +python tests/test_observe.py +``` + +## Changes from upstream microCoAPy + +- Added `COAP_OBSERVE = 6` to option numbers +- Added `setObserve()`, `getObserveValue()`, `setMaxAge()`, `getUriPath()` to `CoapPacket` +- Added `ObserveManager` class for server-side observer tracking +- Added `notifyObservers()`, `observeGet()`, `observeCancel()` to `Coap` +- Modified `handleIncomingRequest()` to detect and handle Observe registrations +- Added RST handling to deregister observers +- Made `CoapOption` accept `str` input (CPython compatibility) diff --git a/esp/config.py b/esp/config.py new file mode 100644 index 0000000..56e2630 --- /dev/null +++ b/esp/config.py @@ -0,0 +1,58 @@ +""" +Demeter ESP Sensor Node - Configuration + +Edit this file for your specific deployment. +""" + +# ── WiFi ── +WIFI_SSID = "YourSSID" +WIFI_PASS = "YourPassword" + +# ── Device Identity ── +# Unique ID for this device (used in CoAP payloads and server registry) +DEVICE_ID = "esp32-plant-01" +FIRMWARE_VERSION = "0.1.0" + +# ── CoAP Server ── +COAP_PORT = 5683 + +# ── Sensor Configuration ── +# Set pin numbers according to your wiring. Set to None to disable a sensor. + +# Analog soil moisture sensor (capacitive recommended) +# Reads 0-4095 on ESP32 ADC, mapped to 0-100% +SOIL_MOISTURE_PIN = 34 # ADC1 channel (GPIO 34) +SOIL_MOISTURE_DRY = 3200 # ADC reading when completely dry +SOIL_MOISTURE_WET = 1400 # ADC reading when fully saturated + +# Temperature sensor (DS18B20 OneWire or DHT22) +# Set TEMP_SENSOR_TYPE to "ds18b20" or "dht22" +TEMP_SENSOR_PIN = 4 +TEMP_SENSOR_TYPE = "dht22" # "ds18b20" or "dht22" + +# Water level sensor (analog) +WATER_LEVEL_PIN = 35 # ADC1 channel (GPIO 35) +WATER_LEVEL_MIN = 0 # ADC reading at minimum +WATER_LEVEL_MAX = 4095 # ADC reading at maximum + +# Digital trigger input (e.g., float switch, door sensor) +TRIGGER_PIN = 5 # GPIO with internal pullup +TRIGGER_EDGE = "falling" # "rising", "falling", or "both" + +# ── Timing ── +# Interval between sensor readings (seconds) +DEFAULT_POLL_INTERVAL = 30 + +# Observe notification: only send if value changed by more than threshold +SOIL_MOISTURE_THRESHOLD = 2.0 # percent +TEMPERATURE_THRESHOLD = 0.5 # degrees C +WATER_LEVEL_THRESHOLD = 2.0 # percent + +# Max-Age for Observe notifications (seconds) +# Tells the observer how long the value is considered fresh +OBSERVE_MAX_AGE = 60 + +# ── Deep Sleep (for battery-operated nodes) ── +# Set to True for battery operation (disables Observe server, uses push model) +DEEP_SLEEP_ENABLED = False +DEEP_SLEEP_SECONDS = 300 # 5 minutes diff --git a/esp/main.py b/esp/main.py new file mode 100644 index 0000000..e3ff628 --- /dev/null +++ b/esp/main.py @@ -0,0 +1,339 @@ +""" +Demeter ESP Sensor Node - Main Entry Point + +Runs a CoAP server with observable resources for sensor data. +Supports both periodic Observe notifications and event-driven +digital trigger notifications. + +Resources: + GET /sensors/soil_moisture (observable, periodic) + GET /sensors/temperature (observable, periodic) + GET /sensors/water_level (observable, periodic) + GET /events/trigger (observable, event-driven) + GET /device/info (not observable) + GET,PUT /config/interval (not observable) +""" + +try: + import network + import machine +except ImportError: + pass + +try: + import time +except ImportError: + import utime as time + +try: + import json +except ImportError: + import ujson as json + +import microcoapy +from microcoapy import COAP_CONTENT_FORMAT, COAP_RESPONSE_CODE, COAP_TYPE +import config +from sensors import SensorManager + + +# ── WiFi Connection ── + +def connect_wifi(): + """Connect to WiFi and return True on success.""" + try: + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + if wlan.isconnected(): + print("[wifi] Already connected:", wlan.ifconfig()) + return True + + print("[wifi] Connecting to", config.WIFI_SSID, "...") + wlan.connect(config.WIFI_SSID, config.WIFI_PASS) + + timeout = 15000 # 15 seconds + start = time.ticks_ms() + while not wlan.isconnected(): + if time.ticks_diff(time.ticks_ms(), start) > timeout: + print("[wifi] Connection timeout") + return False + time.sleep_ms(100) + + print("[wifi] Connected:", wlan.ifconfig()) + return True + except NameError: + # Running on CPython (no network module) + print("[wifi] Skipped (not on ESP)") + return True + + +# ── Resource State ── +# Cached sensor values for Observe notifications +# Only notify if value changed beyond threshold + +_state = { + "soil_moisture": None, + "temperature": None, + "water_level": None, + "trigger": 0, + "poll_interval": config.DEFAULT_POLL_INTERVAL, + "uptime_start": 0, +} + + +def _value_changed(key, new_value, threshold): + """Check if a sensor value changed beyond threshold.""" + old = _state.get(key) + if old is None or new_value is None: + return new_value is not None + return abs(new_value - old) >= threshold + + +# ── CoAP Resource Callbacks ── +# Callbacks that return (payload, content_format) are Observe-compatible. +# The server uses these to build the initial response and notifications. + +def resource_soil_moisture(packet, sender_ip, sender_port): + """GET /sensors/soil_moisture""" + val = sensors.read_soil_moisture() + payload = json.dumps({ + "device": config.DEVICE_ID, + "soil_moisture": val, + "unit": "percent" + }) + return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON) + + +def resource_temperature(packet, sender_ip, sender_port): + """GET /sensors/temperature""" + val = sensors.read_temperature() + payload = json.dumps({ + "device": config.DEVICE_ID, + "temperature": val, + "unit": "celsius" + }) + return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON) + + +def resource_water_level(packet, sender_ip, sender_port): + """GET /sensors/water_level""" + val = sensors.read_water_level() + payload = json.dumps({ + "device": config.DEVICE_ID, + "water_level": val, + "unit": "percent" + }) + return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON) + + +def resource_trigger(packet, sender_ip, sender_port): + """GET /events/trigger""" + trigger_val, _ = sensors.read_trigger() + payload = json.dumps({ + "device": config.DEVICE_ID, + "trigger": trigger_val, + "type": "digital" + }) + return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON) + + +def resource_device_info(packet, sender_ip, sender_port): + """GET /device/info - non-observable device metadata""" + uptime = time.ticks_diff(time.ticks_ms(), _state["uptime_start"]) // 1000 + payload = json.dumps({ + "device": config.DEVICE_ID, + "firmware": config.FIRMWARE_VERSION, + "uptime_seconds": uptime, + "observers": server.observe.observer_count(), + "poll_interval": _state["poll_interval"], + }) + # This callback sends its own response (non-observable pattern) + server.sendResponse( + sender_ip, sender_port, packet.messageid, + payload, COAP_RESPONSE_CODE.COAP_CONTENT, + COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, packet.token + ) + return None # Signal that we handled the response ourselves + + +def resource_config_interval(packet, sender_ip, sender_port): + """GET,PUT /config/interval - read or set the polling interval""" + from microcoapy.coap_macros import COAP_METHOD + + if packet.method == COAP_METHOD.COAP_PUT: + # Parse new interval from payload + try: + new_val = json.loads(packet.payload.decode("utf-8")) + if isinstance(new_val, dict): + new_interval = int(new_val.get("interval", _state["poll_interval"])) + else: + new_interval = int(new_val) + new_interval = max(5, min(3600, new_interval)) # clamp 5s - 1hr + _state["poll_interval"] = new_interval + print("[config] Poll interval set to", new_interval, "seconds") + server.sendResponse( + sender_ip, sender_port, packet.messageid, + json.dumps({"interval": new_interval}), + COAP_RESPONSE_CODE.COAP_CHANGED, + COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, packet.token + ) + except Exception as e: + print("[config] Invalid interval payload:", e) + server.sendResponse( + sender_ip, sender_port, packet.messageid, + None, COAP_RESPONSE_CODE.COAP_BAD_REQUEST, + COAP_CONTENT_FORMAT.COAP_NONE, packet.token + ) + else: + # GET + payload = json.dumps({"interval": _state["poll_interval"]}) + server.sendResponse( + sender_ip, sender_port, packet.messageid, + payload, COAP_RESPONSE_CODE.COAP_CONTENT, + COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, packet.token + ) + return None + + +# ── Main Loop ── + +def run(): + """Main run loop: read sensors, notify observers, handle requests.""" + global server, sensors + + _state["uptime_start"] = time.ticks_ms() + last_read_time = 0 + + print("=" * 40) + print(" Demeter Sensor Node: " + config.DEVICE_ID) + print(" Firmware: " + config.FIRMWARE_VERSION) + print("=" * 40) + + # Connect WiFi + if not connect_wifi(): + print("[main] WiFi failed, halting") + return + + # Initialize sensors + sensors = SensorManager(config) + sensors.init() + print("[main] Sensors initialized") + + # Initialize CoAP server + server = microcoapy.Coap() + server.debug = True + + # Register resource callbacks + server.addIncomingRequestCallback("sensors/soil_moisture", resource_soil_moisture) + server.addIncomingRequestCallback("sensors/temperature", resource_temperature) + server.addIncomingRequestCallback("sensors/water_level", resource_water_level) + server.addIncomingRequestCallback("events/trigger", resource_trigger) + server.addIncomingRequestCallback("device/info", resource_device_info) + server.addIncomingRequestCallback("config/interval", resource_config_interval) + + # Start CoAP server + server.start(config.COAP_PORT) + print("[main] CoAP server started on port", config.COAP_PORT) + print("[main] Poll interval:", _state["poll_interval"], "seconds") + print("[main] Waiting for requests and observers...") + + try: + while True: + # ── Process incoming CoAP requests (non-blocking, short poll) ── + server.poll(timeoutMs=200, pollPeriodMs=50) + + now = time.ticks_ms() + + # ── Check digital trigger (event-driven, immediate) ── + trigger_val, fired = sensors.read_trigger() + if fired: + _state["trigger"] = trigger_val + payload = json.dumps({ + "device": config.DEVICE_ID, + "trigger": trigger_val, + "type": "digital", + "event": "state_change" + }) + # CON for trigger events (reliable delivery) + sent = server.notifyObservers( + "events/trigger", payload, + content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, + message_type=COAP_TYPE.COAP_CON, + max_age=config.OBSERVE_MAX_AGE + ) + if sent > 0: + print("[main] Trigger event notified to", sent, "observers") + + # ── Periodic sensor reading and notification ── + interval_ms = _state["poll_interval"] * 1000 + if time.ticks_diff(now, last_read_time) >= interval_ms: + last_read_time = now + _read_and_notify_sensors() + + except KeyboardInterrupt: + print("\n[main] Shutting down...") + finally: + server.stop() + print("[main] CoAP server stopped") + + +def _read_and_notify_sensors(): + """Read all periodic sensors and notify observers if values changed.""" + cfg = config + max_age = cfg.OBSERVE_MAX_AGE + + # Soil moisture + val = sensors.read_soil_moisture() + if val is not None and _value_changed("soil_moisture", val, cfg.SOIL_MOISTURE_THRESHOLD): + _state["soil_moisture"] = val + payload = json.dumps({ + "device": cfg.DEVICE_ID, + "soil_moisture": val, + "unit": "percent" + }) + server.notifyObservers( + "sensors/soil_moisture", payload, + content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, + message_type=COAP_TYPE.COAP_NONCON, + max_age=max_age + ) + + # Temperature + val = sensors.read_temperature() + if val is not None and _value_changed("temperature", val, cfg.TEMPERATURE_THRESHOLD): + _state["temperature"] = val + payload = json.dumps({ + "device": cfg.DEVICE_ID, + "temperature": val, + "unit": "celsius" + }) + server.notifyObservers( + "sensors/temperature", payload, + content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, + message_type=COAP_TYPE.COAP_NONCON, + max_age=max_age + ) + + # Water level + val = sensors.read_water_level() + if val is not None and _value_changed("water_level", val, cfg.WATER_LEVEL_THRESHOLD): + _state["water_level"] = val + payload = json.dumps({ + "device": cfg.DEVICE_ID, + "water_level": val, + "unit": "percent" + }) + server.notifyObservers( + "sensors/water_level", payload, + content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, + message_type=COAP_TYPE.COAP_NONCON, + max_age=max_age + ) + + +# Globals (set in run()) +server = None +sensors = None + +if __name__ == "__main__": + run() diff --git a/esp/microcoapy/__init__.py b/esp/microcoapy/__init__.py new file mode 100644 index 0000000..ca7b4c9 --- /dev/null +++ b/esp/microcoapy/__init__.py @@ -0,0 +1,7 @@ +from .microcoapy import Coap +from .coap_macros import COAP_CONTENT_FORMAT +from .coap_macros import COAP_RESPONSE_CODE +from .coap_macros import COAP_TYPE +from .coap_macros import COAP_OPTION_NUMBER +from .coap_macros import COAP_OBSERVE_REGISTER +from .coap_macros import COAP_OBSERVE_DEREGISTER diff --git a/esp/microcoapy/coap_macros.py b/esp/microcoapy/coap_macros.py new file mode 100644 index 0000000..9bf31ab --- /dev/null +++ b/esp/microcoapy/coap_macros.py @@ -0,0 +1,112 @@ +# Macros - Extended for Demeter with CoAP Observe (RFC 7641) +_COAP_HEADER_SIZE = 4 +_COAP_OPTION_HEADER_SIZE = 1 +_COAP_PAYLOAD_MARKER = 0xFF +_MAX_OPTION_NUM = 10 +_BUF_MAX_SIZE = 1024 +_COAP_DEFAULT_PORT = 5683 + + +def enum(**enums): + return type('Enum', (), enums) + + +class CoapResponseCode: + @staticmethod + def encode(class_, detail): + return ((class_ << 5) | (detail)) + + @staticmethod + def decode(value): + class_ = (0xE0 & value) >> 5 + detail = 0x1F & value + return (class_, detail) + + +COAP_VERSION = enum( + COAP_VERSION_UNSUPPORTED=0, + COAP_VERSION_1=1 +) + +COAP_TYPE = enum( + COAP_CON=0, + COAP_NONCON=1, + COAP_ACK=2, + COAP_RESET=3 +) + +COAP_METHOD = enum( + COAP_EMPTY_MESSAGE=0, + COAP_GET=1, + COAP_POST=2, + COAP_PUT=3, + COAP_DELETE=4 +) + +COAP_RESPONSE_CODE = enum( + COAP_CREATED=CoapResponseCode.encode(2, 1), + COAP_DELETED=CoapResponseCode.encode(2, 2), + COAP_VALID=CoapResponseCode.encode(2, 3), + COAP_CHANGED=CoapResponseCode.encode(2, 4), + COAP_CONTENT=CoapResponseCode.encode(2, 5), + COAP_BAD_REQUEST=CoapResponseCode.encode(4, 0), + COAP_UNAUTHORIZED=CoapResponseCode.encode(4, 1), + COAP_BAD_OPTION=CoapResponseCode.encode(4, 2), + COAP_FORBIDDEN=CoapResponseCode.encode(4, 3), + COAP_NOT_FOUND=CoapResponseCode.encode(4, 4), + COAP_METHOD_NOT_ALLOWD=CoapResponseCode.encode(4, 5), + COAP_NOT_ACCEPTABLE=CoapResponseCode.encode(4, 6), + COAP_PRECONDITION_FAILED=CoapResponseCode.encode(4, 12), + COAP_REQUEST_ENTITY_TOO_LARGE=CoapResponseCode.encode(4, 13), + COAP_UNSUPPORTED_CONTENT_FORMAT=CoapResponseCode.encode(4, 15), + COAP_INTERNAL_SERVER_ERROR=CoapResponseCode.encode(5, 0), + COAP_NOT_IMPLEMENTED=CoapResponseCode.encode(5, 1), + COAP_BAD_GATEWAY=CoapResponseCode.encode(5, 2), + COAP_SERVICE_UNAVALIABLE=CoapResponseCode.encode(5, 3), + COAP_GATEWAY_TIMEOUT=CoapResponseCode.encode(5, 4), + COAP_PROXYING_NOT_SUPPORTED=CoapResponseCode.encode(5, 5) +) + +COAP_OPTION_NUMBER = enum( + COAP_IF_MATCH=1, + COAP_URI_HOST=3, + COAP_E_TAG=4, + COAP_IF_NONE_MATCH=5, + COAP_OBSERVE=6, # RFC 7641 - Observe + COAP_URI_PORT=7, + COAP_LOCATION_PATH=8, + COAP_URI_PATH=11, + COAP_CONTENT_FORMAT=12, + COAP_MAX_AGE=14, + COAP_URI_QUERY=15, + COAP_ACCEPT=17, + COAP_LOCATION_QUERY=20, + COAP_PROXY_URI=35, + COAP_PROXY_SCHEME=39 +) + +COAP_CONTENT_FORMAT = enum( + COAP_NONE=-1, + COAP_TEXT_PLAIN=0, + COAP_APPLICATION_LINK_FORMAT=40, + COAP_APPLICATION_XML=41, + COAP_APPLICATION_OCTET_STREAM=42, + COAP_APPLICATION_EXI=47, + COAP_APPLICATION_JSON=50, + COAP_APPLICATION_CBOR=60 +) + +# Observe option values (RFC 7641) +COAP_OBSERVE_REGISTER = 0 +COAP_OBSERVE_DEREGISTER = 1 + +coapTypeToStringMap = { + COAP_TYPE.COAP_CON: 'CON', + COAP_TYPE.COAP_NONCON: 'NONCON', + COAP_TYPE.COAP_ACK: 'ACK', + COAP_TYPE.COAP_RESET: 'RESET' +} + + +def coapTypeToString(type): + return coapTypeToStringMap.get(type, "INVALID") diff --git a/esp/microcoapy/coap_option.py b/esp/microcoapy/coap_option.py new file mode 100644 index 0000000..332a36a --- /dev/null +++ b/esp/microcoapy/coap_option.py @@ -0,0 +1,10 @@ +class CoapOption: + def __init__(self, number=-1, buffer=None): + self.number = number + byteBuf = bytearray() + if buffer is not None: + if isinstance(buffer, str): + byteBuf.extend(buffer.encode("utf-8")) + else: + byteBuf.extend(buffer) + self.buffer = byteBuf diff --git a/esp/microcoapy/coap_packet.py b/esp/microcoapy/coap_packet.py new file mode 100644 index 0000000..478e5b1 --- /dev/null +++ b/esp/microcoapy/coap_packet.py @@ -0,0 +1,83 @@ +from . import coap_macros as macros +from .coap_option import CoapOption + + +class CoapPacket: + def __init__(self): + self.version = macros.COAP_VERSION.COAP_VERSION_UNSUPPORTED + self.type = macros.COAP_TYPE.COAP_CON + self.method = macros.COAP_METHOD.COAP_GET + self.token = bytearray() + self.payload = bytearray() + self.messageid = 0 + self.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE + self.query = bytearray() + self.options = [] + + def addOption(self, number, opt_payload): + if len(self.options) >= macros._MAX_OPTION_NUM: + return + self.options.append(CoapOption(number, opt_payload)) + + def setUriHost(self, address): + self.addOption(macros.COAP_OPTION_NUMBER.COAP_URI_HOST, address) + + def setUriPath(self, url): + for subPath in url.split('/'): + self.addOption(macros.COAP_OPTION_NUMBER.COAP_URI_PATH, subPath) + + def setObserve(self, value): + """Set the Observe option (RFC 7641). + + For requests: + value=0: register as observer + value=1: deregister + + For notifications: + value=sequence number (24-bit, 0-16777215) + """ + if value < 256: + buf = bytearray([value & 0xFF]) + elif value < 65536: + buf = bytearray([(value >> 8) & 0xFF, value & 0xFF]) + else: + buf = bytearray([(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]) + self.addOption(macros.COAP_OPTION_NUMBER.COAP_OBSERVE, buf) + + def setMaxAge(self, seconds): + """Set the Max-Age option (seconds until resource is considered stale).""" + if seconds < 256: + buf = bytearray([seconds & 0xFF]) + elif seconds < 65536: + buf = bytearray([(seconds >> 8) & 0xFF, seconds & 0xFF]) + else: + buf = bytearray([ + (seconds >> 24) & 0xFF, (seconds >> 16) & 0xFF, + (seconds >> 8) & 0xFF, seconds & 0xFF + ]) + self.addOption(macros.COAP_OPTION_NUMBER.COAP_MAX_AGE, buf) + + def getObserveValue(self): + """Extract the Observe option value from the packet, or None if absent.""" + for opt in self.options: + if opt.number == macros.COAP_OPTION_NUMBER.COAP_OBSERVE: + val = 0 + for b in opt.buffer: + val = (val << 8) | b + return val + return None + + def getUriPath(self): + """Reconstruct the URI path from URI_PATH options.""" + parts = [] + for opt in self.options: + if opt.number == macros.COAP_OPTION_NUMBER.COAP_URI_PATH and len(opt.buffer) > 0: + parts.append(opt.buffer.decode("utf-8")) + return "/".join(parts) + + def toString(self): + class_, detail = macros.CoapResponseCode.decode(self.method) + return "type: {}, method: {}.{:02d}, messageid: {}, payload: {}".format( + macros.coapTypeToString(self.type), class_, detail, + self.messageid, self.payload + ) diff --git a/esp/microcoapy/coap_reader.py b/esp/microcoapy/coap_reader.py new file mode 100644 index 0000000..3e2c8b4 --- /dev/null +++ b/esp/microcoapy/coap_reader.py @@ -0,0 +1,88 @@ +from . import coap_macros as macros +from .coap_option import CoapOption + + +def parseOption(packet, runningDelta, buffer, i): + option = CoapOption() + headlen = 1 + + errorMessage = (False, runningDelta, i) + + if buffer is None: + return errorMessage + + buflen = len(buffer) - i + + if buflen < headlen: + return errorMessage + + delta = (buffer[i] & 0xF0) >> 4 + length = buffer[i] & 0x0F + + if delta == 15 or length == 15: + return errorMessage + + if delta == 13: + headlen += 1 + if buflen < headlen: + return errorMessage + delta = buffer[i + 1] + 13 + i += 1 + elif delta == 14: + headlen += 2 + if buflen < headlen: + return errorMessage + delta = ((buffer[i + 1] << 8) | buffer[i + 2]) + 269 + i += 2 + + if length == 13: + headlen += 1 + if buflen < headlen: + return errorMessage + length = buffer[i + 1] + 13 + i += 1 + elif length == 14: + headlen += 2 + if buflen < headlen: + return errorMessage + length = ((buffer[i + 1] << 8) | buffer[i + 2]) + 269 + i += 2 + + endOfOptionIndex = (i + 1 + length) + + if endOfOptionIndex > len(buffer): + return errorMessage + + option.number = delta + runningDelta + option.buffer = buffer[i + 1:i + 1 + length] + packet.options.append(option) + + return (True, runningDelta + delta, endOfOptionIndex) + + +def parsePacketHeaderInfo(buffer, packet): + packet.version = (buffer[0] & 0xC0) >> 6 + packet.type = (buffer[0] & 0x30) >> 4 + packet.tokenLength = buffer[0] & 0x0F + packet.method = buffer[1] + packet.messageid = 0xFF00 & (buffer[2] << 8) + packet.messageid |= 0x00FF & buffer[3] + + +def parsePacketOptionsAndPayload(buffer, packet): + bufferLen = len(buffer) + if (macros._COAP_HEADER_SIZE + packet.tokenLength) < bufferLen: + delta = 0 + bufferIndex = macros._COAP_HEADER_SIZE + packet.tokenLength + while (len(packet.options) < macros._MAX_OPTION_NUM) and \ + (bufferIndex < bufferLen) and \ + (buffer[bufferIndex] != 0xFF): + (status, delta, bufferIndex) = parseOption(packet, delta, buffer, bufferIndex) + if status is False: + return False + + if ((bufferIndex + 1) < bufferLen) and (buffer[bufferIndex] == 0xFF): + packet.payload = buffer[bufferIndex + 1:] + else: + packet.payload = None + return True diff --git a/esp/microcoapy/coap_writer.py b/esp/microcoapy/coap_writer.py new file mode 100644 index 0000000..cea44fe --- /dev/null +++ b/esp/microcoapy/coap_writer.py @@ -0,0 +1,67 @@ +from .coap_macros import _BUF_MAX_SIZE +from .coap_macros import COAP_VERSION + + +def CoapOptionDelta(v): + if v < 13: + return (0xFF & v) + elif v <= 0xFF + 13: + return 13 + else: + return 14 + + +def writePacketHeaderInfo(buffer, packet): + buffer.append(COAP_VERSION.COAP_VERSION_1 << 6) + buffer[0] |= (packet.type & 0x03) << 4 + tokenLength = 0 + if (packet.token is not None) and (len(packet.token) <= 0x0F): + tokenLength = len(packet.token) + + buffer[0] |= (tokenLength & 0x0F) + buffer.append(packet.method) + buffer.append(packet.messageid >> 8) + buffer.append(packet.messageid & 0xFF) + + if tokenLength > 0: + buffer.extend(packet.token) + + +def writePacketOptions(buffer, packet): + runningDelta = 0 + for opt in sorted(packet.options, key=lambda x: x.number): + if (opt is None) or (opt.buffer is None) or (len(opt.buffer) == 0): + continue + + optBufferLen = len(opt.buffer) + + if (len(buffer) + 5 + optBufferLen) >= _BUF_MAX_SIZE: + return 0 + + optdelta = opt.number - runningDelta + delta = CoapOptionDelta(optdelta) + length = CoapOptionDelta(optBufferLen) + + buffer.append(0xFF & (delta << 4 | length)) + if delta == 13: + buffer.append(optdelta - 13) + elif delta == 14: + buffer.append((optdelta - 269) >> 8) + buffer.append(0xFF & (optdelta - 269)) + + if length == 13: + buffer.append(optBufferLen - 13) + elif length == 14: + buffer.append(optBufferLen >> 8) + buffer.append(0xFF & (optBufferLen - 269)) + + buffer.extend(opt.buffer) + runningDelta = opt.number + + +def writePacketPayload(buffer, packet): + if (packet.payload is not None) and (len(packet.payload)): + if (len(buffer) + 1 + len(packet.payload)) >= _BUF_MAX_SIZE: + return 0 + buffer.append(0xFF) + buffer.extend(packet.payload) diff --git a/esp/microcoapy/microcoapy.py b/esp/microcoapy/microcoapy.py new file mode 100644 index 0000000..d5d4c78 --- /dev/null +++ b/esp/microcoapy/microcoapy.py @@ -0,0 +1,512 @@ +""" +microCoAPy - Extended for Demeter with CoAP Observe (RFC 7641) + +Changes from upstream microCoAPy: + - Observer registration/deregistration on incoming GET with Option 6 + - notifyObservers() method for server-side Observe notifications + - observeGet() client method for subscribing to observable resources + - RST handling to remove observers + - Per-resource Max-Age support in notifications +""" + +try: + import socket +except ImportError: + import usocket as socket + +try: + import os +except ImportError: + import uos as os + +try: + import time +except ImportError: + import utime as time + +import binascii + +from . import coap_macros as macros +from .coap_packet import CoapPacket +from .coap_reader import parsePacketHeaderInfo +from .coap_reader import parsePacketOptionsAndPayload +from .coap_writer import writePacketHeaderInfo +from .coap_writer import writePacketOptions +from .coap_writer import writePacketPayload +from .observe_manager import ObserveManager + + +class Coap: + TRANSMISSION_STATE = macros.enum( + STATE_IDLE=0, + STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA=1 + ) + + def __init__(self): + self.debug = True + self.sock = None + self.callbacks = {} + self.responseCallback = None + self.port = 0 + self.isServer = False + self.state = self.TRANSMISSION_STATE.STATE_IDLE + self.isCustomSocket = False + + # Observe manager (RFC 7641) + self.observe = ObserveManager(debug=True) + + # Beta flags + self.discardRetransmissions = False + self.lastPacketStr = "" + + def log(self, s): + if self.debug: + print("[microcoapy]: " + s) + + # ── Socket Management ── + + def start(self, port=macros._COAP_DEFAULT_PORT): + """Create and bind a UDP socket.""" + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("", port)) + + def stop(self): + """Close the socket.""" + if self.sock is not None: + self.sock.close() + self.sock = None + + def setCustomSocket(self, custom_socket): + """Use a custom UDP socket implementation.""" + self.stop() + self.isCustomSocket = True + self.sock = custom_socket + + # ── Callback Registration ── + + def addIncomingRequestCallback(self, requestUrl, callback): + """Register a callback for incoming requests to a URL. + + The callback signature is: + callback(packet, senderIp, senderPort) + + For observable resources, the callback should return a tuple: + (payload_str, content_format) + This allows notifyObservers to retrieve the current value. + + If the callback returns None, the server handles it as before + (callback is responsible for sending its own response). + """ + self.callbacks[requestUrl] = callback + self.isServer = True + + # ── Packet Sending ── + + def sendPacket(self, ip, port, coapPacket): + """Serialize and send a CoAP packet.""" + if coapPacket.content_format != macros.COAP_CONTENT_FORMAT.COAP_NONE: + optionBuffer = bytearray(2) + optionBuffer[0] = (coapPacket.content_format & 0xFF00) >> 8 + optionBuffer[1] = coapPacket.content_format & 0x00FF + coapPacket.addOption( + macros.COAP_OPTION_NUMBER.COAP_CONTENT_FORMAT, optionBuffer + ) + + if (coapPacket.query is not None) and (len(coapPacket.query) > 0): + coapPacket.addOption( + macros.COAP_OPTION_NUMBER.COAP_URI_QUERY, coapPacket.query + ) + + buffer = bytearray() + writePacketHeaderInfo(buffer, coapPacket) + writePacketOptions(buffer, coapPacket) + writePacketPayload(buffer, coapPacket) + + status = 0 + try: + sockaddr = (ip, port) + try: + sockaddr = socket.getaddrinfo(ip, port)[0][-1] + except Exception: + pass + + status = self.sock.sendto(buffer, sockaddr) + + if status > 0: + status = coapPacket.messageid + + self.log("Packet sent. messageid: " + str(status)) + except Exception as e: + status = 0 + print("Exception while sending packet...") + import sys + sys.print_exception(e) + + return status + + def send(self, ip, port, url, type, method, token, payload, content_format, query_option): + """Build and send a CoAP request.""" + packet = CoapPacket() + packet.type = type + packet.method = method + packet.token = token + packet.payload = payload + packet.content_format = content_format + packet.query = query_option + return self.sendEx(ip, port, url, packet) + + def sendEx(self, ip, port, url, packet): + """Send a packet with auto-generated message ID and URI options.""" + self.state = self.TRANSMISSION_STATE.STATE_IDLE + randBytes = os.urandom(2) + packet.messageid = (randBytes[0] << 8) | randBytes[1] + packet.setUriHost(ip) + packet.setUriPath(url) + return self.sendPacket(ip, port, packet) + + def sendResponse(self, ip, port, messageid, payload, method, content_format, token): + """Send a response (ACK) packet.""" + packet = CoapPacket() + packet.type = macros.COAP_TYPE.COAP_ACK + packet.method = method + packet.token = token + packet.payload = payload + packet.messageid = messageid + packet.content_format = content_format + return self.sendPacket(ip, port, packet) + + # ── Client Methods (Confirmable) ── + + def get(self, ip, port, url, token=bytearray()): + return self.send( + ip, port, url, + macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_GET, + token, None, macros.COAP_CONTENT_FORMAT.COAP_NONE, None + ) + + def put(self, ip, port, url, payload=bytearray(), query_option=None, + content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()): + return self.send( + ip, port, url, + macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_PUT, + token, payload, content_format, query_option + ) + + def post(self, ip, port, url, payload=bytearray(), query_option=None, + content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()): + return self.send( + ip, port, url, + macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_POST, + token, payload, content_format, query_option + ) + + # ── Client Methods (Non-Confirmable) ── + + def getNonConf(self, ip, port, url, token=bytearray()): + return self.send( + ip, port, url, + macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_GET, + token, None, macros.COAP_CONTENT_FORMAT.COAP_NONE, None + ) + + def putNonConf(self, ip, port, url, payload=bytearray(), query_option=None, + content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()): + return self.send( + ip, port, url, + macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_PUT, + token, payload, content_format, query_option + ) + + def postNonConf(self, ip, port, url, payload=bytearray(), query_option=None, + content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()): + return self.send( + ip, port, url, + macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_POST, + token, payload, content_format, query_option + ) + + # ── Observe Client Methods (RFC 7641) ── + + def observeGet(self, ip, port, url, token=None): + """Send a GET with Observe option 0 (register) to subscribe to a resource. + + Args: + ip: Server IP + port: Server port + url: Resource URI path + token: Token for matching responses (auto-generated if None) + + Returns: + Message ID on success, 0 on failure. + """ + if token is None: + token = bytearray(os.urandom(4)) + + packet = CoapPacket() + packet.type = macros.COAP_TYPE.COAP_CON + packet.method = macros.COAP_METHOD.COAP_GET + packet.token = token + packet.payload = None + packet.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE + + # Set Observe = 0 (register) + packet.setObserve(macros.COAP_OBSERVE_REGISTER) + + return self.sendEx(ip, port, url, packet) + + def observeCancel(self, ip, port, url, token=bytearray()): + """Send a GET with Observe option 1 (deregister) to cancel observation. + + Returns: + Message ID on success, 0 on failure. + """ + packet = CoapPacket() + packet.type = macros.COAP_TYPE.COAP_CON + packet.method = macros.COAP_METHOD.COAP_GET + packet.token = token + packet.payload = None + packet.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE + + # Set Observe = 1 (deregister) + packet.setObserve(macros.COAP_OBSERVE_DEREGISTER) + + return self.sendEx(ip, port, url, packet) + + # ── Observe Server Methods (RFC 7641) ── + + def notifyObservers(self, resource_url, payload, content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, + message_type=macros.COAP_TYPE.COAP_NONCON, max_age=None): + """Send an Observe notification to all observers of a resource. + + Args: + resource_url: The resource URI path (must match what observers subscribed to) + payload: The current resource representation (str or bytes) + content_format: Content format of the payload + message_type: COAP_NON for periodic data, COAP_CON for critical events + max_age: Optional Max-Age in seconds (freshness lifetime) + + Returns: + Number of notifications sent successfully. + """ + if not self.observe.has_observers(resource_url): + return 0 + + observers = self.observe.get_observers(resource_url) + seq = self.observe.next_sequence(resource_url) + sent = 0 + + for obs in observers: + packet = CoapPacket() + packet.type = message_type + packet.method = macros.COAP_RESPONSE_CODE.COAP_CONTENT + packet.token = obs["token"] + packet.content_format = content_format + + if isinstance(payload, str): + packet.payload = bytearray(payload.encode("utf-8")) + elif payload is not None: + packet.payload = bytearray(payload) + else: + packet.payload = bytearray() + + # Generate message ID + randBytes = os.urandom(2) + packet.messageid = (randBytes[0] << 8) | randBytes[1] + + # Add Observe sequence number + packet.setObserve(seq) + + # Add Max-Age if specified + if max_age is not None: + packet.setMaxAge(max_age) + + status = self.sendPacket(obs["ip"], obs["port"], packet) + if status > 0: + sent += 1 + else: + self.log("Failed to notify observer {}:{}".format(obs["ip"], obs["port"])) + + self.log("Notified {}/{} observers of {} (seq={})".format( + sent, len(observers), resource_url, seq + )) + return sent + + # ── Incoming Request Handling ── + + def handleIncomingRequest(self, requestPacket, sourceIp, sourcePort): + """Handle an incoming CoAP request, including Observe registration.""" + url = requestPacket.getUriPath() + + urlCallback = None + if url != "": + urlCallback = self.callbacks.get(url) + + if urlCallback is None: + if self.responseCallback: + return False + print("Callback for url [", url, "] not found") + self.sendResponse( + sourceIp, sourcePort, requestPacket.messageid, + None, macros.COAP_RESPONSE_CODE.COAP_NOT_FOUND, + macros.COAP_CONTENT_FORMAT.COAP_NONE, requestPacket.token, + ) + return True + + # Check for Observe option in GET requests (RFC 7641) + if requestPacket.method == macros.COAP_METHOD.COAP_GET: + observeValue = requestPacket.getObserveValue() + + if observeValue == macros.COAP_OBSERVE_REGISTER: + # Register observer + registered = self.observe.register( + url, sourceIp, sourcePort, requestPacket.token + ) + + if registered: + # Send initial response with Observe option (sequence 0) + result = urlCallback(requestPacket, sourceIp, sourcePort) + + if result is not None: + payload_str, cf = result + response = CoapPacket() + response.type = macros.COAP_TYPE.COAP_ACK + response.method = macros.COAP_RESPONSE_CODE.COAP_CONTENT + response.token = requestPacket.token + response.messageid = requestPacket.messageid + response.content_format = cf + + if isinstance(payload_str, str): + response.payload = bytearray(payload_str.encode("utf-8")) + elif payload_str is not None: + response.payload = bytearray(payload_str) + + # Include Observe option with sequence 0 in initial response + response.setObserve(self.observe.next_sequence(url)) + + self.sendPacket(sourceIp, sourcePort, response) + # else: callback handled its own response + return True + + else: + # Registration failed (limits exceeded) — respond without Observe + self.log("Observer registration failed for {}:{} on {}".format( + sourceIp, sourcePort, url + )) + # Fall through to normal callback handling + + elif observeValue == macros.COAP_OBSERVE_DEREGISTER: + # Deregister observer + self.observe.deregister(url, sourceIp, sourcePort) + # Fall through to normal GET response + + # Normal (non-observe) request handling + urlCallback(requestPacket, sourceIp, sourcePort) + return True + + # ── Socket Reading ── + + def readBytesFromSocket(self, numOfBytes): + try: + return self.sock.recvfrom(numOfBytes) + except Exception: + return (None, None) + + def parsePacketToken(self, buffer, packet): + if packet.tokenLength == 0: + packet.token = None + elif packet.tokenLength <= 8: + packet.token = buffer[4: 4 + packet.tokenLength] + else: + (tempBuffer, tempRemoteAddress) = self.readBytesFromSocket( + macros._BUF_MAX_SIZE + ) + if tempBuffer is not None: + buffer.extend(tempBuffer) + return False + return True + + # ── Main Loop ── + + def loop(self, blocking=True): + """Process one incoming packet. + + Returns True if a packet was processed, False otherwise. + """ + if self.sock is None: + return False + + self.sock.setblocking(blocking) + (buffer, remoteAddress) = self.readBytesFromSocket(macros._BUF_MAX_SIZE) + self.sock.setblocking(True) + + while (buffer is not None) and (len(buffer) > 0): + bufferLen = len(buffer) + if (bufferLen < macros._COAP_HEADER_SIZE) or \ + (((buffer[0] & 0xC0) >> 6) != 1): + (tempBuffer, tempRemoteAddress) = self.readBytesFromSocket( + macros._BUF_MAX_SIZE - bufferLen + ) + if tempBuffer is not None: + buffer.extend(tempBuffer) + continue + + packet = CoapPacket() + self.log("Incoming bytes: " + str(binascii.hexlify(bytearray(buffer)))) + + parsePacketHeaderInfo(buffer, packet) + + if not self.parsePacketToken(buffer, packet): + continue + + if not parsePacketOptionsAndPayload(buffer, packet): + return False + + # Handle RST — deregister observer (RFC 7641 §3.6) + if packet.type == macros.COAP_TYPE.COAP_RESET: + self.log("RST received, deregistering any observer with matching token") + for res_url in self.observe.get_all_resources(): + self.observe.deregister_by_token(res_url, packet.token) + return True + + # Beta: discard retransmissions + if self.discardRetransmissions: + if packet.toString() == self.lastPacketStr: + self.log("Discarded retransmission: " + packet.toString()) + return False + else: + self.lastPacketStr = packet.toString() + + if not self.isServer or not self.handleIncomingRequest( + packet, remoteAddress[0], remoteAddress[1] + ): + # Separate response handling (RFC 7252 §5.2.2) + if (packet.type == macros.COAP_TYPE.COAP_ACK and + packet.method == macros.COAP_METHOD.COAP_EMPTY_MESSAGE): + self.state = self.TRANSMISSION_STATE.STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA + return False + else: + if self.state == self.TRANSMISSION_STATE.STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA: + self.state = self.TRANSMISSION_STATE.STATE_IDLE + self.sendResponse( + remoteAddress[0], remoteAddress[1], + packet.messageid, None, + macros.COAP_TYPE.COAP_ACK, + macros.COAP_CONTENT_FORMAT.COAP_NONE, + packet.token, + ) + if self.responseCallback is not None: + self.responseCallback(packet, remoteAddress) + return True + + return False + + def poll(self, timeoutMs=-1, pollPeriodMs=500): + """Poll for incoming packets for up to timeoutMs milliseconds.""" + start_time = time.ticks_ms() + status = False + while not status: + status = self.loop(False) + if time.ticks_diff(time.ticks_ms(), start_time) >= timeoutMs: + break + time.sleep_ms(pollPeriodMs) + return status diff --git a/esp/microcoapy/observe_manager.py b/esp/microcoapy/observe_manager.py new file mode 100644 index 0000000..e9f0f9f --- /dev/null +++ b/esp/microcoapy/observe_manager.py @@ -0,0 +1,144 @@ +""" +CoAP Observe Manager (RFC 7641) + +Manages observer registrations for CoAP resources on the ESP server side. +Each resource can have multiple observers. When the resource state changes, +all registered observers receive a notification. + +Observer entry structure: + { + "ip": str, # Observer IP address + "port": int, # Observer UDP port + "token": bytearray, # Token from the original GET request (for matching) + } +""" + +try: + import time +except ImportError: + import utime as time + + +class ObserveManager: + """Manages CoAP Observe subscriptions per resource URI.""" + + # Maximum observers per resource (memory constrained on ESP) + MAX_OBSERVERS_PER_RESOURCE = 4 + # Maximum total observers across all resources + MAX_TOTAL_OBSERVERS = 8 + + def __init__(self, debug=True): + # Dict of resource_url -> list of observer entries + self._observers = {} + # Per-resource sequence counter (24-bit, wraps at 0xFFFFFF) + self._sequence = {} + self.debug = debug + + def log(self, s): + if self.debug: + print("[observe]: " + s) + + def register(self, resource_url, ip, port, token): + """Register an observer for a resource. + + Returns True if successfully registered, False if limits exceeded. + """ + if resource_url not in self._observers: + self._observers[resource_url] = [] + self._sequence[resource_url] = 0 + + observers = self._observers[resource_url] + + # Check if this observer is already registered (same ip+port+token) + for obs in observers: + if obs["ip"] == ip and obs["port"] == port: + # Update the token (re-registration) + obs["token"] = token + self.log("Re-registered observer {}:{} for {}".format(ip, port, resource_url)) + return True + + # Check limits + total = sum(len(v) for v in self._observers.values()) + if total >= self.MAX_TOTAL_OBSERVERS: + self.log("Max total observers reached, rejecting registration") + return False + + if len(observers) >= self.MAX_OBSERVERS_PER_RESOURCE: + self.log("Max observers for {} reached, rejecting".format(resource_url)) + return False + + observers.append({ + "ip": ip, + "port": port, + "token": token, + }) + + self.log("Registered observer {}:{} for {} (token={})".format( + ip, port, resource_url, token + )) + return True + + def deregister(self, resource_url, ip, port): + """Remove an observer for a resource.""" + if resource_url not in self._observers: + return + + observers = self._observers[resource_url] + self._observers[resource_url] = [ + obs for obs in observers + if not (obs["ip"] == ip and obs["port"] == port) + ] + + self.log("Deregistered observer {}:{} from {}".format(ip, port, resource_url)) + + def deregister_by_token(self, resource_url, token): + """Remove an observer by token (used when RST is received).""" + if resource_url not in self._observers: + return + + observers = self._observers[resource_url] + self._observers[resource_url] = [ + obs for obs in observers + if obs["token"] != token + ] + + def deregister_all(self, resource_url): + """Remove all observers for a resource.""" + if resource_url in self._observers: + del self._observers[resource_url] + del self._sequence[resource_url] + + def get_observers(self, resource_url): + """Get the list of observers for a resource.""" + return self._observers.get(resource_url, []) + + def has_observers(self, resource_url): + """Check if a resource has any registered observers.""" + return len(self._observers.get(resource_url, [])) > 0 + + def next_sequence(self, resource_url): + """Get and increment the sequence number for a resource (24-bit wrap).""" + if resource_url not in self._sequence: + self._sequence[resource_url] = 0 + + seq = self._sequence[resource_url] + self._sequence[resource_url] = (seq + 1) & 0xFFFFFF + return seq + + def get_all_resources(self): + """Get all resource URLs that have observers.""" + return list(self._observers.keys()) + + def observer_count(self, resource_url=None): + """Get observer count. If resource_url is None, returns total count.""" + if resource_url: + return len(self._observers.get(resource_url, [])) + return sum(len(v) for v in self._observers.values()) + + def summary(self): + """Return a summary string of all observer registrations.""" + parts = [] + for url, observers in self._observers.items(): + addrs = ["{}:{}".format(o["ip"], o["port"]) for o in observers] + parts.append("{} -> [{}]".format(url, ", ".join(addrs))) + return "; ".join(parts) if parts else "(no observers)" diff --git a/esp/sensors.py b/esp/sensors.py new file mode 100644 index 0000000..c6c89a2 --- /dev/null +++ b/esp/sensors.py @@ -0,0 +1,223 @@ +""" +Demeter ESP Sensor Node - Sensor Hardware Abstraction + +Reads analog/digital sensors and returns normalized values. +Designed for ESP32 but with ESP8266 fallback. + +Usage: + sensors = SensorManager(config) + sensors.init() + reading = sensors.read_soil_moisture() # returns 0.0 - 100.0 +""" + +try: + from machine import Pin, ADC +except ImportError: + # Running on CPython for testing + class Pin: + IN = 0 + PULL_UP = 1 + IRQ_FALLING = 2 + IRQ_RISING = 1 + IRQ_BOTH = 3 + def __init__(self, *a, **kw): pass + def value(self): return 0 + def irq(self, **kw): pass + + class ADC: + ATTN_11DB = 3 + WIDTH_12BIT = 3 + def __init__(self, pin): pass + def atten(self, a): pass + def width(self, w): pass + def read(self): return 2048 + +try: + import json +except ImportError: + import ujson as json + + +def _clamp(value, lo, hi): + return max(lo, min(hi, value)) + + +def _map_range(value, in_min, in_max, out_min, out_max): + """Map a value from one range to another, clamped.""" + if in_max == in_min: + return out_min + mapped = (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min + return _clamp(mapped, min(out_min, out_max), max(out_min, out_max)) + + +class SensorManager: + """Manages all sensors for a Demeter node.""" + + def __init__(self, config): + self.config = config + self._soil_adc = None + self._water_adc = None + self._trigger_pin = None + self._temp_sensor = None + self._trigger_fired = False + self._trigger_value = 0 + + def init(self): + """Initialize all configured sensor hardware.""" + cfg = self.config + + # Soil moisture (analog) + if cfg.SOIL_MOISTURE_PIN is not None: + pin = Pin(cfg.SOIL_MOISTURE_PIN) + self._soil_adc = ADC(pin) + try: + self._soil_adc.atten(ADC.ATTN_11DB) # Full 3.3V range + self._soil_adc.width(ADC.WIDTH_12BIT) # 0-4095 + except Exception: + pass # ESP8266 doesn't support these + + # Water level (analog) + if cfg.WATER_LEVEL_PIN is not None: + pin = Pin(cfg.WATER_LEVEL_PIN) + self._water_adc = ADC(pin) + try: + self._water_adc.atten(ADC.ATTN_11DB) + self._water_adc.width(ADC.WIDTH_12BIT) + except Exception: + pass + + # Temperature sensor + if cfg.TEMP_SENSOR_PIN is not None: + self._init_temp_sensor() + + # Digital trigger with interrupt + if cfg.TRIGGER_PIN is not None: + self._trigger_pin = Pin(cfg.TRIGGER_PIN, Pin.IN, Pin.PULL_UP) + self._trigger_value = self._trigger_pin.value() + + edge = Pin.IRQ_FALLING + if cfg.TRIGGER_EDGE == "rising": + edge = Pin.IRQ_RISING + elif cfg.TRIGGER_EDGE == "both": + edge = Pin.IRQ_FALLING | Pin.IRQ_RISING + + self._trigger_pin.irq(trigger=edge, handler=self._trigger_isr) + + def _init_temp_sensor(self): + """Initialize temperature sensor based on config type.""" + cfg = self.config + try: + if cfg.TEMP_SENSOR_TYPE == "dht22": + import dht + self._temp_sensor = dht.DHT22(Pin(cfg.TEMP_SENSOR_PIN)) + elif cfg.TEMP_SENSOR_TYPE == "ds18b20": + import onewire + import ds18x20 + ow = onewire.OneWire(Pin(cfg.TEMP_SENSOR_PIN)) + self._temp_sensor = ds18x20.DS18X20(ow) + roms = self._temp_sensor.scan() + if roms: + self._ds18b20_rom = roms[0] + else: + print("[sensors] No DS18B20 found on pin", cfg.TEMP_SENSOR_PIN) + self._temp_sensor = None + except ImportError: + print("[sensors] Temperature sensor driver not available") + self._temp_sensor = None + + def _trigger_isr(self, pin): + """ISR for digital trigger - just sets a flag (no CoAP inside ISR).""" + self._trigger_fired = True + self._trigger_value = pin.value() + + # ── Reading Methods ── + + def read_soil_moisture(self): + """Read soil moisture as a percentage (0.0 = dry, 100.0 = wet). + + Returns None if sensor is not configured. + """ + if self._soil_adc is None: + return None + raw = self._soil_adc.read() + cfg = self.config + return round(_map_range(raw, cfg.SOIL_MOISTURE_DRY, cfg.SOIL_MOISTURE_WET, 0.0, 100.0), 1) + + def read_temperature(self): + """Read temperature in Celsius. + + Returns None if sensor is not configured or read fails. + """ + if self._temp_sensor is None: + return None + + cfg = self.config + try: + if cfg.TEMP_SENSOR_TYPE == "dht22": + self._temp_sensor.measure() + return round(self._temp_sensor.temperature(), 1) + elif cfg.TEMP_SENSOR_TYPE == "ds18b20": + self._temp_sensor.convert_temp() + # DS18B20 needs ~750ms conversion time + import time + time.sleep_ms(750) + return round(self._temp_sensor.read_temp(self._ds18b20_rom), 1) + except Exception as e: + print("[sensors] Temperature read error:", e) + return None + + def read_water_level(self): + """Read water level as a percentage (0.0 = empty, 100.0 = full). + + Returns None if sensor is not configured. + """ + if self._water_adc is None: + return None + raw = self._water_adc.read() + cfg = self.config + return round(_map_range(raw, cfg.WATER_LEVEL_MIN, cfg.WATER_LEVEL_MAX, 0.0, 100.0), 1) + + def read_trigger(self): + """Read the current digital trigger pin state. + + Returns (value, fired) where: + value: current pin state (0 or 1) + fired: True if the ISR has fired since last call (clears flag) + """ + if self._trigger_pin is None: + return (0, False) + + fired = self._trigger_fired + self._trigger_fired = False + return (self._trigger_value, fired) + + def read_all_json(self): + """Read all sensors and return a JSON string. + + Example output: + { + "device": "esp32-plant-01", + "soil_moisture": 45.2, + "temperature": 24.1, + "water_level": 78.5, + "trigger": 0 + } + """ + data = {"device": self.config.DEVICE_ID} + + val = self.read_soil_moisture() + if val is not None: + data["soil_moisture"] = val + + val = self.read_temperature() + if val is not None: + data["temperature"] = val + + val = self.read_water_level() + if val is not None: + data["water_level"] = val + + trigger_val, _ = self.read_trigger() + data["trigger"] = trigger_val + + return json.dumps(data) diff --git a/esp/tests/test_observe.py b/esp/tests/test_observe.py new file mode 100644 index 0000000..617b533 --- /dev/null +++ b/esp/tests/test_observe.py @@ -0,0 +1,259 @@ +""" +Tests for microCoAPy Observe Extension + +Run with: python -m pytest tests/test_observe.py -v +Or simply: python tests/test_observe.py +""" + +import sys +import os + +# Add parent directory to path so we can import microcoapy +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from microcoapy.coap_macros import ( + COAP_OPTION_NUMBER, COAP_TYPE, COAP_METHOD, COAP_RESPONSE_CODE, + COAP_CONTENT_FORMAT, COAP_OBSERVE_REGISTER, COAP_OBSERVE_DEREGISTER, +) +from microcoapy.coap_packet import CoapPacket +from microcoapy.coap_writer import writePacketHeaderInfo, writePacketOptions, writePacketPayload +from microcoapy.coap_reader import parsePacketHeaderInfo, parsePacketOptionsAndPayload +from microcoapy.observe_manager import ObserveManager + + +def test_observe_option_encode_single_byte(): + """Observe value < 256 should encode as 1 byte.""" + pkt = CoapPacket() + pkt.setObserve(0) + + obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE] + assert len(obs_opts) == 1 + assert obs_opts[0].buffer == bytearray([0]) + print("PASS: observe option encode single byte (value=0)") + + pkt2 = CoapPacket() + pkt2.setObserve(42) + obs_opts2 = [o for o in pkt2.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE] + assert obs_opts2[0].buffer == bytearray([42]) + print("PASS: observe option encode single byte (value=42)") + + +def test_observe_option_encode_two_bytes(): + """Observe value 256-65535 should encode as 2 bytes.""" + pkt = CoapPacket() + pkt.setObserve(1000) + + obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE] + assert len(obs_opts) == 1 + buf = obs_opts[0].buffer + assert len(buf) == 2 + val = (buf[0] << 8) | buf[1] + assert val == 1000 + print("PASS: observe option encode two bytes (value=1000)") + + +def test_observe_option_encode_three_bytes(): + """Observe value 65536+ should encode as 3 bytes.""" + pkt = CoapPacket() + pkt.setObserve(100000) + + obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE] + buf = obs_opts[0].buffer + assert len(buf) == 3 + val = (buf[0] << 16) | (buf[1] << 8) | buf[2] + assert val == 100000 + print("PASS: observe option encode three bytes (value=100000)") + + +def test_get_observe_value(): + """getObserveValue() should decode the Observe option.""" + pkt = CoapPacket() + assert pkt.getObserveValue() is None + print("PASS: getObserveValue returns None when no option set") + + pkt.setObserve(0) + assert pkt.getObserveValue() == 0 + print("PASS: getObserveValue returns 0 (register)") + + pkt2 = CoapPacket() + pkt2.setObserve(1) + assert pkt2.getObserveValue() == 1 + print("PASS: getObserveValue returns 1 (deregister)") + + pkt3 = CoapPacket() + pkt3.setObserve(65000) + assert pkt3.getObserveValue() == 65000 + print("PASS: getObserveValue returns 65000") + + +def test_packet_roundtrip_with_observe(): + """A packet with Observe option should survive encode -> decode.""" + pkt = CoapPacket() + pkt.type = COAP_TYPE.COAP_CON + pkt.method = COAP_METHOD.COAP_GET + pkt.messageid = 0x1234 + pkt.token = bytearray([0xAA, 0xBB]) + pkt.setObserve(COAP_OBSERVE_REGISTER) + pkt.setUriPath("sensors/temperature") + + # Encode + buffer = bytearray() + writePacketHeaderInfo(buffer, pkt) + writePacketOptions(buffer, pkt) + writePacketPayload(buffer, pkt) + + # Decode + decoded = CoapPacket() + parsePacketHeaderInfo(buffer, decoded) + decoded.tokenLength = buffer[0] & 0x0F + decoded.token = buffer[4:4 + decoded.tokenLength] + assert parsePacketOptionsAndPayload(buffer, decoded) + + # Verify Observe option survived + obs_val = decoded.getObserveValue() + assert obs_val == 0, "Expected Observe=0, got {}".format(obs_val) + print("PASS: packet roundtrip with Observe option") + + # Verify URI path survived + uri = decoded.getUriPath() + assert uri == "sensors/temperature", "Expected 'sensors/temperature', got '{}'".format(uri) + print("PASS: packet roundtrip URI path preserved") + + +def test_max_age_option(): + """Max-Age option should encode correctly.""" + pkt = CoapPacket() + pkt.setMaxAge(60) + + max_age_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_MAX_AGE] + assert len(max_age_opts) == 1 + assert max_age_opts[0].buffer == bytearray([60]) + print("PASS: Max-Age option encode (value=60)") + + +def test_observer_manager_register(): + """ObserveManager should register and retrieve observers.""" + mgr = ObserveManager(debug=False) + + assert not mgr.has_observers("test/resource") + assert mgr.observer_count() == 0 + + ok = mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1, 2])) + assert ok + assert mgr.has_observers("test/resource") + assert mgr.observer_count("test/resource") == 1 + assert mgr.observer_count() == 1 + print("PASS: observer registration") + + +def test_observer_manager_reregister(): + """Re-registering the same observer should update token, not duplicate.""" + mgr = ObserveManager(debug=False) + + mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1, 2])) + mgr.register("test/resource", "192.168.1.10", 5683, bytearray([3, 4])) + + assert mgr.observer_count("test/resource") == 1 + + observers = mgr.get_observers("test/resource") + assert observers[0]["token"] == bytearray([3, 4]) + print("PASS: observer re-registration updates token") + + +def test_observer_manager_deregister(): + """Deregistering should remove the observer.""" + mgr = ObserveManager(debug=False) + + mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1])) + mgr.register("test/resource", "192.168.1.20", 5683, bytearray([2])) + assert mgr.observer_count("test/resource") == 2 + + mgr.deregister("test/resource", "192.168.1.10", 5683) + assert mgr.observer_count("test/resource") == 1 + assert mgr.get_observers("test/resource")[0]["ip"] == "192.168.1.20" + print("PASS: observer deregistration") + + +def test_observer_manager_deregister_by_token(): + """Deregistering by token (RST handling) should work.""" + mgr = ObserveManager(debug=False) + + mgr.register("test/resource", "192.168.1.10", 5683, bytearray([0xAA])) + mgr.register("test/resource", "192.168.1.20", 5684, bytearray([0xBB])) + + mgr.deregister_by_token("test/resource", bytearray([0xAA])) + assert mgr.observer_count("test/resource") == 1 + assert mgr.get_observers("test/resource")[0]["token"] == bytearray([0xBB]) + print("PASS: observer deregister by token (RST)") + + +def test_observer_manager_limits(): + """Observer limits should be enforced.""" + mgr = ObserveManager(debug=False) + mgr.MAX_OBSERVERS_PER_RESOURCE = 2 + mgr.MAX_TOTAL_OBSERVERS = 3 + + assert mgr.register("res1", "10.0.0.1", 5683, bytearray([1])) + assert mgr.register("res1", "10.0.0.2", 5683, bytearray([2])) + assert not mgr.register("res1", "10.0.0.3", 5683, bytearray([3])) # per-resource limit + print("PASS: per-resource observer limit enforced") + + assert mgr.register("res2", "10.0.0.3", 5683, bytearray([3])) + assert not mgr.register("res2", "10.0.0.4", 5683, bytearray([4])) # total limit + print("PASS: total observer limit enforced") + + +def test_observer_manager_sequence(): + """Sequence numbers should increment and wrap at 24 bits.""" + mgr = ObserveManager(debug=False) + mgr._sequence["test"] = 0xFFFFFE + + assert mgr.next_sequence("test") == 0xFFFFFE + assert mgr.next_sequence("test") == 0xFFFFFF + assert mgr.next_sequence("test") == 0 # Wrap + print("PASS: sequence number wrap at 24 bits") + + +def test_get_uri_path(): + """getUriPath() should reconstruct the path from options.""" + pkt = CoapPacket() + pkt.setUriPath("sensors/soil_moisture") + + assert pkt.getUriPath() == "sensors/soil_moisture" + print("PASS: getUriPath reconstruction") + + +# ── Run all tests ── + +if __name__ == "__main__": + tests = [ + test_observe_option_encode_single_byte, + test_observe_option_encode_two_bytes, + test_observe_option_encode_three_bytes, + test_get_observe_value, + test_packet_roundtrip_with_observe, + test_max_age_option, + test_observer_manager_register, + test_observer_manager_reregister, + test_observer_manager_deregister, + test_observer_manager_deregister_by_token, + test_observer_manager_limits, + test_observer_manager_sequence, + test_get_uri_path, + ] + + passed = 0 + failed = 0 + for test in tests: + try: + test() + passed += 1 + except Exception as e: + print("FAIL: {} - {}".format(test.__name__, e)) + import traceback + traceback.print_exc() + failed += 1 + + print("\n" + "=" * 40) + print("Results: {} passed, {} failed".format(passed, failed)) + print("=" * 40) diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..30cf661 --- /dev/null +++ b/server/app/__init__.py @@ -0,0 +1 @@ +"""Demeter IoT Management Server.""" diff --git a/server/app/coap_bridge.py b/server/app/coap_bridge.py new file mode 100644 index 0000000..820f110 --- /dev/null +++ b/server/app/coap_bridge.py @@ -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)) diff --git a/server/app/coap_observer.py b/server/app/coap_observer.py new file mode 100644 index 0000000..877df94 --- /dev/null +++ b/server/app/coap_observer.py @@ -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": "...", "": 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 diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..d7b92d5 --- /dev/null +++ b/server/app/config.py @@ -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() diff --git a/server/app/dashboard.py b/server/app/dashboard.py new file mode 100644 index 0000000..ed3ba0e --- /dev/null +++ b/server/app/dashboard.py @@ -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(), + } diff --git a/server/app/device_store.py b/server/app/device_store.py new file mode 100644 index 0000000..a9c2190 --- /dev/null +++ b/server/app/device_store.py @@ -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 diff --git a/server/app/devices_api.py b/server/app/devices_api.py new file mode 100644 index 0000000..825b6fa --- /dev/null +++ b/server/app/devices_api.py @@ -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(), + } diff --git a/server/app/logging_config.py b/server/app/logging_config.py new file mode 100644 index 0000000..9e371d5 --- /dev/null +++ b/server/app/logging_config.py @@ -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) diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..df4be2f --- /dev/null +++ b/server/app/main.py @@ -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() diff --git a/server/app/metrics.py b/server/app/metrics.py new file mode 100644 index 0000000..764a786 --- /dev/null +++ b/server/app/metrics.py @@ -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) diff --git a/server/config/devices.yaml b/server/config/devices.yaml new file mode 100644 index 0000000..f50d1fb --- /dev/null +++ b/server/config/devices.yaml @@ -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" diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..1285960 --- /dev/null +++ b/server/pyproject.toml @@ -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"] diff --git a/server/templates/base.html b/server/templates/base.html new file mode 100644 index 0000000..8f8ee33 --- /dev/null +++ b/server/templates/base.html @@ -0,0 +1,85 @@ + + + + + + {% block title %}Demeter IoT{% endblock %} + + + + + + + + + + +
+ {% if error %} +
+ + + + {{ error }} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + +
+
+

Demeter IoT Server v{{ server_version }} — CoAP + FastAPI

+
+
+ + + + + {% block scripts %}{% endblock %} + + diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html new file mode 100644 index 0000000..2005957 --- /dev/null +++ b/server/templates/dashboard.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Demeter IoT{% endblock %} + +{% block content %} + +
+
+
Devices
+
{{ devices|length }}
+
Registered
+
+
+
Online
+
{{ devices|selectattr('online')|list|length }}
+
Connected via CoAP
+
+
+
Subscriptions
+
{{ active_subscriptions }}
+
Active Observe
+
+
+ + +
+ {% for device in devices %} +
+
+ +
+

{{ device.config.name }}

+ {% if device.online %} +
+ + + + Online +
+ {% else %} +
+ + + + Offline +
+ {% endif %} +
+ +

{{ device.config.ip }}:{{ device.config.port }} · {{ device.config.id }}

+ +
+ + + {% if device.readings %} + {% for uri, reading in device.readings.items() %} +
+ + {% if 'soil_moisture' in uri %} + + Soil Moisture + {% elif 'temperature' in uri %} + + Temperature + {% elif 'water_level' in uri %} + + Water Level + {% elif 'trigger' in uri %} + + Trigger + {% else %} + {{ uri }} + {% endif %} + + + {% if reading.value is not none %} + {{ reading.value }} + {{ reading.unit }} + {% else %} + + {% endif %} + +
+ + {% if 'soil_moisture' in uri or 'water_level' in uri %} + + + {% endif %} + {% endfor %} + {% else %} +

No readings yet

+ {% endif %} + + +
+ Details +
+
+
+ {% endfor %} + + {% if not devices %} +
+
+ + + + No devices configured. Edit config/devices.yaml and restart the server. +
+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/templates/device_detail.html b/server/templates/device_detail.html new file mode 100644 index 0000000..c5d38e0 --- /dev/null +++ b/server/templates/device_detail.html @@ -0,0 +1,164 @@ +{% extends "base.html" %} +{% block title %}{{ device.config.name }} — Demeter IoT{% endblock %} + +{% block content %} + + + + +
+

{{ device.config.name }}

+ {% if device.online %} +
Online
+ {% else %} +
Offline
+ {% endif %} +
+ +
+ +
+
+

Device Information

+ + + + + + + + + + + + + + + + + + + +
Device ID{{ device.config.id }}
IP Address{{ device.config.ip }}:{{ device.config.port }}
Status + {% if device.online %} + Connected + {% else %} + Disconnected + {% endif %} +
Resources{{ device.config.resources|length }} configured
+
+
+ + +
+
+

CoAP Bridge

+

Query the device directly via the HTTP-to-CoAP proxy.

+ +
+ {% for resource in device.config.resources %} +
+ {{ resource.uri }} + +
+ {% endfor %} + +
+ device/info + +
+
+ + +
+
+Click GET to query a resource...
+
+
+
+
+ + +
+
+

Sensor Readings

+ + {% if device.readings %} +
+ + + + + + + + + + + + {% for uri, reading in device.readings.items() %} + + + + + + + + {% endfor %} + +
ResourceValueUnitAgeStatus
{{ uri }} + {% if reading.value is not none %} + {{ reading.value }} + {% else %} + + {% endif %} + {{ reading.unit }}{{ reading.age_seconds()|round(0)|int }}s ago + {% if reading.stale %} +
Stale
+ {% else %} +
Fresh
+ {% endif %} +
+
+ {% else %} +

No readings received yet. The device may be offline or not yet subscribed.

+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..92345f9 --- /dev/null +++ b/server/tests/conftest.py @@ -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) diff --git a/server/tests/test_device_store.py b/server/tests/test_device_store.py new file mode 100644 index 0000000..b577d40 --- /dev/null +++ b/server/tests/test_device_store.py @@ -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") diff --git a/server/tests/test_devices_api.py b/server/tests/test_devices_api.py new file mode 100644 index 0000000..83a4dae --- /dev/null +++ b/server/tests/test_devices_api.py @@ -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