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