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

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