docs: add project description and server setup instructions to README

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

BIN
esp/.DS_Store vendored Normal file

Binary file not shown.

77
esp/README.md Normal file
View File

@@ -0,0 +1,77 @@
# Demeter ESP - CoAP Sensor Node with Observe
CoAP (RFC 7252) sensor node firmware for ESP32/ESP8266 running MicroPython, with CoAP Observe (RFC 7641) support for real-time push notifications.
## Project Structure
```
demeter-esp/
├── microcoapy/ # Extended microCoAPy library
│ ├── __init__.py
│ ├── coap_macros.py # Constants + COAP_OBSERVE option number
│ ├── coap_option.py # CoapOption (str-compatible)
│ ├── coap_packet.py # CoapPacket + setObserve(), getObserveValue(), setMaxAge()
│ ├── coap_reader.py # Packet parser (unchanged)
│ ├── coap_writer.py # Packet serializer (unchanged)
│ ├── microcoapy.py # Main Coap class + Observe server/client methods
│ └── observe_manager.py # Observer registry with per-resource tracking
├── config.py # WiFi, device ID, pin assignments, thresholds
├── sensors.py # Hardware abstraction for analog/digital sensors
├── main.py # Entry point: CoAP server + sensor loop
└── tests/
└── test_observe.py # Observe extension tests (runs on CPython)
```
## CoAP Resources
| URI Path | Method | Observable | Description |
|----------|--------|------------|-------------|
| `/sensors/soil_moisture` | GET | Yes (periodic) | Soil moisture 0100% |
| `/sensors/temperature` | GET | Yes (periodic) | Temperature in °C |
| `/sensors/water_level` | GET | Yes (periodic) | Water level 0100% |
| `/events/trigger` | GET | Yes (event-driven) | Digital input state change |
| `/device/info` | GET | No | Device metadata, uptime |
| `/config/interval` | GET, PUT | No | Read/set polling interval |
## Observe Behavior
- **Periodic sensors** (soil, temp, water): NON-confirmable notifications at configurable intervals. Only sent when value changes beyond a configurable threshold.
- **Trigger events**: CON-confirmable notifications sent immediately on GPIO state change via hardware interrupt.
- **Max observers**: 4 per resource, 8 total (configurable in `observe_manager.py`).
- **Deregistration**: Via Observe option value 1, or automatically on RST response.
## Setup
1. Flash MicroPython to your ESP32/ESP8266
2. Edit `config.py` with your WiFi credentials, device ID, and pin assignments
3. Upload all files to the board (via `mpremote`, `ampy`, or Thonny)
4. The node starts automatically and listens on UDP port 5683
## Testing with aiocoap (from Demeter server)
```bash
# Simple GET
aiocoap-client coap://ESP_IP/sensors/temperature
# Observe subscription
aiocoap-client coap://ESP_IP/sensors/soil_moisture --observe
# Set polling interval to 10 seconds
echo '{"interval": 10}' | aiocoap-client coap://ESP_IP/config/interval -m PUT
```
## Running Tests
```bash
python tests/test_observe.py
```
## Changes from upstream microCoAPy
- Added `COAP_OBSERVE = 6` to option numbers
- Added `setObserve()`, `getObserveValue()`, `setMaxAge()`, `getUriPath()` to `CoapPacket`
- Added `ObserveManager` class for server-side observer tracking
- Added `notifyObservers()`, `observeGet()`, `observeCancel()` to `Coap`
- Modified `handleIncomingRequest()` to detect and handle Observe registrations
- Added RST handling to deregister observers
- Made `CoapOption` accept `str` input (CPython compatibility)

58
esp/config.py Normal file
View File

@@ -0,0 +1,58 @@
"""
Demeter ESP Sensor Node - Configuration
Edit this file for your specific deployment.
"""
# ── WiFi ──
WIFI_SSID = "YourSSID"
WIFI_PASS = "YourPassword"
# ── Device Identity ──
# Unique ID for this device (used in CoAP payloads and server registry)
DEVICE_ID = "esp32-plant-01"
FIRMWARE_VERSION = "0.1.0"
# ── CoAP Server ──
COAP_PORT = 5683
# ── Sensor Configuration ──
# Set pin numbers according to your wiring. Set to None to disable a sensor.
# Analog soil moisture sensor (capacitive recommended)
# Reads 0-4095 on ESP32 ADC, mapped to 0-100%
SOIL_MOISTURE_PIN = 34 # ADC1 channel (GPIO 34)
SOIL_MOISTURE_DRY = 3200 # ADC reading when completely dry
SOIL_MOISTURE_WET = 1400 # ADC reading when fully saturated
# Temperature sensor (DS18B20 OneWire or DHT22)
# Set TEMP_SENSOR_TYPE to "ds18b20" or "dht22"
TEMP_SENSOR_PIN = 4
TEMP_SENSOR_TYPE = "dht22" # "ds18b20" or "dht22"
# Water level sensor (analog)
WATER_LEVEL_PIN = 35 # ADC1 channel (GPIO 35)
WATER_LEVEL_MIN = 0 # ADC reading at minimum
WATER_LEVEL_MAX = 4095 # ADC reading at maximum
# Digital trigger input (e.g., float switch, door sensor)
TRIGGER_PIN = 5 # GPIO with internal pullup
TRIGGER_EDGE = "falling" # "rising", "falling", or "both"
# ── Timing ──
# Interval between sensor readings (seconds)
DEFAULT_POLL_INTERVAL = 30
# Observe notification: only send if value changed by more than threshold
SOIL_MOISTURE_THRESHOLD = 2.0 # percent
TEMPERATURE_THRESHOLD = 0.5 # degrees C
WATER_LEVEL_THRESHOLD = 2.0 # percent
# Max-Age for Observe notifications (seconds)
# Tells the observer how long the value is considered fresh
OBSERVE_MAX_AGE = 60
# ── Deep Sleep (for battery-operated nodes) ──
# Set to True for battery operation (disables Observe server, uses push model)
DEEP_SLEEP_ENABLED = False
DEEP_SLEEP_SECONDS = 300 # 5 minutes

339
esp/main.py Normal file
View File

@@ -0,0 +1,339 @@
"""
Demeter ESP Sensor Node - Main Entry Point
Runs a CoAP server with observable resources for sensor data.
Supports both periodic Observe notifications and event-driven
digital trigger notifications.
Resources:
GET /sensors/soil_moisture (observable, periodic)
GET /sensors/temperature (observable, periodic)
GET /sensors/water_level (observable, periodic)
GET /events/trigger (observable, event-driven)
GET /device/info (not observable)
GET,PUT /config/interval (not observable)
"""
try:
import network
import machine
except ImportError:
pass
try:
import time
except ImportError:
import utime as time
try:
import json
except ImportError:
import ujson as json
import microcoapy
from microcoapy import COAP_CONTENT_FORMAT, COAP_RESPONSE_CODE, COAP_TYPE
import config
from sensors import SensorManager
# ── WiFi Connection ──
def connect_wifi():
"""Connect to WiFi and return True on success."""
try:
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if wlan.isconnected():
print("[wifi] Already connected:", wlan.ifconfig())
return True
print("[wifi] Connecting to", config.WIFI_SSID, "...")
wlan.connect(config.WIFI_SSID, config.WIFI_PASS)
timeout = 15000 # 15 seconds
start = time.ticks_ms()
while not wlan.isconnected():
if time.ticks_diff(time.ticks_ms(), start) > timeout:
print("[wifi] Connection timeout")
return False
time.sleep_ms(100)
print("[wifi] Connected:", wlan.ifconfig())
return True
except NameError:
# Running on CPython (no network module)
print("[wifi] Skipped (not on ESP)")
return True
# ── Resource State ──
# Cached sensor values for Observe notifications
# Only notify if value changed beyond threshold
_state = {
"soil_moisture": None,
"temperature": None,
"water_level": None,
"trigger": 0,
"poll_interval": config.DEFAULT_POLL_INTERVAL,
"uptime_start": 0,
}
def _value_changed(key, new_value, threshold):
"""Check if a sensor value changed beyond threshold."""
old = _state.get(key)
if old is None or new_value is None:
return new_value is not None
return abs(new_value - old) >= threshold
# ── CoAP Resource Callbacks ──
# Callbacks that return (payload, content_format) are Observe-compatible.
# The server uses these to build the initial response and notifications.
def resource_soil_moisture(packet, sender_ip, sender_port):
"""GET /sensors/soil_moisture"""
val = sensors.read_soil_moisture()
payload = json.dumps({
"device": config.DEVICE_ID,
"soil_moisture": val,
"unit": "percent"
})
return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON)
def resource_temperature(packet, sender_ip, sender_port):
"""GET /sensors/temperature"""
val = sensors.read_temperature()
payload = json.dumps({
"device": config.DEVICE_ID,
"temperature": val,
"unit": "celsius"
})
return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON)
def resource_water_level(packet, sender_ip, sender_port):
"""GET /sensors/water_level"""
val = sensors.read_water_level()
payload = json.dumps({
"device": config.DEVICE_ID,
"water_level": val,
"unit": "percent"
})
return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON)
def resource_trigger(packet, sender_ip, sender_port):
"""GET /events/trigger"""
trigger_val, _ = sensors.read_trigger()
payload = json.dumps({
"device": config.DEVICE_ID,
"trigger": trigger_val,
"type": "digital"
})
return (payload, COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON)
def resource_device_info(packet, sender_ip, sender_port):
"""GET /device/info - non-observable device metadata"""
uptime = time.ticks_diff(time.ticks_ms(), _state["uptime_start"]) // 1000
payload = json.dumps({
"device": config.DEVICE_ID,
"firmware": config.FIRMWARE_VERSION,
"uptime_seconds": uptime,
"observers": server.observe.observer_count(),
"poll_interval": _state["poll_interval"],
})
# This callback sends its own response (non-observable pattern)
server.sendResponse(
sender_ip, sender_port, packet.messageid,
payload, COAP_RESPONSE_CODE.COAP_CONTENT,
COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, packet.token
)
return None # Signal that we handled the response ourselves
def resource_config_interval(packet, sender_ip, sender_port):
"""GET,PUT /config/interval - read or set the polling interval"""
from microcoapy.coap_macros import COAP_METHOD
if packet.method == COAP_METHOD.COAP_PUT:
# Parse new interval from payload
try:
new_val = json.loads(packet.payload.decode("utf-8"))
if isinstance(new_val, dict):
new_interval = int(new_val.get("interval", _state["poll_interval"]))
else:
new_interval = int(new_val)
new_interval = max(5, min(3600, new_interval)) # clamp 5s - 1hr
_state["poll_interval"] = new_interval
print("[config] Poll interval set to", new_interval, "seconds")
server.sendResponse(
sender_ip, sender_port, packet.messageid,
json.dumps({"interval": new_interval}),
COAP_RESPONSE_CODE.COAP_CHANGED,
COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, packet.token
)
except Exception as e:
print("[config] Invalid interval payload:", e)
server.sendResponse(
sender_ip, sender_port, packet.messageid,
None, COAP_RESPONSE_CODE.COAP_BAD_REQUEST,
COAP_CONTENT_FORMAT.COAP_NONE, packet.token
)
else:
# GET
payload = json.dumps({"interval": _state["poll_interval"]})
server.sendResponse(
sender_ip, sender_port, packet.messageid,
payload, COAP_RESPONSE_CODE.COAP_CONTENT,
COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON, packet.token
)
return None
# ── Main Loop ──
def run():
"""Main run loop: read sensors, notify observers, handle requests."""
global server, sensors
_state["uptime_start"] = time.ticks_ms()
last_read_time = 0
print("=" * 40)
print(" Demeter Sensor Node: " + config.DEVICE_ID)
print(" Firmware: " + config.FIRMWARE_VERSION)
print("=" * 40)
# Connect WiFi
if not connect_wifi():
print("[main] WiFi failed, halting")
return
# Initialize sensors
sensors = SensorManager(config)
sensors.init()
print("[main] Sensors initialized")
# Initialize CoAP server
server = microcoapy.Coap()
server.debug = True
# Register resource callbacks
server.addIncomingRequestCallback("sensors/soil_moisture", resource_soil_moisture)
server.addIncomingRequestCallback("sensors/temperature", resource_temperature)
server.addIncomingRequestCallback("sensors/water_level", resource_water_level)
server.addIncomingRequestCallback("events/trigger", resource_trigger)
server.addIncomingRequestCallback("device/info", resource_device_info)
server.addIncomingRequestCallback("config/interval", resource_config_interval)
# Start CoAP server
server.start(config.COAP_PORT)
print("[main] CoAP server started on port", config.COAP_PORT)
print("[main] Poll interval:", _state["poll_interval"], "seconds")
print("[main] Waiting for requests and observers...")
try:
while True:
# ── Process incoming CoAP requests (non-blocking, short poll) ──
server.poll(timeoutMs=200, pollPeriodMs=50)
now = time.ticks_ms()
# ── Check digital trigger (event-driven, immediate) ──
trigger_val, fired = sensors.read_trigger()
if fired:
_state["trigger"] = trigger_val
payload = json.dumps({
"device": config.DEVICE_ID,
"trigger": trigger_val,
"type": "digital",
"event": "state_change"
})
# CON for trigger events (reliable delivery)
sent = server.notifyObservers(
"events/trigger", payload,
content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON,
message_type=COAP_TYPE.COAP_CON,
max_age=config.OBSERVE_MAX_AGE
)
if sent > 0:
print("[main] Trigger event notified to", sent, "observers")
# ── Periodic sensor reading and notification ──
interval_ms = _state["poll_interval"] * 1000
if time.ticks_diff(now, last_read_time) >= interval_ms:
last_read_time = now
_read_and_notify_sensors()
except KeyboardInterrupt:
print("\n[main] Shutting down...")
finally:
server.stop()
print("[main] CoAP server stopped")
def _read_and_notify_sensors():
"""Read all periodic sensors and notify observers if values changed."""
cfg = config
max_age = cfg.OBSERVE_MAX_AGE
# Soil moisture
val = sensors.read_soil_moisture()
if val is not None and _value_changed("soil_moisture", val, cfg.SOIL_MOISTURE_THRESHOLD):
_state["soil_moisture"] = val
payload = json.dumps({
"device": cfg.DEVICE_ID,
"soil_moisture": val,
"unit": "percent"
})
server.notifyObservers(
"sensors/soil_moisture", payload,
content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON,
message_type=COAP_TYPE.COAP_NONCON,
max_age=max_age
)
# Temperature
val = sensors.read_temperature()
if val is not None and _value_changed("temperature", val, cfg.TEMPERATURE_THRESHOLD):
_state["temperature"] = val
payload = json.dumps({
"device": cfg.DEVICE_ID,
"temperature": val,
"unit": "celsius"
})
server.notifyObservers(
"sensors/temperature", payload,
content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON,
message_type=COAP_TYPE.COAP_NONCON,
max_age=max_age
)
# Water level
val = sensors.read_water_level()
if val is not None and _value_changed("water_level", val, cfg.WATER_LEVEL_THRESHOLD):
_state["water_level"] = val
payload = json.dumps({
"device": cfg.DEVICE_ID,
"water_level": val,
"unit": "percent"
})
server.notifyObservers(
"sensors/water_level", payload,
content_format=COAP_CONTENT_FORMAT.COAP_APPLICATION_JSON,
message_type=COAP_TYPE.COAP_NONCON,
max_age=max_age
)
# Globals (set in run())
server = None
sensors = None
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,7 @@
from .microcoapy import Coap
from .coap_macros import COAP_CONTENT_FORMAT
from .coap_macros import COAP_RESPONSE_CODE
from .coap_macros import COAP_TYPE
from .coap_macros import COAP_OPTION_NUMBER
from .coap_macros import COAP_OBSERVE_REGISTER
from .coap_macros import COAP_OBSERVE_DEREGISTER

View File

@@ -0,0 +1,112 @@
# Macros - Extended for Demeter with CoAP Observe (RFC 7641)
_COAP_HEADER_SIZE = 4
_COAP_OPTION_HEADER_SIZE = 1
_COAP_PAYLOAD_MARKER = 0xFF
_MAX_OPTION_NUM = 10
_BUF_MAX_SIZE = 1024
_COAP_DEFAULT_PORT = 5683
def enum(**enums):
return type('Enum', (), enums)
class CoapResponseCode:
@staticmethod
def encode(class_, detail):
return ((class_ << 5) | (detail))
@staticmethod
def decode(value):
class_ = (0xE0 & value) >> 5
detail = 0x1F & value
return (class_, detail)
COAP_VERSION = enum(
COAP_VERSION_UNSUPPORTED=0,
COAP_VERSION_1=1
)
COAP_TYPE = enum(
COAP_CON=0,
COAP_NONCON=1,
COAP_ACK=2,
COAP_RESET=3
)
COAP_METHOD = enum(
COAP_EMPTY_MESSAGE=0,
COAP_GET=1,
COAP_POST=2,
COAP_PUT=3,
COAP_DELETE=4
)
COAP_RESPONSE_CODE = enum(
COAP_CREATED=CoapResponseCode.encode(2, 1),
COAP_DELETED=CoapResponseCode.encode(2, 2),
COAP_VALID=CoapResponseCode.encode(2, 3),
COAP_CHANGED=CoapResponseCode.encode(2, 4),
COAP_CONTENT=CoapResponseCode.encode(2, 5),
COAP_BAD_REQUEST=CoapResponseCode.encode(4, 0),
COAP_UNAUTHORIZED=CoapResponseCode.encode(4, 1),
COAP_BAD_OPTION=CoapResponseCode.encode(4, 2),
COAP_FORBIDDEN=CoapResponseCode.encode(4, 3),
COAP_NOT_FOUND=CoapResponseCode.encode(4, 4),
COAP_METHOD_NOT_ALLOWD=CoapResponseCode.encode(4, 5),
COAP_NOT_ACCEPTABLE=CoapResponseCode.encode(4, 6),
COAP_PRECONDITION_FAILED=CoapResponseCode.encode(4, 12),
COAP_REQUEST_ENTITY_TOO_LARGE=CoapResponseCode.encode(4, 13),
COAP_UNSUPPORTED_CONTENT_FORMAT=CoapResponseCode.encode(4, 15),
COAP_INTERNAL_SERVER_ERROR=CoapResponseCode.encode(5, 0),
COAP_NOT_IMPLEMENTED=CoapResponseCode.encode(5, 1),
COAP_BAD_GATEWAY=CoapResponseCode.encode(5, 2),
COAP_SERVICE_UNAVALIABLE=CoapResponseCode.encode(5, 3),
COAP_GATEWAY_TIMEOUT=CoapResponseCode.encode(5, 4),
COAP_PROXYING_NOT_SUPPORTED=CoapResponseCode.encode(5, 5)
)
COAP_OPTION_NUMBER = enum(
COAP_IF_MATCH=1,
COAP_URI_HOST=3,
COAP_E_TAG=4,
COAP_IF_NONE_MATCH=5,
COAP_OBSERVE=6, # RFC 7641 - Observe
COAP_URI_PORT=7,
COAP_LOCATION_PATH=8,
COAP_URI_PATH=11,
COAP_CONTENT_FORMAT=12,
COAP_MAX_AGE=14,
COAP_URI_QUERY=15,
COAP_ACCEPT=17,
COAP_LOCATION_QUERY=20,
COAP_PROXY_URI=35,
COAP_PROXY_SCHEME=39
)
COAP_CONTENT_FORMAT = enum(
COAP_NONE=-1,
COAP_TEXT_PLAIN=0,
COAP_APPLICATION_LINK_FORMAT=40,
COAP_APPLICATION_XML=41,
COAP_APPLICATION_OCTET_STREAM=42,
COAP_APPLICATION_EXI=47,
COAP_APPLICATION_JSON=50,
COAP_APPLICATION_CBOR=60
)
# Observe option values (RFC 7641)
COAP_OBSERVE_REGISTER = 0
COAP_OBSERVE_DEREGISTER = 1
coapTypeToStringMap = {
COAP_TYPE.COAP_CON: 'CON',
COAP_TYPE.COAP_NONCON: 'NONCON',
COAP_TYPE.COAP_ACK: 'ACK',
COAP_TYPE.COAP_RESET: 'RESET'
}
def coapTypeToString(type):
return coapTypeToStringMap.get(type, "INVALID")

View File

@@ -0,0 +1,10 @@
class CoapOption:
def __init__(self, number=-1, buffer=None):
self.number = number
byteBuf = bytearray()
if buffer is not None:
if isinstance(buffer, str):
byteBuf.extend(buffer.encode("utf-8"))
else:
byteBuf.extend(buffer)
self.buffer = byteBuf

View File

@@ -0,0 +1,83 @@
from . import coap_macros as macros
from .coap_option import CoapOption
class CoapPacket:
def __init__(self):
self.version = macros.COAP_VERSION.COAP_VERSION_UNSUPPORTED
self.type = macros.COAP_TYPE.COAP_CON
self.method = macros.COAP_METHOD.COAP_GET
self.token = bytearray()
self.payload = bytearray()
self.messageid = 0
self.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE
self.query = bytearray()
self.options = []
def addOption(self, number, opt_payload):
if len(self.options) >= macros._MAX_OPTION_NUM:
return
self.options.append(CoapOption(number, opt_payload))
def setUriHost(self, address):
self.addOption(macros.COAP_OPTION_NUMBER.COAP_URI_HOST, address)
def setUriPath(self, url):
for subPath in url.split('/'):
self.addOption(macros.COAP_OPTION_NUMBER.COAP_URI_PATH, subPath)
def setObserve(self, value):
"""Set the Observe option (RFC 7641).
For requests:
value=0: register as observer
value=1: deregister
For notifications:
value=sequence number (24-bit, 0-16777215)
"""
if value < 256:
buf = bytearray([value & 0xFF])
elif value < 65536:
buf = bytearray([(value >> 8) & 0xFF, value & 0xFF])
else:
buf = bytearray([(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF])
self.addOption(macros.COAP_OPTION_NUMBER.COAP_OBSERVE, buf)
def setMaxAge(self, seconds):
"""Set the Max-Age option (seconds until resource is considered stale)."""
if seconds < 256:
buf = bytearray([seconds & 0xFF])
elif seconds < 65536:
buf = bytearray([(seconds >> 8) & 0xFF, seconds & 0xFF])
else:
buf = bytearray([
(seconds >> 24) & 0xFF, (seconds >> 16) & 0xFF,
(seconds >> 8) & 0xFF, seconds & 0xFF
])
self.addOption(macros.COAP_OPTION_NUMBER.COAP_MAX_AGE, buf)
def getObserveValue(self):
"""Extract the Observe option value from the packet, or None if absent."""
for opt in self.options:
if opt.number == macros.COAP_OPTION_NUMBER.COAP_OBSERVE:
val = 0
for b in opt.buffer:
val = (val << 8) | b
return val
return None
def getUriPath(self):
"""Reconstruct the URI path from URI_PATH options."""
parts = []
for opt in self.options:
if opt.number == macros.COAP_OPTION_NUMBER.COAP_URI_PATH and len(opt.buffer) > 0:
parts.append(opt.buffer.decode("utf-8"))
return "/".join(parts)
def toString(self):
class_, detail = macros.CoapResponseCode.decode(self.method)
return "type: {}, method: {}.{:02d}, messageid: {}, payload: {}".format(
macros.coapTypeToString(self.type), class_, detail,
self.messageid, self.payload
)

View File

@@ -0,0 +1,88 @@
from . import coap_macros as macros
from .coap_option import CoapOption
def parseOption(packet, runningDelta, buffer, i):
option = CoapOption()
headlen = 1
errorMessage = (False, runningDelta, i)
if buffer is None:
return errorMessage
buflen = len(buffer) - i
if buflen < headlen:
return errorMessage
delta = (buffer[i] & 0xF0) >> 4
length = buffer[i] & 0x0F
if delta == 15 or length == 15:
return errorMessage
if delta == 13:
headlen += 1
if buflen < headlen:
return errorMessage
delta = buffer[i + 1] + 13
i += 1
elif delta == 14:
headlen += 2
if buflen < headlen:
return errorMessage
delta = ((buffer[i + 1] << 8) | buffer[i + 2]) + 269
i += 2
if length == 13:
headlen += 1
if buflen < headlen:
return errorMessage
length = buffer[i + 1] + 13
i += 1
elif length == 14:
headlen += 2
if buflen < headlen:
return errorMessage
length = ((buffer[i + 1] << 8) | buffer[i + 2]) + 269
i += 2
endOfOptionIndex = (i + 1 + length)
if endOfOptionIndex > len(buffer):
return errorMessage
option.number = delta + runningDelta
option.buffer = buffer[i + 1:i + 1 + length]
packet.options.append(option)
return (True, runningDelta + delta, endOfOptionIndex)
def parsePacketHeaderInfo(buffer, packet):
packet.version = (buffer[0] & 0xC0) >> 6
packet.type = (buffer[0] & 0x30) >> 4
packet.tokenLength = buffer[0] & 0x0F
packet.method = buffer[1]
packet.messageid = 0xFF00 & (buffer[2] << 8)
packet.messageid |= 0x00FF & buffer[3]
def parsePacketOptionsAndPayload(buffer, packet):
bufferLen = len(buffer)
if (macros._COAP_HEADER_SIZE + packet.tokenLength) < bufferLen:
delta = 0
bufferIndex = macros._COAP_HEADER_SIZE + packet.tokenLength
while (len(packet.options) < macros._MAX_OPTION_NUM) and \
(bufferIndex < bufferLen) and \
(buffer[bufferIndex] != 0xFF):
(status, delta, bufferIndex) = parseOption(packet, delta, buffer, bufferIndex)
if status is False:
return False
if ((bufferIndex + 1) < bufferLen) and (buffer[bufferIndex] == 0xFF):
packet.payload = buffer[bufferIndex + 1:]
else:
packet.payload = None
return True

View File

@@ -0,0 +1,67 @@
from .coap_macros import _BUF_MAX_SIZE
from .coap_macros import COAP_VERSION
def CoapOptionDelta(v):
if v < 13:
return (0xFF & v)
elif v <= 0xFF + 13:
return 13
else:
return 14
def writePacketHeaderInfo(buffer, packet):
buffer.append(COAP_VERSION.COAP_VERSION_1 << 6)
buffer[0] |= (packet.type & 0x03) << 4
tokenLength = 0
if (packet.token is not None) and (len(packet.token) <= 0x0F):
tokenLength = len(packet.token)
buffer[0] |= (tokenLength & 0x0F)
buffer.append(packet.method)
buffer.append(packet.messageid >> 8)
buffer.append(packet.messageid & 0xFF)
if tokenLength > 0:
buffer.extend(packet.token)
def writePacketOptions(buffer, packet):
runningDelta = 0
for opt in sorted(packet.options, key=lambda x: x.number):
if (opt is None) or (opt.buffer is None) or (len(opt.buffer) == 0):
continue
optBufferLen = len(opt.buffer)
if (len(buffer) + 5 + optBufferLen) >= _BUF_MAX_SIZE:
return 0
optdelta = opt.number - runningDelta
delta = CoapOptionDelta(optdelta)
length = CoapOptionDelta(optBufferLen)
buffer.append(0xFF & (delta << 4 | length))
if delta == 13:
buffer.append(optdelta - 13)
elif delta == 14:
buffer.append((optdelta - 269) >> 8)
buffer.append(0xFF & (optdelta - 269))
if length == 13:
buffer.append(optBufferLen - 13)
elif length == 14:
buffer.append(optBufferLen >> 8)
buffer.append(0xFF & (optBufferLen - 269))
buffer.extend(opt.buffer)
runningDelta = opt.number
def writePacketPayload(buffer, packet):
if (packet.payload is not None) and (len(packet.payload)):
if (len(buffer) + 1 + len(packet.payload)) >= _BUF_MAX_SIZE:
return 0
buffer.append(0xFF)
buffer.extend(packet.payload)

View File

@@ -0,0 +1,512 @@
"""
microCoAPy - Extended for Demeter with CoAP Observe (RFC 7641)
Changes from upstream microCoAPy:
- Observer registration/deregistration on incoming GET with Option 6
- notifyObservers() method for server-side Observe notifications
- observeGet() client method for subscribing to observable resources
- RST handling to remove observers
- Per-resource Max-Age support in notifications
"""
try:
import socket
except ImportError:
import usocket as socket
try:
import os
except ImportError:
import uos as os
try:
import time
except ImportError:
import utime as time
import binascii
from . import coap_macros as macros
from .coap_packet import CoapPacket
from .coap_reader import parsePacketHeaderInfo
from .coap_reader import parsePacketOptionsAndPayload
from .coap_writer import writePacketHeaderInfo
from .coap_writer import writePacketOptions
from .coap_writer import writePacketPayload
from .observe_manager import ObserveManager
class Coap:
TRANSMISSION_STATE = macros.enum(
STATE_IDLE=0,
STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA=1
)
def __init__(self):
self.debug = True
self.sock = None
self.callbacks = {}
self.responseCallback = None
self.port = 0
self.isServer = False
self.state = self.TRANSMISSION_STATE.STATE_IDLE
self.isCustomSocket = False
# Observe manager (RFC 7641)
self.observe = ObserveManager(debug=True)
# Beta flags
self.discardRetransmissions = False
self.lastPacketStr = ""
def log(self, s):
if self.debug:
print("[microcoapy]: " + s)
# ── Socket Management ──
def start(self, port=macros._COAP_DEFAULT_PORT):
"""Create and bind a UDP socket."""
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("", port))
def stop(self):
"""Close the socket."""
if self.sock is not None:
self.sock.close()
self.sock = None
def setCustomSocket(self, custom_socket):
"""Use a custom UDP socket implementation."""
self.stop()
self.isCustomSocket = True
self.sock = custom_socket
# ── Callback Registration ──
def addIncomingRequestCallback(self, requestUrl, callback):
"""Register a callback for incoming requests to a URL.
The callback signature is:
callback(packet, senderIp, senderPort)
For observable resources, the callback should return a tuple:
(payload_str, content_format)
This allows notifyObservers to retrieve the current value.
If the callback returns None, the server handles it as before
(callback is responsible for sending its own response).
"""
self.callbacks[requestUrl] = callback
self.isServer = True
# ── Packet Sending ──
def sendPacket(self, ip, port, coapPacket):
"""Serialize and send a CoAP packet."""
if coapPacket.content_format != macros.COAP_CONTENT_FORMAT.COAP_NONE:
optionBuffer = bytearray(2)
optionBuffer[0] = (coapPacket.content_format & 0xFF00) >> 8
optionBuffer[1] = coapPacket.content_format & 0x00FF
coapPacket.addOption(
macros.COAP_OPTION_NUMBER.COAP_CONTENT_FORMAT, optionBuffer
)
if (coapPacket.query is not None) and (len(coapPacket.query) > 0):
coapPacket.addOption(
macros.COAP_OPTION_NUMBER.COAP_URI_QUERY, coapPacket.query
)
buffer = bytearray()
writePacketHeaderInfo(buffer, coapPacket)
writePacketOptions(buffer, coapPacket)
writePacketPayload(buffer, coapPacket)
status = 0
try:
sockaddr = (ip, port)
try:
sockaddr = socket.getaddrinfo(ip, port)[0][-1]
except Exception:
pass
status = self.sock.sendto(buffer, sockaddr)
if status > 0:
status = coapPacket.messageid
self.log("Packet sent. messageid: " + str(status))
except Exception as e:
status = 0
print("Exception while sending packet...")
import sys
sys.print_exception(e)
return status
def send(self, ip, port, url, type, method, token, payload, content_format, query_option):
"""Build and send a CoAP request."""
packet = CoapPacket()
packet.type = type
packet.method = method
packet.token = token
packet.payload = payload
packet.content_format = content_format
packet.query = query_option
return self.sendEx(ip, port, url, packet)
def sendEx(self, ip, port, url, packet):
"""Send a packet with auto-generated message ID and URI options."""
self.state = self.TRANSMISSION_STATE.STATE_IDLE
randBytes = os.urandom(2)
packet.messageid = (randBytes[0] << 8) | randBytes[1]
packet.setUriHost(ip)
packet.setUriPath(url)
return self.sendPacket(ip, port, packet)
def sendResponse(self, ip, port, messageid, payload, method, content_format, token):
"""Send a response (ACK) packet."""
packet = CoapPacket()
packet.type = macros.COAP_TYPE.COAP_ACK
packet.method = method
packet.token = token
packet.payload = payload
packet.messageid = messageid
packet.content_format = content_format
return self.sendPacket(ip, port, packet)
# ── Client Methods (Confirmable) ──
def get(self, ip, port, url, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_GET,
token, None, macros.COAP_CONTENT_FORMAT.COAP_NONE, None
)
def put(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_PUT,
token, payload, content_format, query_option
)
def post(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_POST,
token, payload, content_format, query_option
)
# ── Client Methods (Non-Confirmable) ──
def getNonConf(self, ip, port, url, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_GET,
token, None, macros.COAP_CONTENT_FORMAT.COAP_NONE, None
)
def putNonConf(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_PUT,
token, payload, content_format, query_option
)
def postNonConf(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_POST,
token, payload, content_format, query_option
)
# ── Observe Client Methods (RFC 7641) ──
def observeGet(self, ip, port, url, token=None):
"""Send a GET with Observe option 0 (register) to subscribe to a resource.
Args:
ip: Server IP
port: Server port
url: Resource URI path
token: Token for matching responses (auto-generated if None)
Returns:
Message ID on success, 0 on failure.
"""
if token is None:
token = bytearray(os.urandom(4))
packet = CoapPacket()
packet.type = macros.COAP_TYPE.COAP_CON
packet.method = macros.COAP_METHOD.COAP_GET
packet.token = token
packet.payload = None
packet.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE
# Set Observe = 0 (register)
packet.setObserve(macros.COAP_OBSERVE_REGISTER)
return self.sendEx(ip, port, url, packet)
def observeCancel(self, ip, port, url, token=bytearray()):
"""Send a GET with Observe option 1 (deregister) to cancel observation.
Returns:
Message ID on success, 0 on failure.
"""
packet = CoapPacket()
packet.type = macros.COAP_TYPE.COAP_CON
packet.method = macros.COAP_METHOD.COAP_GET
packet.token = token
packet.payload = None
packet.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE
# Set Observe = 1 (deregister)
packet.setObserve(macros.COAP_OBSERVE_DEREGISTER)
return self.sendEx(ip, port, url, packet)
# ── Observe Server Methods (RFC 7641) ──
def notifyObservers(self, resource_url, payload, content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE,
message_type=macros.COAP_TYPE.COAP_NONCON, max_age=None):
"""Send an Observe notification to all observers of a resource.
Args:
resource_url: The resource URI path (must match what observers subscribed to)
payload: The current resource representation (str or bytes)
content_format: Content format of the payload
message_type: COAP_NON for periodic data, COAP_CON for critical events
max_age: Optional Max-Age in seconds (freshness lifetime)
Returns:
Number of notifications sent successfully.
"""
if not self.observe.has_observers(resource_url):
return 0
observers = self.observe.get_observers(resource_url)
seq = self.observe.next_sequence(resource_url)
sent = 0
for obs in observers:
packet = CoapPacket()
packet.type = message_type
packet.method = macros.COAP_RESPONSE_CODE.COAP_CONTENT
packet.token = obs["token"]
packet.content_format = content_format
if isinstance(payload, str):
packet.payload = bytearray(payload.encode("utf-8"))
elif payload is not None:
packet.payload = bytearray(payload)
else:
packet.payload = bytearray()
# Generate message ID
randBytes = os.urandom(2)
packet.messageid = (randBytes[0] << 8) | randBytes[1]
# Add Observe sequence number
packet.setObserve(seq)
# Add Max-Age if specified
if max_age is not None:
packet.setMaxAge(max_age)
status = self.sendPacket(obs["ip"], obs["port"], packet)
if status > 0:
sent += 1
else:
self.log("Failed to notify observer {}:{}".format(obs["ip"], obs["port"]))
self.log("Notified {}/{} observers of {} (seq={})".format(
sent, len(observers), resource_url, seq
))
return sent
# ── Incoming Request Handling ──
def handleIncomingRequest(self, requestPacket, sourceIp, sourcePort):
"""Handle an incoming CoAP request, including Observe registration."""
url = requestPacket.getUriPath()
urlCallback = None
if url != "":
urlCallback = self.callbacks.get(url)
if urlCallback is None:
if self.responseCallback:
return False
print("Callback for url [", url, "] not found")
self.sendResponse(
sourceIp, sourcePort, requestPacket.messageid,
None, macros.COAP_RESPONSE_CODE.COAP_NOT_FOUND,
macros.COAP_CONTENT_FORMAT.COAP_NONE, requestPacket.token,
)
return True
# Check for Observe option in GET requests (RFC 7641)
if requestPacket.method == macros.COAP_METHOD.COAP_GET:
observeValue = requestPacket.getObserveValue()
if observeValue == macros.COAP_OBSERVE_REGISTER:
# Register observer
registered = self.observe.register(
url, sourceIp, sourcePort, requestPacket.token
)
if registered:
# Send initial response with Observe option (sequence 0)
result = urlCallback(requestPacket, sourceIp, sourcePort)
if result is not None:
payload_str, cf = result
response = CoapPacket()
response.type = macros.COAP_TYPE.COAP_ACK
response.method = macros.COAP_RESPONSE_CODE.COAP_CONTENT
response.token = requestPacket.token
response.messageid = requestPacket.messageid
response.content_format = cf
if isinstance(payload_str, str):
response.payload = bytearray(payload_str.encode("utf-8"))
elif payload_str is not None:
response.payload = bytearray(payload_str)
# Include Observe option with sequence 0 in initial response
response.setObserve(self.observe.next_sequence(url))
self.sendPacket(sourceIp, sourcePort, response)
# else: callback handled its own response
return True
else:
# Registration failed (limits exceeded) — respond without Observe
self.log("Observer registration failed for {}:{} on {}".format(
sourceIp, sourcePort, url
))
# Fall through to normal callback handling
elif observeValue == macros.COAP_OBSERVE_DEREGISTER:
# Deregister observer
self.observe.deregister(url, sourceIp, sourcePort)
# Fall through to normal GET response
# Normal (non-observe) request handling
urlCallback(requestPacket, sourceIp, sourcePort)
return True
# ── Socket Reading ──
def readBytesFromSocket(self, numOfBytes):
try:
return self.sock.recvfrom(numOfBytes)
except Exception:
return (None, None)
def parsePacketToken(self, buffer, packet):
if packet.tokenLength == 0:
packet.token = None
elif packet.tokenLength <= 8:
packet.token = buffer[4: 4 + packet.tokenLength]
else:
(tempBuffer, tempRemoteAddress) = self.readBytesFromSocket(
macros._BUF_MAX_SIZE
)
if tempBuffer is not None:
buffer.extend(tempBuffer)
return False
return True
# ── Main Loop ──
def loop(self, blocking=True):
"""Process one incoming packet.
Returns True if a packet was processed, False otherwise.
"""
if self.sock is None:
return False
self.sock.setblocking(blocking)
(buffer, remoteAddress) = self.readBytesFromSocket(macros._BUF_MAX_SIZE)
self.sock.setblocking(True)
while (buffer is not None) and (len(buffer) > 0):
bufferLen = len(buffer)
if (bufferLen < macros._COAP_HEADER_SIZE) or \
(((buffer[0] & 0xC0) >> 6) != 1):
(tempBuffer, tempRemoteAddress) = self.readBytesFromSocket(
macros._BUF_MAX_SIZE - bufferLen
)
if tempBuffer is not None:
buffer.extend(tempBuffer)
continue
packet = CoapPacket()
self.log("Incoming bytes: " + str(binascii.hexlify(bytearray(buffer))))
parsePacketHeaderInfo(buffer, packet)
if not self.parsePacketToken(buffer, packet):
continue
if not parsePacketOptionsAndPayload(buffer, packet):
return False
# Handle RST — deregister observer (RFC 7641 §3.6)
if packet.type == macros.COAP_TYPE.COAP_RESET:
self.log("RST received, deregistering any observer with matching token")
for res_url in self.observe.get_all_resources():
self.observe.deregister_by_token(res_url, packet.token)
return True
# Beta: discard retransmissions
if self.discardRetransmissions:
if packet.toString() == self.lastPacketStr:
self.log("Discarded retransmission: " + packet.toString())
return False
else:
self.lastPacketStr = packet.toString()
if not self.isServer or not self.handleIncomingRequest(
packet, remoteAddress[0], remoteAddress[1]
):
# Separate response handling (RFC 7252 §5.2.2)
if (packet.type == macros.COAP_TYPE.COAP_ACK and
packet.method == macros.COAP_METHOD.COAP_EMPTY_MESSAGE):
self.state = self.TRANSMISSION_STATE.STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA
return False
else:
if self.state == self.TRANSMISSION_STATE.STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA:
self.state = self.TRANSMISSION_STATE.STATE_IDLE
self.sendResponse(
remoteAddress[0], remoteAddress[1],
packet.messageid, None,
macros.COAP_TYPE.COAP_ACK,
macros.COAP_CONTENT_FORMAT.COAP_NONE,
packet.token,
)
if self.responseCallback is not None:
self.responseCallback(packet, remoteAddress)
return True
return False
def poll(self, timeoutMs=-1, pollPeriodMs=500):
"""Poll for incoming packets for up to timeoutMs milliseconds."""
start_time = time.ticks_ms()
status = False
while not status:
status = self.loop(False)
if time.ticks_diff(time.ticks_ms(), start_time) >= timeoutMs:
break
time.sleep_ms(pollPeriodMs)
return status

View File

@@ -0,0 +1,144 @@
"""
CoAP Observe Manager (RFC 7641)
Manages observer registrations for CoAP resources on the ESP server side.
Each resource can have multiple observers. When the resource state changes,
all registered observers receive a notification.
Observer entry structure:
{
"ip": str, # Observer IP address
"port": int, # Observer UDP port
"token": bytearray, # Token from the original GET request (for matching)
}
"""
try:
import time
except ImportError:
import utime as time
class ObserveManager:
"""Manages CoAP Observe subscriptions per resource URI."""
# Maximum observers per resource (memory constrained on ESP)
MAX_OBSERVERS_PER_RESOURCE = 4
# Maximum total observers across all resources
MAX_TOTAL_OBSERVERS = 8
def __init__(self, debug=True):
# Dict of resource_url -> list of observer entries
self._observers = {}
# Per-resource sequence counter (24-bit, wraps at 0xFFFFFF)
self._sequence = {}
self.debug = debug
def log(self, s):
if self.debug:
print("[observe]: " + s)
def register(self, resource_url, ip, port, token):
"""Register an observer for a resource.
Returns True if successfully registered, False if limits exceeded.
"""
if resource_url not in self._observers:
self._observers[resource_url] = []
self._sequence[resource_url] = 0
observers = self._observers[resource_url]
# Check if this observer is already registered (same ip+port+token)
for obs in observers:
if obs["ip"] == ip and obs["port"] == port:
# Update the token (re-registration)
obs["token"] = token
self.log("Re-registered observer {}:{} for {}".format(ip, port, resource_url))
return True
# Check limits
total = sum(len(v) for v in self._observers.values())
if total >= self.MAX_TOTAL_OBSERVERS:
self.log("Max total observers reached, rejecting registration")
return False
if len(observers) >= self.MAX_OBSERVERS_PER_RESOURCE:
self.log("Max observers for {} reached, rejecting".format(resource_url))
return False
observers.append({
"ip": ip,
"port": port,
"token": token,
})
self.log("Registered observer {}:{} for {} (token={})".format(
ip, port, resource_url, token
))
return True
def deregister(self, resource_url, ip, port):
"""Remove an observer for a resource."""
if resource_url not in self._observers:
return
observers = self._observers[resource_url]
self._observers[resource_url] = [
obs for obs in observers
if not (obs["ip"] == ip and obs["port"] == port)
]
self.log("Deregistered observer {}:{} from {}".format(ip, port, resource_url))
def deregister_by_token(self, resource_url, token):
"""Remove an observer by token (used when RST is received)."""
if resource_url not in self._observers:
return
observers = self._observers[resource_url]
self._observers[resource_url] = [
obs for obs in observers
if obs["token"] != token
]
def deregister_all(self, resource_url):
"""Remove all observers for a resource."""
if resource_url in self._observers:
del self._observers[resource_url]
del self._sequence[resource_url]
def get_observers(self, resource_url):
"""Get the list of observers for a resource."""
return self._observers.get(resource_url, [])
def has_observers(self, resource_url):
"""Check if a resource has any registered observers."""
return len(self._observers.get(resource_url, [])) > 0
def next_sequence(self, resource_url):
"""Get and increment the sequence number for a resource (24-bit wrap)."""
if resource_url not in self._sequence:
self._sequence[resource_url] = 0
seq = self._sequence[resource_url]
self._sequence[resource_url] = (seq + 1) & 0xFFFFFF
return seq
def get_all_resources(self):
"""Get all resource URLs that have observers."""
return list(self._observers.keys())
def observer_count(self, resource_url=None):
"""Get observer count. If resource_url is None, returns total count."""
if resource_url:
return len(self._observers.get(resource_url, []))
return sum(len(v) for v in self._observers.values())
def summary(self):
"""Return a summary string of all observer registrations."""
parts = []
for url, observers in self._observers.items():
addrs = ["{}:{}".format(o["ip"], o["port"]) for o in observers]
parts.append("{} -> [{}]".format(url, ", ".join(addrs)))
return "; ".join(parts) if parts else "(no observers)"

223
esp/sensors.py Normal file
View File

@@ -0,0 +1,223 @@
"""
Demeter ESP Sensor Node - Sensor Hardware Abstraction
Reads analog/digital sensors and returns normalized values.
Designed for ESP32 but with ESP8266 fallback.
Usage:
sensors = SensorManager(config)
sensors.init()
reading = sensors.read_soil_moisture() # returns 0.0 - 100.0
"""
try:
from machine import Pin, ADC
except ImportError:
# Running on CPython for testing
class Pin:
IN = 0
PULL_UP = 1
IRQ_FALLING = 2
IRQ_RISING = 1
IRQ_BOTH = 3
def __init__(self, *a, **kw): pass
def value(self): return 0
def irq(self, **kw): pass
class ADC:
ATTN_11DB = 3
WIDTH_12BIT = 3
def __init__(self, pin): pass
def atten(self, a): pass
def width(self, w): pass
def read(self): return 2048
try:
import json
except ImportError:
import ujson as json
def _clamp(value, lo, hi):
return max(lo, min(hi, value))
def _map_range(value, in_min, in_max, out_min, out_max):
"""Map a value from one range to another, clamped."""
if in_max == in_min:
return out_min
mapped = (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
return _clamp(mapped, min(out_min, out_max), max(out_min, out_max))
class SensorManager:
"""Manages all sensors for a Demeter node."""
def __init__(self, config):
self.config = config
self._soil_adc = None
self._water_adc = None
self._trigger_pin = None
self._temp_sensor = None
self._trigger_fired = False
self._trigger_value = 0
def init(self):
"""Initialize all configured sensor hardware."""
cfg = self.config
# Soil moisture (analog)
if cfg.SOIL_MOISTURE_PIN is not None:
pin = Pin(cfg.SOIL_MOISTURE_PIN)
self._soil_adc = ADC(pin)
try:
self._soil_adc.atten(ADC.ATTN_11DB) # Full 3.3V range
self._soil_adc.width(ADC.WIDTH_12BIT) # 0-4095
except Exception:
pass # ESP8266 doesn't support these
# Water level (analog)
if cfg.WATER_LEVEL_PIN is not None:
pin = Pin(cfg.WATER_LEVEL_PIN)
self._water_adc = ADC(pin)
try:
self._water_adc.atten(ADC.ATTN_11DB)
self._water_adc.width(ADC.WIDTH_12BIT)
except Exception:
pass
# Temperature sensor
if cfg.TEMP_SENSOR_PIN is not None:
self._init_temp_sensor()
# Digital trigger with interrupt
if cfg.TRIGGER_PIN is not None:
self._trigger_pin = Pin(cfg.TRIGGER_PIN, Pin.IN, Pin.PULL_UP)
self._trigger_value = self._trigger_pin.value()
edge = Pin.IRQ_FALLING
if cfg.TRIGGER_EDGE == "rising":
edge = Pin.IRQ_RISING
elif cfg.TRIGGER_EDGE == "both":
edge = Pin.IRQ_FALLING | Pin.IRQ_RISING
self._trigger_pin.irq(trigger=edge, handler=self._trigger_isr)
def _init_temp_sensor(self):
"""Initialize temperature sensor based on config type."""
cfg = self.config
try:
if cfg.TEMP_SENSOR_TYPE == "dht22":
import dht
self._temp_sensor = dht.DHT22(Pin(cfg.TEMP_SENSOR_PIN))
elif cfg.TEMP_SENSOR_TYPE == "ds18b20":
import onewire
import ds18x20
ow = onewire.OneWire(Pin(cfg.TEMP_SENSOR_PIN))
self._temp_sensor = ds18x20.DS18X20(ow)
roms = self._temp_sensor.scan()
if roms:
self._ds18b20_rom = roms[0]
else:
print("[sensors] No DS18B20 found on pin", cfg.TEMP_SENSOR_PIN)
self._temp_sensor = None
except ImportError:
print("[sensors] Temperature sensor driver not available")
self._temp_sensor = None
def _trigger_isr(self, pin):
"""ISR for digital trigger - just sets a flag (no CoAP inside ISR)."""
self._trigger_fired = True
self._trigger_value = pin.value()
# ── Reading Methods ──
def read_soil_moisture(self):
"""Read soil moisture as a percentage (0.0 = dry, 100.0 = wet).
Returns None if sensor is not configured.
"""
if self._soil_adc is None:
return None
raw = self._soil_adc.read()
cfg = self.config
return round(_map_range(raw, cfg.SOIL_MOISTURE_DRY, cfg.SOIL_MOISTURE_WET, 0.0, 100.0), 1)
def read_temperature(self):
"""Read temperature in Celsius.
Returns None if sensor is not configured or read fails.
"""
if self._temp_sensor is None:
return None
cfg = self.config
try:
if cfg.TEMP_SENSOR_TYPE == "dht22":
self._temp_sensor.measure()
return round(self._temp_sensor.temperature(), 1)
elif cfg.TEMP_SENSOR_TYPE == "ds18b20":
self._temp_sensor.convert_temp()
# DS18B20 needs ~750ms conversion time
import time
time.sleep_ms(750)
return round(self._temp_sensor.read_temp(self._ds18b20_rom), 1)
except Exception as e:
print("[sensors] Temperature read error:", e)
return None
def read_water_level(self):
"""Read water level as a percentage (0.0 = empty, 100.0 = full).
Returns None if sensor is not configured.
"""
if self._water_adc is None:
return None
raw = self._water_adc.read()
cfg = self.config
return round(_map_range(raw, cfg.WATER_LEVEL_MIN, cfg.WATER_LEVEL_MAX, 0.0, 100.0), 1)
def read_trigger(self):
"""Read the current digital trigger pin state.
Returns (value, fired) where:
value: current pin state (0 or 1)
fired: True if the ISR has fired since last call (clears flag)
"""
if self._trigger_pin is None:
return (0, False)
fired = self._trigger_fired
self._trigger_fired = False
return (self._trigger_value, fired)
def read_all_json(self):
"""Read all sensors and return a JSON string.
Example output:
{
"device": "esp32-plant-01",
"soil_moisture": 45.2,
"temperature": 24.1,
"water_level": 78.5,
"trigger": 0
}
"""
data = {"device": self.config.DEVICE_ID}
val = self.read_soil_moisture()
if val is not None:
data["soil_moisture"] = val
val = self.read_temperature()
if val is not None:
data["temperature"] = val
val = self.read_water_level()
if val is not None:
data["water_level"] = val
trigger_val, _ = self.read_trigger()
data["trigger"] = trigger_val
return json.dumps(data)

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

@@ -0,0 +1,259 @@
"""
Tests for microCoAPy Observe Extension
Run with: python -m pytest tests/test_observe.py -v
Or simply: python tests/test_observe.py
"""
import sys
import os
# Add parent directory to path so we can import microcoapy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from microcoapy.coap_macros import (
COAP_OPTION_NUMBER, COAP_TYPE, COAP_METHOD, COAP_RESPONSE_CODE,
COAP_CONTENT_FORMAT, COAP_OBSERVE_REGISTER, COAP_OBSERVE_DEREGISTER,
)
from microcoapy.coap_packet import CoapPacket
from microcoapy.coap_writer import writePacketHeaderInfo, writePacketOptions, writePacketPayload
from microcoapy.coap_reader import parsePacketHeaderInfo, parsePacketOptionsAndPayload
from microcoapy.observe_manager import ObserveManager
def test_observe_option_encode_single_byte():
"""Observe value < 256 should encode as 1 byte."""
pkt = CoapPacket()
pkt.setObserve(0)
obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
assert len(obs_opts) == 1
assert obs_opts[0].buffer == bytearray([0])
print("PASS: observe option encode single byte (value=0)")
pkt2 = CoapPacket()
pkt2.setObserve(42)
obs_opts2 = [o for o in pkt2.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
assert obs_opts2[0].buffer == bytearray([42])
print("PASS: observe option encode single byte (value=42)")
def test_observe_option_encode_two_bytes():
"""Observe value 256-65535 should encode as 2 bytes."""
pkt = CoapPacket()
pkt.setObserve(1000)
obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
assert len(obs_opts) == 1
buf = obs_opts[0].buffer
assert len(buf) == 2
val = (buf[0] << 8) | buf[1]
assert val == 1000
print("PASS: observe option encode two bytes (value=1000)")
def test_observe_option_encode_three_bytes():
"""Observe value 65536+ should encode as 3 bytes."""
pkt = CoapPacket()
pkt.setObserve(100000)
obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
buf = obs_opts[0].buffer
assert len(buf) == 3
val = (buf[0] << 16) | (buf[1] << 8) | buf[2]
assert val == 100000
print("PASS: observe option encode three bytes (value=100000)")
def test_get_observe_value():
"""getObserveValue() should decode the Observe option."""
pkt = CoapPacket()
assert pkt.getObserveValue() is None
print("PASS: getObserveValue returns None when no option set")
pkt.setObserve(0)
assert pkt.getObserveValue() == 0
print("PASS: getObserveValue returns 0 (register)")
pkt2 = CoapPacket()
pkt2.setObserve(1)
assert pkt2.getObserveValue() == 1
print("PASS: getObserveValue returns 1 (deregister)")
pkt3 = CoapPacket()
pkt3.setObserve(65000)
assert pkt3.getObserveValue() == 65000
print("PASS: getObserveValue returns 65000")
def test_packet_roundtrip_with_observe():
"""A packet with Observe option should survive encode -> decode."""
pkt = CoapPacket()
pkt.type = COAP_TYPE.COAP_CON
pkt.method = COAP_METHOD.COAP_GET
pkt.messageid = 0x1234
pkt.token = bytearray([0xAA, 0xBB])
pkt.setObserve(COAP_OBSERVE_REGISTER)
pkt.setUriPath("sensors/temperature")
# Encode
buffer = bytearray()
writePacketHeaderInfo(buffer, pkt)
writePacketOptions(buffer, pkt)
writePacketPayload(buffer, pkt)
# Decode
decoded = CoapPacket()
parsePacketHeaderInfo(buffer, decoded)
decoded.tokenLength = buffer[0] & 0x0F
decoded.token = buffer[4:4 + decoded.tokenLength]
assert parsePacketOptionsAndPayload(buffer, decoded)
# Verify Observe option survived
obs_val = decoded.getObserveValue()
assert obs_val == 0, "Expected Observe=0, got {}".format(obs_val)
print("PASS: packet roundtrip with Observe option")
# Verify URI path survived
uri = decoded.getUriPath()
assert uri == "sensors/temperature", "Expected 'sensors/temperature', got '{}'".format(uri)
print("PASS: packet roundtrip URI path preserved")
def test_max_age_option():
"""Max-Age option should encode correctly."""
pkt = CoapPacket()
pkt.setMaxAge(60)
max_age_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_MAX_AGE]
assert len(max_age_opts) == 1
assert max_age_opts[0].buffer == bytearray([60])
print("PASS: Max-Age option encode (value=60)")
def test_observer_manager_register():
"""ObserveManager should register and retrieve observers."""
mgr = ObserveManager(debug=False)
assert not mgr.has_observers("test/resource")
assert mgr.observer_count() == 0
ok = mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1, 2]))
assert ok
assert mgr.has_observers("test/resource")
assert mgr.observer_count("test/resource") == 1
assert mgr.observer_count() == 1
print("PASS: observer registration")
def test_observer_manager_reregister():
"""Re-registering the same observer should update token, not duplicate."""
mgr = ObserveManager(debug=False)
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1, 2]))
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([3, 4]))
assert mgr.observer_count("test/resource") == 1
observers = mgr.get_observers("test/resource")
assert observers[0]["token"] == bytearray([3, 4])
print("PASS: observer re-registration updates token")
def test_observer_manager_deregister():
"""Deregistering should remove the observer."""
mgr = ObserveManager(debug=False)
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1]))
mgr.register("test/resource", "192.168.1.20", 5683, bytearray([2]))
assert mgr.observer_count("test/resource") == 2
mgr.deregister("test/resource", "192.168.1.10", 5683)
assert mgr.observer_count("test/resource") == 1
assert mgr.get_observers("test/resource")[0]["ip"] == "192.168.1.20"
print("PASS: observer deregistration")
def test_observer_manager_deregister_by_token():
"""Deregistering by token (RST handling) should work."""
mgr = ObserveManager(debug=False)
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([0xAA]))
mgr.register("test/resource", "192.168.1.20", 5684, bytearray([0xBB]))
mgr.deregister_by_token("test/resource", bytearray([0xAA]))
assert mgr.observer_count("test/resource") == 1
assert mgr.get_observers("test/resource")[0]["token"] == bytearray([0xBB])
print("PASS: observer deregister by token (RST)")
def test_observer_manager_limits():
"""Observer limits should be enforced."""
mgr = ObserveManager(debug=False)
mgr.MAX_OBSERVERS_PER_RESOURCE = 2
mgr.MAX_TOTAL_OBSERVERS = 3
assert mgr.register("res1", "10.0.0.1", 5683, bytearray([1]))
assert mgr.register("res1", "10.0.0.2", 5683, bytearray([2]))
assert not mgr.register("res1", "10.0.0.3", 5683, bytearray([3])) # per-resource limit
print("PASS: per-resource observer limit enforced")
assert mgr.register("res2", "10.0.0.3", 5683, bytearray([3]))
assert not mgr.register("res2", "10.0.0.4", 5683, bytearray([4])) # total limit
print("PASS: total observer limit enforced")
def test_observer_manager_sequence():
"""Sequence numbers should increment and wrap at 24 bits."""
mgr = ObserveManager(debug=False)
mgr._sequence["test"] = 0xFFFFFE
assert mgr.next_sequence("test") == 0xFFFFFE
assert mgr.next_sequence("test") == 0xFFFFFF
assert mgr.next_sequence("test") == 0 # Wrap
print("PASS: sequence number wrap at 24 bits")
def test_get_uri_path():
"""getUriPath() should reconstruct the path from options."""
pkt = CoapPacket()
pkt.setUriPath("sensors/soil_moisture")
assert pkt.getUriPath() == "sensors/soil_moisture"
print("PASS: getUriPath reconstruction")
# ── Run all tests ──
if __name__ == "__main__":
tests = [
test_observe_option_encode_single_byte,
test_observe_option_encode_two_bytes,
test_observe_option_encode_three_bytes,
test_get_observe_value,
test_packet_roundtrip_with_observe,
test_max_age_option,
test_observer_manager_register,
test_observer_manager_reregister,
test_observer_manager_deregister,
test_observer_manager_deregister_by_token,
test_observer_manager_limits,
test_observer_manager_sequence,
test_get_uri_path,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except Exception as e:
print("FAIL: {} - {}".format(test.__name__, e))
import traceback
traceback.print_exc()
failed += 1
print("\n" + "=" * 40)
print("Results: {} passed, {} failed".format(passed, failed))
print("=" * 40)