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