docs: add project description and server setup instructions to README
This commit is contained in:
@@ -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
|
||||||
BIN
docs/demeter-architecture.docx
Normal file
BIN
docs/demeter-architecture.docx
Normal file
Binary file not shown.
BIN
esp/.DS_Store
vendored
Normal file
BIN
esp/.DS_Store
vendored
Normal file
Binary file not shown.
77
esp/README.md
Normal file
77
esp/README.md
Normal 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 0–100% |
|
||||||
|
| `/sensors/temperature` | GET | Yes (periodic) | Temperature in °C |
|
||||||
|
| `/sensors/water_level` | GET | Yes (periodic) | Water level 0–100% |
|
||||||
|
| `/events/trigger` | GET | Yes (event-driven) | Digital input state change |
|
||||||
|
| `/device/info` | GET | No | Device metadata, uptime |
|
||||||
|
| `/config/interval` | GET, PUT | No | Read/set polling interval |
|
||||||
|
|
||||||
|
## Observe Behavior
|
||||||
|
|
||||||
|
- **Periodic sensors** (soil, temp, water): NON-confirmable notifications at configurable intervals. Only sent when value changes beyond a configurable threshold.
|
||||||
|
- **Trigger events**: CON-confirmable notifications sent immediately on GPIO state change via hardware interrupt.
|
||||||
|
- **Max observers**: 4 per resource, 8 total (configurable in `observe_manager.py`).
|
||||||
|
- **Deregistration**: Via Observe option value 1, or automatically on RST response.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Flash MicroPython to your ESP32/ESP8266
|
||||||
|
2. Edit `config.py` with your WiFi credentials, device ID, and pin assignments
|
||||||
|
3. Upload all files to the board (via `mpremote`, `ampy`, or Thonny)
|
||||||
|
4. The node starts automatically and listens on UDP port 5683
|
||||||
|
|
||||||
|
## Testing with aiocoap (from Demeter server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simple GET
|
||||||
|
aiocoap-client coap://ESP_IP/sensors/temperature
|
||||||
|
|
||||||
|
# Observe subscription
|
||||||
|
aiocoap-client coap://ESP_IP/sensors/soil_moisture --observe
|
||||||
|
|
||||||
|
# Set polling interval to 10 seconds
|
||||||
|
echo '{"interval": 10}' | aiocoap-client coap://ESP_IP/config/interval -m PUT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_observe.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changes from upstream microCoAPy
|
||||||
|
|
||||||
|
- Added `COAP_OBSERVE = 6` to option numbers
|
||||||
|
- Added `setObserve()`, `getObserveValue()`, `setMaxAge()`, `getUriPath()` to `CoapPacket`
|
||||||
|
- Added `ObserveManager` class for server-side observer tracking
|
||||||
|
- Added `notifyObservers()`, `observeGet()`, `observeCancel()` to `Coap`
|
||||||
|
- Modified `handleIncomingRequest()` to detect and handle Observe registrations
|
||||||
|
- Added RST handling to deregister observers
|
||||||
|
- Made `CoapOption` accept `str` input (CPython compatibility)
|
||||||
58
esp/config.py
Normal file
58
esp/config.py
Normal 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
339
esp/main.py
Normal 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()
|
||||||
7
esp/microcoapy/__init__.py
Normal file
7
esp/microcoapy/__init__.py
Normal 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
|
||||||
112
esp/microcoapy/coap_macros.py
Normal file
112
esp/microcoapy/coap_macros.py
Normal 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")
|
||||||
10
esp/microcoapy/coap_option.py
Normal file
10
esp/microcoapy/coap_option.py
Normal 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
|
||||||
83
esp/microcoapy/coap_packet.py
Normal file
83
esp/microcoapy/coap_packet.py
Normal 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
|
||||||
|
)
|
||||||
88
esp/microcoapy/coap_reader.py
Normal file
88
esp/microcoapy/coap_reader.py
Normal 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
|
||||||
67
esp/microcoapy/coap_writer.py
Normal file
67
esp/microcoapy/coap_writer.py
Normal 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)
|
||||||
512
esp/microcoapy/microcoapy.py
Normal file
512
esp/microcoapy/microcoapy.py
Normal 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
|
||||||
144
esp/microcoapy/observe_manager.py
Normal file
144
esp/microcoapy/observe_manager.py
Normal 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
223
esp/sensors.py
Normal 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
259
esp/tests/test_observe.py
Normal 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
1
server/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Demeter IoT Management Server."""
|
||||||
98
server/app/coap_bridge.py
Normal file
98
server/app/coap_bridge.py
Normal 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
313
server/app/coap_observer.py
Normal 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
99
server/app/config.py
Normal 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
101
server/app/dashboard.py
Normal 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
177
server/app/device_store.py
Normal 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
90
server/app/devices_api.py
Normal 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(),
|
||||||
|
}
|
||||||
64
server/app/logging_config.py
Normal file
64
server/app/logging_config.py
Normal 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
140
server/app/main.py
Normal 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
131
server/app/metrics.py
Normal 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)
|
||||||
39
server/config/devices.yaml
Normal file
39
server/config/devices.yaml
Normal 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
36
server/pyproject.toml
Normal 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"]
|
||||||
85
server/templates/base.html
Normal file
85
server/templates/base.html
Normal 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>
|
||||||
141
server/templates/dashboard.html
Normal file
141
server/templates/dashboard.html
Normal 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 }} · {{ 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 %}
|
||||||
164
server/templates/device_detail.html
Normal file
164
server/templates/device_detail.html
Normal 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
0
server/tests/__init__.py
Normal file
98
server/tests/conftest.py
Normal file
98
server/tests/conftest.py
Normal 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)
|
||||||
108
server/tests/test_device_store.py
Normal file
108
server/tests/test_device_store.py
Normal 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")
|
||||||
133
server/tests/test_devices_api.py
Normal file
133
server/tests/test_devices_api.py
Normal 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
|
||||||
Reference in New Issue
Block a user