docs: add project description and server setup instructions to README
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user