340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""
|
|
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()
|