docs: add project description and server setup instructions to README

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

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,2 +1,8 @@
# demeter # demeter
IOT Plant and Aquarium Monitoring System
To Run the server:
cd server
pip install -e ".[dev]"
uvicorn app.main:app --reload --port 25572

Binary file not shown.

BIN
esp/.DS_Store vendored Normal file

Binary file not shown.

77
esp/README.md Normal file
View File

@@ -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 0100% |
| `/sensors/temperature` | GET | Yes (periodic) | Temperature in °C |
| `/sensors/water_level` | GET | Yes (periodic) | Water level 0100% |
| `/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)

58
esp/config.py Normal file
View File

@@ -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

339
esp/main.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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)"

223
esp/sensors.py Normal file
View File

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

259
esp/tests/test_observe.py Normal file
View File

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

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

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

98
server/app/coap_bridge.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Demeter Server — CoAP-to-REST Bridge
Proxies HTTP requests to CoAP endpoints on ESP devices, allowing
the dashboard and external tools to query devices via standard HTTP.
Routes:
GET /api/devices/{device_id}/coap/{resource_path} — proxy CoAP GET
PUT /api/devices/{device_id}/coap/{resource_path} — proxy CoAP PUT
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
router = APIRouter(prefix="/api", tags=["coap-bridge"])
def _get_store(request: Request):
return request.app.state.store
def _get_observer(request: Request):
return request.app.state.observer
@router.get("/devices/{device_id}/coap/{resource_path:path}")
async def coap_proxy_get(
device_id: str,
resource_path: str,
request: Request,
) -> dict[str, Any]:
"""
Proxy a CoAP GET request to an ESP device.
Example: GET /api/devices/esp32-plant-01/coap/sensors/temperature
→ CoAP GET coap://192.168.1.100:5683/sensors/temperature
"""
store = _get_store(request)
observer = _get_observer(request)
device = await store.get_device(device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
try:
result = await observer.coap_get(
device.config.ip, device.config.port, resource_path
)
return {
"device_id": device_id,
"resource": resource_path,
"method": "GET",
"response": result,
}
except ConnectionError as e:
raise HTTPException(status_code=504, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.put("/devices/{device_id}/coap/{resource_path:path}")
async def coap_proxy_put(
device_id: str,
resource_path: str,
body: dict[str, Any],
request: Request,
) -> dict[str, Any]:
"""
Proxy a CoAP PUT request to an ESP device.
Example: PUT /api/devices/esp32-plant-01/coap/config/interval
Body: {"interval": 10}
→ CoAP PUT coap://192.168.1.100:5683/config/interval
"""
store = _get_store(request)
observer = _get_observer(request)
device = await store.get_device(device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
try:
result = await observer.coap_put(
device.config.ip, device.config.port, resource_path, body
)
return {
"device_id": device_id,
"resource": resource_path,
"method": "PUT",
"response": result,
}
except ConnectionError as e:
raise HTTPException(status_code=504, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=502, detail=str(e))

313
server/app/coap_observer.py Normal file
View File

@@ -0,0 +1,313 @@
"""
Demeter Server — CoAP Observer Client
Wraps aiocoap to manage Observe subscriptions to ESP sensor nodes.
Each subscription receives push notifications when sensor values change,
updating the in-memory DeviceStore.
Also provides one-shot CoAP GET/PUT for the REST-to-CoAP bridge.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING, Any, Optional
import aiocoap
from aiocoap import Code, Message
if TYPE_CHECKING:
from .device_store import DeviceStore
from .metrics import MetricsCollector
logger = logging.getLogger(__name__)
class CoapObserverClient:
"""
Manages CoAP Observe subscriptions and one-shot requests.
Lifecycle:
client = CoapObserverClient(store, metrics)
await client.startup()
await client.subscribe("esp32-plant-01", "192.168.1.100", 5683, "sensors/temperature")
...
await client.shutdown()
"""
def __init__(
self,
store: DeviceStore,
metrics: Optional[MetricsCollector] = None,
request_timeout: float = 10.0,
reconnect_base: float = 5.0,
reconnect_max: float = 60.0,
):
self._store = store
self._metrics = metrics
self._context: Optional[aiocoap.Context] = None
self._subscriptions: dict[tuple[str, str], asyncio.Task] = {}
self._request_timeout = request_timeout
self._reconnect_base = reconnect_base
self._reconnect_max = reconnect_max
async def startup(self) -> None:
"""Create the aiocoap client context."""
self._context = await aiocoap.Context.create_client_context()
logger.info("CoAP client context created")
async def shutdown(self) -> None:
"""Cancel all subscriptions and close the context."""
logger.info("Shutting down CoAP observer client...")
# Cancel all subscription tasks
for key, task in self._subscriptions.items():
task.cancel()
logger.debug("Cancelled subscription %s", key)
self._subscriptions.clear()
# Close context
if self._context:
await self._context.shutdown()
self._context = None
logger.info("CoAP observer client shut down")
# ── Observe Subscriptions ──
async def subscribe(
self,
device_id: str,
ip: str,
port: int,
resource_uri: str,
) -> None:
"""
Start an Observe subscription to a device resource.
Runs as a background task with automatic reconnection on failure.
"""
key = (device_id, resource_uri)
if key in self._subscriptions and not self._subscriptions[key].done():
logger.debug("Already subscribed to %s/%s", device_id, resource_uri)
return
task = asyncio.create_task(
self._observe_loop(device_id, ip, port, resource_uri),
name=f"observe-{device_id}-{resource_uri}",
)
self._subscriptions[key] = task
logger.info("Started Observe subscription: %s/%s", device_id, resource_uri)
async def unsubscribe(self, device_id: str, resource_uri: str) -> None:
"""Cancel a specific subscription."""
key = (device_id, resource_uri)
task = self._subscriptions.pop(key, None)
if task and not task.done():
task.cancel()
logger.info("Unsubscribed from %s/%s", device_id, resource_uri)
async def _observe_loop(
self,
device_id: str,
ip: str,
port: int,
resource_uri: str,
) -> None:
"""
Observe loop with exponential backoff on failure.
Keeps reconnecting until cancelled.
"""
backoff = self._reconnect_base
while True:
try:
await self._run_observation(device_id, ip, port, resource_uri)
except asyncio.CancelledError:
logger.debug("Observe cancelled: %s/%s", device_id, resource_uri)
return
except Exception as e:
logger.warning(
"Observe failed for %s/%s: %s — retrying in %.0fs",
device_id, resource_uri, e, backoff,
)
await self._store.mark_offline(device_id)
if self._metrics:
self._metrics.record_coap_error(device_id, type(e).__name__)
try:
await asyncio.sleep(backoff)
except asyncio.CancelledError:
return
backoff = min(backoff * 2, self._reconnect_max)
else:
# Observation ended normally (shouldn't happen) — reset backoff
backoff = self._reconnect_base
async def _run_observation(
self,
device_id: str,
ip: str,
port: int,
resource_uri: str,
) -> None:
"""Execute a single Observe session."""
uri = f"coap://{ip}:{port}/{resource_uri}"
request = Message(code=Code.GET, uri=uri, observe=0)
logger.debug("Sending Observe GET to %s", uri)
try:
response = await asyncio.wait_for(
self._context.request(request).response,
timeout=self._request_timeout,
)
except asyncio.TimeoutError:
raise ConnectionError(f"Timeout connecting to {uri}")
# Process the initial response
await self._handle_notification(device_id, resource_uri, response)
await self._store.mark_online(device_id)
if self._metrics:
self._metrics.record_coap_request(device_id)
# Reset backoff on successful connection
logger.info("Observe active: %s/%s", device_id, resource_uri)
# Process subsequent notifications
observation = self._context.request(Message(code=Code.GET, uri=uri, observe=0))
async for notification in observation.observation:
await self._handle_notification(device_id, resource_uri, notification)
if self._metrics:
self._metrics.record_coap_request(device_id)
async def _handle_notification(
self,
device_id: str,
resource_uri: str,
response: Message,
) -> None:
"""Parse a CoAP response/notification and update the store."""
try:
payload = json.loads(response.payload.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.warning(
"Invalid payload from %s/%s: %s", device_id, resource_uri, e
)
return
# Extract value and unit from the ESP JSON format
# ESP sends: {"device": "...", "<resource_key>": value, "unit": "..."}
value = None
unit = payload.get("unit", "")
# Map resource URIs to JSON keys
resource_key_map = {
"sensors/soil_moisture": "soil_moisture",
"sensors/temperature": "temperature",
"sensors/water_level": "water_level",
"events/trigger": "trigger",
}
key = resource_key_map.get(resource_uri)
if key and key in payload:
value = payload[key]
else:
# Fallback: try "value" key or use full payload
value = payload.get("value", payload)
await self._store.update_reading(device_id, resource_uri, value, unit)
if self._metrics:
self._metrics.update_sensor_metric(device_id, resource_uri, value, unit)
logger.debug(
"Notification: %s/%s = %s %s", device_id, resource_uri, value, unit
)
# ── One-Shot Requests (for CoAP bridge) ──
async def coap_get(
self, ip: str, port: int, resource_uri: str
) -> dict[str, Any]:
"""
Send a one-shot CoAP GET and return the parsed JSON payload.
Raises ConnectionError on timeout, ValueError on invalid response.
"""
uri = f"coap://{ip}:{port}/{resource_uri}"
request = Message(code=Code.GET, uri=uri)
try:
response = await asyncio.wait_for(
self._context.request(request).response,
timeout=self._request_timeout,
)
except asyncio.TimeoutError:
raise ConnectionError(f"CoAP GET timeout: {uri}")
if not response.payload:
return {"status": "ok", "code": str(response.code)}
try:
return json.loads(response.payload.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
raise ValueError(f"Invalid JSON from {uri}")
async def coap_put(
self, ip: str, port: int, resource_uri: str, payload: dict
) -> dict[str, Any]:
"""
Send a one-shot CoAP PUT with a JSON payload.
Raises ConnectionError on timeout, ValueError on invalid response.
"""
uri = f"coap://{ip}:{port}/{resource_uri}"
request = Message(
code=Code.PUT,
uri=uri,
payload=json.dumps(payload).encode("utf-8"),
)
try:
response = await asyncio.wait_for(
self._context.request(request).response,
timeout=self._request_timeout,
)
except asyncio.TimeoutError:
raise ConnectionError(f"CoAP PUT timeout: {uri}")
if not response.payload:
return {"status": "ok", "code": str(response.code)}
try:
return json.loads(response.payload.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
raise ValueError(f"Invalid JSON from {uri}")
# ── Status ──
@property
def active_subscriptions(self) -> int:
"""Number of currently active subscription tasks."""
return sum(1 for t in self._subscriptions.values() if not t.done())
def subscription_status(self) -> dict[str, str]:
"""Return status of each subscription (running/done/cancelled)."""
result = {}
for (dev_id, uri), task in self._subscriptions.items():
key = f"{dev_id}/{uri}"
if task.cancelled():
result[key] = "cancelled"
elif task.done():
exc = task.exception()
result[key] = f"failed: {exc}" if exc else "done"
else:
result[key] = "running"
return result

99
server/app/config.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Demeter Server — Configuration
Loads settings from environment variables and the device registry
from config/devices.yaml.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
import yaml
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
logger = logging.getLogger(__name__)
# ── Paths ──
BASE_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = BASE_DIR / "config"
TEMPLATES_DIR = BASE_DIR / "templates"
# ── Device Config Models ──
class ResourceConfig(BaseModel):
"""A single CoAP resource on a device."""
uri: str
name: str
type: str = "periodic" # "periodic" or "event"
class DeviceConfig(BaseModel):
"""A single ESP sensor node."""
id: str
name: str
ip: str
port: int = 5683
enabled: bool = True
resources: list[ResourceConfig] = Field(default_factory=list)
class DevicesConfig(BaseModel):
"""Top-level device registry loaded from YAML."""
devices: list[DeviceConfig] = Field(default_factory=list)
# ── Application Settings ──
class Settings(BaseSettings):
"""Server settings loaded from environment variables."""
# FastAPI
app_title: str = "Demeter IoT Server"
app_version: str = "0.1.0"
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 8000
# Loki
loki_url: Optional[str] = None # e.g. "http://localhost:3100/loki/api/v1/push"
# CoAP
coap_request_timeout: float = 10.0 # seconds
coap_reconnect_base: float = 5.0 # base backoff seconds
coap_reconnect_max: float = 60.0 # max backoff seconds
# Device config path
devices_config_path: str = str(CONFIG_DIR / "devices.yaml")
model_config = {"env_prefix": "DEMETER_", "env_file": ".env"}
def load_devices_config(path: str | Path | None = None) -> DevicesConfig:
"""Load and validate the device registry from YAML."""
if path is None:
path = CONFIG_DIR / "devices.yaml"
path = Path(path)
if not path.exists():
logger.warning("Device config not found at %s, using empty registry", path)
return DevicesConfig(devices=[])
with open(path) as f:
raw = yaml.safe_load(f)
if raw is None:
return DevicesConfig(devices=[])
return DevicesConfig.model_validate(raw)
# Singleton settings instance
settings = Settings()

101
server/app/dashboard.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Demeter Server — Dashboard Routes
Serves the web UI using Jinja2 templates with DaisyUI (Tailwind CSS).
Auto-refresh is handled by periodic fetch() calls in the templates.
Routes:
GET / — redirect to dashboard
GET /dashboard — main device overview
GET /dashboard/devices/{device_id} — device detail page
GET /dashboard/api/readings — JSON readings for auto-refresh
"""
from __future__ import annotations
import time
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .config import TEMPLATES_DIR
router = APIRouter(tags=["dashboard"])
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
def _get_store(request: Request):
return request.app.state.store
def _get_observer(request: Request):
return request.app.state.observer
@router.get("/", include_in_schema=False)
async def root():
"""Redirect root to dashboard."""
return RedirectResponse(url="/dashboard")
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Main dashboard showing all devices and their latest readings."""
store = _get_store(request)
observer = _get_observer(request)
devices = await store.get_all_devices()
settings = request.app.state.settings
return templates.TemplateResponse(
request,
"dashboard.html",
{
"devices": devices,
"server_version": settings.app_version,
"active_subscriptions": observer.active_subscriptions,
"timestamp": time.time(),
},
)
@router.get("/dashboard/devices/{device_id}", response_class=HTMLResponse)
async def device_detail(device_id: str, request: Request):
"""Detailed view for a single device."""
store = _get_store(request)
device = await store.get_device(device_id)
if device is None:
return templates.TemplateResponse(
request,
"dashboard.html",
{
"devices": await store.get_all_devices(),
"server_version": request.app.state.settings.app_version,
"active_subscriptions": _get_observer(request).active_subscriptions,
"timestamp": time.time(),
"error": f"Device {device_id!r} not found",
},
)
return templates.TemplateResponse(
request,
"device_detail.html",
{
"device": device,
"server_version": request.app.state.settings.app_version,
},
)
@router.get("/dashboard/api/readings")
async def dashboard_readings(request: Request) -> dict[str, Any]:
"""JSON endpoint for dashboard auto-refresh polling."""
store = _get_store(request)
snapshot = await store.snapshot()
return {
"devices": snapshot,
"timestamp": time.time(),
}

177
server/app/device_store.py Normal file
View File

@@ -0,0 +1,177 @@
"""
Demeter Server — In-Memory Device Store
Maintains the device registry (from YAML config) and a cache of
the latest sensor readings received via CoAP Observe notifications.
No database — this is intentionally simple for the POC.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Optional
from .config import DeviceConfig, DevicesConfig
logger = logging.getLogger(__name__)
@dataclass
class SensorReading:
"""A single sensor reading with metadata."""
value: Any
unit: str = ""
timestamp: float = 0.0
stale: bool = False
def age_seconds(self) -> float:
"""Seconds since this reading was recorded."""
if self.timestamp == 0:
return float("inf")
return time.time() - self.timestamp
def to_dict(self) -> dict:
return {
"value": self.value,
"unit": self.unit,
"timestamp": self.timestamp,
"stale": self.stale,
"age_seconds": round(self.age_seconds(), 1),
}
@dataclass
class DeviceState:
"""Runtime state for a single device."""
config: DeviceConfig
online: bool = False
last_seen: float = 0.0
readings: dict[str, SensorReading] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"id": self.config.id,
"name": self.config.name,
"ip": self.config.ip,
"port": self.config.port,
"enabled": self.config.enabled,
"online": self.online,
"last_seen": self.last_seen,
"readings": {
uri: reading.to_dict()
for uri, reading in self.readings.items()
},
}
class DeviceStore:
"""
Thread-safe in-memory store for device metadata and sensor readings.
Designed for single-process asyncio usage (FastAPI + aiocoap).
"""
def __init__(self, devices_config: DevicesConfig):
self._lock = asyncio.Lock()
self._devices: dict[str, DeviceState] = {}
for dev in devices_config.devices:
self._devices[dev.id] = DeviceState(config=dev)
logger.info(
"Registered device %s (%s) at %s:%d with %d resources",
dev.id, dev.name, dev.ip, dev.port, len(dev.resources),
)
# ── Queries ──
async def get_device(self, device_id: str) -> Optional[DeviceState]:
"""Get a single device state, or None if not found."""
async with self._lock:
return self._devices.get(device_id)
async def get_all_devices(self) -> list[DeviceState]:
"""Get all registered devices."""
async with self._lock:
return list(self._devices.values())
async def get_enabled_devices(self) -> list[DeviceState]:
"""Get only enabled devices (for subscription)."""
async with self._lock:
return [d for d in self._devices.values() if d.config.enabled]
async def get_device_by_ip(self, ip: str) -> Optional[DeviceState]:
"""Look up a device by IP address."""
async with self._lock:
for dev in self._devices.values():
if dev.config.ip == ip:
return dev
return None
async def snapshot(self) -> dict[str, dict]:
"""Return a JSON-serializable snapshot of all devices + readings."""
async with self._lock:
return {
dev_id: state.to_dict()
for dev_id, state in self._devices.items()
}
# ── Updates ──
async def update_reading(
self,
device_id: str,
resource_uri: str,
value: Any,
unit: str = "",
) -> None:
"""Record a new sensor reading from a CoAP notification."""
async with self._lock:
state = self._devices.get(device_id)
if state is None:
logger.warning("Reading for unknown device %s, ignoring", device_id)
return
now = time.time()
state.readings[resource_uri] = SensorReading(
value=value,
unit=unit,
timestamp=now,
)
state.online = True
state.last_seen = now
logger.debug(
"Updated %s/%s = %s %s",
device_id, resource_uri, value, unit,
)
async def mark_online(self, device_id: str) -> None:
"""Mark a device as online (e.g., on successful subscription)."""
async with self._lock:
state = self._devices.get(device_id)
if state:
state.online = True
state.last_seen = time.time()
async def mark_offline(self, device_id: str) -> None:
"""Mark a device as offline (e.g., on subscription failure)."""
async with self._lock:
state = self._devices.get(device_id)
if state:
state.online = False
# Mark all readings as stale
for reading in state.readings.values():
reading.stale = True
async def mark_reading_stale(
self, device_id: str, resource_uri: str
) -> None:
"""Mark a specific reading as stale (e.g., Max-Age expired)."""
async with self._lock:
state = self._devices.get(device_id)
if state and resource_uri in state.readings:
state.readings[resource_uri].stale = True

90
server/app/devices_api.py Normal file
View File

@@ -0,0 +1,90 @@
"""
Demeter Server — REST API Endpoints
Provides JSON endpoints for querying device metadata and sensor
readings from the in-memory store.
Routes:
GET /api/devices — list all devices + latest readings
GET /api/devices/{device_id} — single device detail
GET /api/devices/{device_id}/readings — sensor readings only
GET /api/status — server status summary
"""
from __future__ import annotations
import time
from typing import Any
from fastapi import APIRouter, HTTPException, Request
router = APIRouter(prefix="/api", tags=["devices"])
def _get_store(request: Request):
"""Retrieve the DeviceStore from app state."""
return request.app.state.store
def _get_observer(request: Request):
"""Retrieve the CoapObserverClient from app state."""
return request.app.state.observer
@router.get("/devices")
async def list_devices(request: Request) -> dict[str, Any]:
"""List all registered devices with their latest readings."""
store = _get_store(request)
snapshot = await store.snapshot()
return {
"devices": list(snapshot.values()),
"count": len(snapshot),
"timestamp": time.time(),
}
@router.get("/devices/{device_id}")
async def get_device(device_id: str, request: Request) -> dict[str, Any]:
"""Get a single device's full state."""
store = _get_store(request)
device = await store.get_device(device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
return device.to_dict()
@router.get("/devices/{device_id}/readings")
async def get_readings(device_id: str, request: Request) -> dict[str, Any]:
"""Get sensor readings for a specific device."""
store = _get_store(request)
device = await store.get_device(device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id!r} not found")
return {
"device_id": device_id,
"online": device.online,
"readings": {
uri: reading.to_dict()
for uri, reading in device.readings.items()
},
}
@router.get("/status")
async def server_status(request: Request) -> dict[str, Any]:
"""Server status: subscription count, device overview."""
store = _get_store(request)
observer = _get_observer(request)
devices = await store.get_all_devices()
online = sum(1 for d in devices if d.online)
return {
"server": "demeter",
"version": request.app.state.settings.app_version,
"devices_total": len(devices),
"devices_online": online,
"active_subscriptions": observer.active_subscriptions,
"subscriptions": observer.subscription_status(),
"timestamp": time.time(),
}

View File

@@ -0,0 +1,64 @@
"""
Demeter Server — Logging Configuration
Sets up structured logging with optional Loki integration.
Falls back to console-only logging if Loki is unreachable.
"""
from __future__ import annotations
import logging
import sys
from typing import Optional
def setup_logging(loki_url: Optional[str] = None, debug: bool = False) -> None:
"""
Configure logging for the Demeter server.
Args:
loki_url: Loki push endpoint (e.g., "http://localhost:3100/loki/api/v1/push").
If None, logs go to console only.
debug: Enable DEBUG level logging.
"""
level = logging.DEBUG if debug else logging.INFO
# Root logger
root = logging.getLogger()
root.setLevel(level)
# Console handler
console = logging.StreamHandler(sys.stdout)
console.setLevel(level)
fmt = logging.Formatter(
"[%(asctime)s] %(levelname)-8s %(name)s%(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console.setFormatter(fmt)
root.addHandler(console)
# Loki handler (optional)
if loki_url:
try:
import logging_loki
loki_handler = logging_loki.LokiHandler(
url=loki_url,
tags={"app": "demeter-server"},
version="1",
)
loki_handler.setLevel(level)
root.addHandler(loki_handler)
logging.getLogger(__name__).info("Loki logging enabled at %s", loki_url)
except ImportError:
logging.getLogger(__name__).warning(
"python-logging-loki not installed, Loki logging disabled"
)
except Exception as e:
logging.getLogger(__name__).warning(
"Failed to connect to Loki at %s: %s", loki_url, e
)
# Quiet noisy libraries
logging.getLogger("aiocoap").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)

140
server/app/main.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Demeter Server — FastAPI Application
Entry point for the Demeter IoT management server. Manages the
lifecycle of the aiocoap observer client and FastAPI HTTP server
on a shared asyncio event loop.
Run with:
cd server
uvicorn app.main:app --reload --port 8000
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import Response
from .coap_bridge import router as coap_bridge_router
from .coap_observer import CoapObserverClient
from .config import Settings, load_devices_config
from .dashboard import router as dashboard_router
from .device_store import DeviceStore
from .devices_api import router as devices_api_router
from .logging_config import setup_logging
from .metrics import MetricsCollector
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Manage startup and shutdown of the CoAP observer client.
Startup:
1. Configure logging (console + optional Loki)
2. Load device registry from YAML
3. Initialize the in-memory store
4. Create the aiocoap client context
5. Subscribe to all enabled device resources
Shutdown:
1. Cancel all CoAP subscriptions
2. Close the aiocoap context
"""
settings: Settings = app.state.settings
metrics: MetricsCollector = app.state.metrics
# ── Logging ──
setup_logging(loki_url=settings.loki_url, debug=settings.debug)
logger.info("=" * 50)
logger.info(" Demeter IoT Server v%s", settings.app_version)
logger.info("=" * 50)
# ── Device Config ──
devices_config = load_devices_config(settings.devices_config_path)
enabled = [d for d in devices_config.devices if d.enabled]
logger.info("Loaded %d devices (%d enabled)", len(devices_config.devices), len(enabled))
# ── Store ──
store = DeviceStore(devices_config)
app.state.store = store
# ── Metrics ──
metrics.set_server_info(settings.app_version)
# ── CoAP Observer ──
observer = CoapObserverClient(
store=store,
metrics=metrics,
request_timeout=settings.coap_request_timeout,
reconnect_base=settings.coap_reconnect_base,
reconnect_max=settings.coap_reconnect_max,
)
app.state.observer = observer
await observer.startup()
# Subscribe to all enabled device resources
for device in enabled:
for resource in device.resources:
await observer.subscribe(
device_id=device.id,
ip=device.ip,
port=device.port,
resource_uri=resource.uri,
)
total_subs = sum(len(d.resources) for d in enabled)
logger.info("Subscribed to %d resources across %d devices", total_subs, len(enabled))
metrics.set_active_subscriptions(total_subs)
logger.info("Server ready — dashboard at http://%s:%d/dashboard", settings.host, settings.port)
yield
# ── Shutdown ──
logger.info("Shutting down...")
await observer.shutdown()
logger.info("Shutdown complete")
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
settings = Settings()
metrics = MetricsCollector()
app = FastAPI(
title=settings.app_title,
version=settings.app_version,
lifespan=lifespan,
)
# Store settings and metrics on app state (available before lifespan)
app.state.settings = settings
app.state.metrics = metrics
# ── Routers ──
app.include_router(devices_api_router)
app.include_router(coap_bridge_router)
app.include_router(dashboard_router)
# ── Prometheus /metrics ──
@app.get("/metrics", tags=["monitoring"], include_in_schema=False)
async def prometheus_metrics():
return Response(
content=metrics.generate(),
media_type="text/plain; version=0.0.4; charset=utf-8",
)
return app
# Module-level app instance for uvicorn
app = create_app()

131
server/app/metrics.py Normal file
View File

@@ -0,0 +1,131 @@
"""
Demeter Server — Prometheus Metrics
Defines gauges and counters for sensor readings, device status,
and CoAP communication. Exposes a /metrics endpoint for Prometheus
scraping.
Uses a dedicated CollectorRegistry to avoid conflicts when creating
multiple app instances (e.g., in tests).
"""
from __future__ import annotations
import logging
from typing import Any
from prometheus_client import (
CollectorRegistry,
Counter,
Gauge,
Info,
generate_latest,
)
logger = logging.getLogger(__name__)
class MetricsCollector:
"""Manages all Prometheus metrics for the Demeter server."""
def __init__(self, registry: CollectorRegistry | None = None) -> None:
self.registry = registry or CollectorRegistry()
# Server info
self.server_info = Info(
"demeter_server",
"Demeter IoT management server information",
registry=self.registry,
)
# Sensor readings (the core metric)
self.sensor_reading = Gauge(
"demeter_sensor_reading",
"Latest sensor reading value",
["device", "resource", "unit"],
registry=self.registry,
)
# Device online status
self.device_online = Gauge(
"demeter_device_online",
"Whether the device is online (1) or offline (0)",
["device"],
registry=self.registry,
)
# Last reading timestamp (unix epoch)
self.last_reading_ts = Gauge(
"demeter_last_reading_timestamp_seconds",
"Unix timestamp of the last reading received",
["device", "resource"],
registry=self.registry,
)
# CoAP request counters
self.coap_requests = Counter(
"demeter_coap_requests_total",
"Total CoAP requests/notifications received",
["device"],
registry=self.registry,
)
self.coap_errors = Counter(
"demeter_coap_errors_total",
"Total CoAP communication errors",
["device", "error_type"],
registry=self.registry,
)
# Active subscriptions
self.active_subscriptions = Gauge(
"demeter_active_subscriptions",
"Number of active CoAP Observe subscriptions",
registry=self.registry,
)
def set_server_info(self, version: str) -> None:
"""Set server version info."""
self.server_info.info({"version": version})
def update_sensor_metric(
self,
device_id: str,
resource_uri: str,
value: Any,
unit: str,
) -> None:
"""Update the sensor reading gauge from a notification."""
try:
numeric = float(value)
except (TypeError, ValueError):
# Non-numeric values (e.g., trigger state) — store as 0/1
numeric = float(value) if isinstance(value, (int, bool)) else 0.0
self.sensor_reading.labels(
device=device_id, resource=resource_uri, unit=unit
).set(numeric)
self.last_reading_ts.labels(
device=device_id, resource=resource_uri
).set_to_current_time()
def set_device_online(self, device_id: str, online: bool) -> None:
"""Update device online/offline status."""
self.device_online.labels(device=device_id).set(1 if online else 0)
def record_coap_request(self, device_id: str) -> None:
"""Increment CoAP request counter."""
self.coap_requests.labels(device=device_id).inc()
def record_coap_error(self, device_id: str, error_type: str) -> None:
"""Increment CoAP error counter."""
self.coap_errors.labels(device=device_id, error_type=error_type).inc()
def set_active_subscriptions(self, count: int) -> None:
"""Update the active subscription gauge."""
self.active_subscriptions.set(count)
def generate(self) -> bytes:
"""Generate Prometheus metrics output from this collector's registry."""
return generate_latest(self.registry)

View File

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

36
server/pyproject.toml Normal file
View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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