import base64 import json import time import asyncio import requests from shared.microwave_state import MicrowaveState, MicrowaveStateFields from orchestrateur.sensors import gps from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db from shared.logging import log from shared.cookingState import CookingStates from shared.lora_device import LoraCommands from shared.alerts import AlertManager, Alert, AlertType from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd from lib.systemd_logs import get_systemd_logs from external_display_manager import DisplayManager from rgb_lcd_manager import RGBLCDManager rgb_lcd.setRGB(255, 255, 255, brightness=0.8) rgb_lcd.setText("SmartWave\nOrchestrateur") # --- CONFIGURATION CONSTANTS --- DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting # --- DB SETUP --- DB_PATH = "orchestrateur/db.sqlite" def init_db(): """Ensures the connected_components table exists on startup.""" sql = """ CREATE TABLE IF NOT EXISTS connected_components ( id TEXT PRIMARY KEY, type TEXT, timestamp INTEGER ); """ db.execute(DB_PATH, sql) print(f"[DB] Initialized database table at {DB_PATH}") def save_connected_component(component_id: str, component_type: str): """Upserts component information into the database (blocking sync worker).""" current_time = int(time.time()) sql = """ INSERT INTO connected_components (id, type, timestamp) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, timestamp = excluded.timestamp; """ db.execute(DB_PATH, sql, (str(component_id), str(component_type), current_time)) print(f"[DB] Component saved/updated -> ID: {component_id}, Type: {component_type}, Timestamp: {current_time}") def remove_connected_component(component_id: str): """Removes a component from the database (blocking sync worker).""" sql = "DELETE FROM connected_components WHERE id = ?;" db.execute(DB_PATH, sql, (str(component_id),)) print(f"[DB] Component removed -> ID: {component_id}") def get_connected_components(): """Returns a list of all connected components from the database.""" sql = "SELECT id, type, timestamp FROM connected_components;" rows = db.fetchall(DB_PATH, sql) return [{"id": row[0], "type": row[1], "timestamp": row[2]} for row in rows] # --- Read Unique Device ID --- def get_device_id(): for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]: try: with open(path, "r") as f: return f.read().strip() except Exception: pass return "RPI_Orchestrateur_Default" DEVICE_ID = get_device_id() # --- DISPLAY QUEUE & NOTIFICATION HELPERS --- display_update_queue = None def notify_display_update(): """Safely adds an update event timestamp to the display update queue.""" if display_update_queue is not None: try: loop = asyncio.get_running_loop() loop.call_soon_threadsafe(display_update_queue.put_nowait, time.time()) except RuntimeError: pass def on_microwave_change(state: MicrowaveState, field: MicrowaveStateFields): if field != MicrowaveStateFields.STATE: # No external screen or lcd uses this field, so we can skip updates for it notify_display_update() if field == MicrowaveStateFields.STATE and state.state in (MicrowaveState.DONE, MicrowaveState.ANALYZING, MicrowaveState.WAITING_FOR_CLOUD, MicrowaveState.ALERT): # Send to LoRa device if state changed lora.send_reliable(payloads.lora_microwave_state(state.state), max_retries=5) def set_cloud_alert(state: bool): """Setter for cloud_alert that triggers a display update when changed.""" global cloud_alert if cloud_alert != state: cloud_alert = state notify_display_update() update_lcd_cloud_alert() # Global state trackers microwave_states = {"2": MicrowaveState("2", on_change_callback=on_microwave_change)} button_state = False cloud_alert = None # Global status flag for screen / UI display async_event_queue = None # Display Manager instance display_manager = DisplayManager(endpoint_path="/api/display") rgb_lcd_manager = RGBLCDManager(rgb_lcd, brightness=0.8, interval=5.0) # Async synchronization trackers for MQTT IR sensors responses ir_data_cache = {} # mw_id -> dict of IR readings ir_data_events = {} # mw_id -> asyncio.Event() # --- HARDWARE & MQTT SETUP --- lora = get_lora() lora.configure() # Local MQTT Broker (ESP32 IR & Sensors) mqtt_client = get_mqtt_client( host="192.168.50.1", client_id="smartwave-orchestrateur-" + DEVICE_ID, use_tls=config.USE_TLS, cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt", keepalive=config.MQTT_KEEPALIVE, ) mqtt_client.connect() mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) # DHT sensor warmup time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state if hasattr(mqtt_client._client, "loop_start"): mqtt_client._client.loop_start() print("[MQTT Local] Paho background loop started.") # Cloud MQTT Broker (Remote Commands) cloud_mqtt_client = get_mqtt_client( host="mqtt.matthiasg.dev", client_id=DEVICE_ID, use_tls=False, username="microwave_device", password="myMicrowaveVerySecret", keepalive=config.MQTT_KEEPALIVE, ) try: cloud_mqtt_client.connect() cloud_mqtt_client.subscribe("cmd/all", qos=config.CLOUD_MQTT_QOS) cloud_mqtt_client.subscribe(f"cmd/{DEVICE_ID}", qos=config.CLOUD_MQTT_QOS) except Exception as e: print(f"[MQTT Cloud] Error connecting to cloud MQTT broker: {e}") if hasattr(cloud_mqtt_client._client, "loop_start"): cloud_mqtt_client._client.loop_start() print("[MQTT Cloud] Paho background loop started.") # --- BACKGROUND TASKS (PRODUCERS) --- async def lora_listener_task(): """Polls LoRa and pushes to the async queue.""" print("[LoRa] Async listener started.") while True: # Run blocking lora receive in a thread to not block asyncio loop paquet = await asyncio.to_thread(lora.receive_reliable, timeout_ms=100) if paquet: await async_event_queue.put({"source": "LoRa", "data": paquet}) await asyncio.sleep(0.05) async def mqtt_listener_task(): """Polls local MQTT cache and pushes to the async queue.""" print("[MQTT Local] Async listener started.") while True: message = mqtt_client.get_message() if message: try: payload = json.loads(message['payload']) except Exception: payload = message['payload'] # --- SAFE TOPIC DECODING --- topic = message['topic'] if isinstance(topic, bytes): topic = topic.decode('utf-8') await async_event_queue.put({ "source": "MQTT", "topic": topic, "data": payload }) await asyncio.sleep(0.1) async def cloud_mqtt_listener_task(): """Polls Cloud MQTT cache and pushes commands to the async queue.""" print("[MQTT Cloud] Async listener started.") while True: message = cloud_mqtt_client.get_message() if message: try: payload = json.loads(message['payload']) except Exception: payload = message['payload'] topic = message['topic'] if isinstance(topic, bytes): topic = topic.decode('utf-8') await async_event_queue.put({ "source": "CloudMQTT", "topic": topic, "data": payload }) await asyncio.sleep(0.1) # === LCD DISPLAY === def on_screens_change(screens, is_removed: bool, modified_name: str): """Callback for when screens are added or removed.""" log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}") rgb_lcd_manager.set_external_screen_count(len(screens)) # Add Or remove from database of connected components # 1. Extract id from name (assuming format "smartwave-epaper-._displaytcp._tcp.local") display_id = modified_name.split("-")[-1].split(".")[0] if is_removed: remove_connected_component(display_id) else: save_connected_component(display_id, "external_display") display_manager.set_on_screens_change_callback(on_screens_change) def update_lcd_microwave_count(): """Updates the LCD with the current number of connected microwaves.""" global microwave_states count = len(microwave_states) rgb_lcd_manager.set_microwave_count(count) def update_lcd_cloud_alert(): """Updates the LCD with the current cloud connectivity status.""" global cloud_alert rgb_lcd_manager.set_cloud_alert(cloud_alert) async def display_broadcast_worker_task(): """ Debounced display broadcast task. Waits until DISPLAY_DEBOUNCE_SECONDS have passed since the first queued update before sending state to all connected screens. """ global display_update_queue, microwave_states, cloud_alert print("[Display Worker] Started debounced display broadcast worker.") while True: # Block until the FIRST update reminder arrives in the queue first_event_time = await display_update_queue.get() # Compute wait time relative to the first event's timestamp elapsed = time.time() - first_event_time remaining_wait = DISPLAY_DEBOUNCE_SECONDS - elapsed if remaining_wait > 0: await asyncio.sleep(remaining_wait) # Clear any subsequent events that arrived during the wait period while not display_update_queue.empty(): try: display_update_queue.get_nowait() except asyncio.QueueEmpty: break # Broadcast state ONCE await display_manager.broadcast_state( microwave_states=microwave_states, cloud_alert=cloud_alert ) def button_callback(): """Button physical interrupt callback.""" global button_state if microwave_states.get("2").cooking_state in (CookingStates.COOKING, CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT): print("[Button] Toggling pause/resume for microwave '2'.") lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}, max_retries=6) else: button_state = not button_state print(f"[Button] Defrost state toggled to: {button_state}") lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state}, max_retries=5) button.set_callback(button_callback) button.start_button_monitoring_thread() # --- Alert manager --- alert_manager = None def on_new_alert(alert: Alert): # Sends to cloud server alert_dict = alert.to_dict() logs_list = asyncio.run(get_systemd_logs(lines=200, service_name="smartwave")) payload = { "orchestrator_id": DEVICE_ID, "alert": alert_dict, "logs": logs_list } response = requests.post( "https://smartwave.matthiasg.dev/alert", json=payload, ) response.raise_for_status() res_json = response.json() print(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}") if not alert.redistributed: # If not redistributed from microwaves # Send the alert to the lora device payload = payloads.lora_new_alert(alert) print(f"[Alert Handling] Sending alert to LoRa device: {payload}") if lora.send_reliable(payload): microwave_states["2"].set_cooking_state(CookingStates.ALERT) # --- HARDWARE CONTROLLERS --- def _stop_hardware(microwave_id: str): print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.") # TODO: Add LoRa STOP command here # --- ASYNC COOKING LOGIC --- def read_local_sensors(microwave_id, initial_dish_height): """Blocking function to read local I2C/SPI sensors. Runs in a thread.""" print(f"[{microwave_id}] Reading local physical sensors...") sensor_data = { "microwave_id": microwave_id, "defrost_mode": button_state, "ultrasonic_distance": initial_dish_height # Reuse height from trigger } # Temp / Hum (handles DHT error safely) try: temp, hum = temp_hum.get_temperature_and_humidity_with_retry() if temp is not None: sensor_data["temperature"] = temp sensor_data["humidity"] = hum if config.DEBUG_TEMPERATURE_ALERT or temp > config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT: print(f"[{microwave_id}] ALERT: Unsafe temperature in technical compartment detected! Temp: {temp}°C, Humidity: {hum}%") alert = Alert( AlertType.TEMPERATURE_ALERT, f"Unsafe temperature in technical compartment detected! Temp: {temp}/{config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT}°C, Humidity: {hum}%", redistributed=False ) # lora.send_reliable(payloads.lora_new_alert(alert), max_retries=5) alert_manager.add_alert(alert) return None # Abort further processing if unsafe temperature detected except Exception as e: log(f"[{microwave_id}] DHT read warning: {e}") # Camera try: sensor_data["camera_image"] = camera.get_picture() except Exception as e: log(f"[{microwave_id}] Camera read failed: {e}") return sensor_data async def handle_new_dish(microwave_id, detected_height): """Triggered when a new dish is placed inside.""" microwave_states[microwave_id].set_state(MicrowaveState.ANALYZING) print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...") # 1. Setup synchronization event and clear previous cache for this microwave event = asyncio.Event() ir_data_events[microwave_id] = event ir_data_cache.pop(microwave_id, None) # 2. Send IR request to ESP32 via MQTT immediately mqtt_client.publish( config.MQTT_TOPIC_COOKING, payloads.mqtt_cooking_init(microwave_id), qos=config.MQTT_QOS ) # 3. Start local sensor reading in parallel sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height)) # 4. Wait for local sensors to finish reading sensors_data = await sensor_task if sensors_data is None: print(f"[{microwave_id}] Aborting cooking plan because of empty sensor data") microwave_states[microwave_id].set_state(MicrowaveState.ALERT) return # Check if dish was removed while reading sensors if microwave_states.get(microwave_id).state != MicrowaveState.ANALYZING: print(f"[{microwave_id}] Dish removed during sensor read. Aborting.") ir_data_events.pop(microwave_id, None) return # 5. Wait for MQTT IR data try: await asyncio.wait_for(event.wait(), timeout=10.0) ir_payload = ir_data_cache.get(microwave_id, {}) sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp") sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp") print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}") # 6. Dispatch cloud request task asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data)) except asyncio.TimeoutError: print(f"[{microwave_id}] Timeout waiting for MQTT IR data from ESP32.") sensors_data["ir_initial_temp"] = None sensors_data["ir_ambient_temp"] = None finally: ir_data_events.pop(microwave_id, None) async def request_cloud_cooking_plan(microwave_id, sensors_data): """Sends all data to the cloud with up to 3 retries (330s timeout for AI generation).""" global cloud_alert, alert_manager microwave_states[microwave_id].set_state(MicrowaveState.WAITING_FOR_CLOUD) URL = "https://smartwave.matthiasg.dev/cooking-params" # Format image if isinstance(sensors_data.get("camera_image"), bytes): sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8") print(f"[{microwave_id}] Requesting cooking plan from cloud app...") max_retries = 3 retry_delay_seconds = 10 # Reduced delay since each request already waits up to 5 mins # Tuple format: (connect_timeout_seconds, read_timeout_seconds) HTTP_TIMEOUT = (10, 330) for attempt in range(1, max_retries + 1): if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD: print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.") return try: print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries} (Waiting up to 5.5 min)...") response = await asyncio.to_thread( requests.post, URL, json=sensors_data, timeout=HTTP_TIMEOUT ) # Abort if state changed while waiting for cloud AI response if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD: print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.") return response.raise_for_status() set_cloud_alert(False) # Clear any previous cloud alert # JSON extraction res_json = response.json() # Error handling if "error" in res_json: print(f"[{microwave_id}] Cloud API returned error: {res_json['error']}") microwave_states[microwave_id].set_state(MicrowaveState.DONE) if not res_json['is_safe']: # The cooking area is not safe, raise an alert alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message']) alert_manager.add_alert(alert) else: alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}") return plan = res_json.get("cook_plan", res_json) c_time = plan.get("cook_time_seconds") c_power = plan.get("effective_power_watts") c_temp = plan.get("target_temp") if c_time is None or c_power is None or c_temp is None: print(f"[{microwave_id}] Invalid plan received: {res_json}") microwave_states[microwave_id].set_state(MicrowaveState.DONE) return print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W") microwave_states[microwave_id].set_state(MicrowaveState.COOKING) if config.DEBUG: c_time = 20 # Set to 20s for debug c_temp = 50 # Set to 50°C for debug mqtt_client.publish( config.MQTT_TOPIC_COOKING, payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp), qos=config.MQTT_QOS ) microwave_states[microwave_id].set_cooking_start_time(time.time()) return except Exception as e: # Print stacktrace import traceback traceback.print_exc() print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}") if attempt < max_retries: print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...") # Interruptible wait loop in case state changes mid-wait for _ in range(retry_delay_seconds): if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD: print(f"[{microwave_id}] State changed during retry wait. Aborting retries.") return await asyncio.sleep(1) # Executed only if all 3 retries failed print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.") set_cloud_alert(True) microwave_states[microwave_id].set_state(MicrowaveState.DONE) # --- MAIN LOGIC TASKS --- async def process_messages_task(): """Consumes the unified event queue.""" # Helper to normalize config topics to str def to_str(val): return val.decode('utf-8') if isinstance(val, bytes) else val while True: msg = await async_event_queue.get() source = msg["source"] data = msg["data"] if source == "LoRa": if "new_cooking_state" in data.get("data", {}): mw_id = data["data"].get("id") n_state = data["data"].get("new_cooking_state") print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}") microwave_states[mw_id].set_cooking_state(n_state) update_lcd_microwave_count() # Update the LCD with new microwave count if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT): beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3) buzzer.buzzer_siren(beatsNb=beats) if n_state == CookingStates.IDLE and microwave_states.get(mw_id).state == MicrowaveState.COOKING: microwave_states[mw_id].set_state(MicrowaveState.DONE) print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.") if "action" in data.get("data", {}): if type(data["data"]) != dict: print(f"[LoRa] Invalid data format received: {data['data']}") continue action = data["data"]["action"] if action == LoraCommands.COOKING_UPDATE: mw_id = data["data"].get("id") if mw_id in microwave_states: state_info = data["data"] microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state)) microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time)) microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused)) elif action == LoraCommands.NEW_ALERT: alert_type = data["data"].get("alert_type") alert_message = data["data"].get("alert_message") alert = Alert(alert_type, alert_message, redistributed=True) alert_manager.add_alert(alert) print(f"[LoRa] New alert received: {alert.to_dict()}") else: print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}") elif source == "MQTT": topic = msg["topic"] hello_topic = to_str(config.MQTT_TOPIC_HELLO) sensor_topic = to_str(config.MQTT_TOPIC_SENSOR) if topic == hello_topic: if data.get("id_orchestrator") != DEVICE_ID: component_id = data.get("id_microwave") component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"]) if component_id: print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.") # Offload DB insertion to async thread execution pool await asyncio.to_thread(save_connected_component, component_id, component_type) # Pass change callback to newly connected microwave microwave_states[component_id] = MicrowaveState( component_id, on_change_callback=on_microwave_change ) notify_display_update() # Update the external screens update_lcd_microwave_count() # Update the LCD with new microwave count mqtt_client.publish( config.MQTT_TOPIC_HELLO, payloads.mqtt_hello_ack(DEVICE_ID, component_id), qos=config.MQTT_QOS ) elif topic == sensor_topic: mw_id = str(data.get("id_microwave")) print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}") # Store IR data and notify the waiting dish handler ir_data_cache[mw_id] = data if mw_id in ir_data_events: ir_data_events[mw_id].set() elif source == "CloudMQTT": topic = msg["topic"] print(f"[CloudMQTT] Command received on '{topic}': {data}") action = data.get("action") if action == "request_telemetry": endpoint = data.get("endpoint") if endpoint: # Schedule as a background task so processing the queue isn't stalled do_timeout = topic == "cmd/all" asyncio.create_task(handle_telemetry_request(endpoint, do_timeout=do_timeout)) else: print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.") else: print(f"[CloudMQTT] Unknown action: {action}") async def handle_telemetry_request(endpoint: str, do_timeout=True): """ Task to gather telemetry data (including structured systemd logs) and post it to the target HTTP endpoint. """ print(f"[Telemetry] Starting telemetry collection for endpoint: {endpoint}") try: # 1. Fetch systemd logs asynchronously logs_list = await get_systemd_logs(lines=200, service_name="smartwave") # 2. Unpack temperature and humidity ambient_temp, ambient_humidity = temp_hum.get_temperature_and_humidity_with_retry() # Unpack microwave states microwave_states_snapshot = {mw_id: mw_state.to_dict() for mw_id, mw_state in microwave_states.items()} # 3. Build the telemetry payload (returns a JSON string) telemetry_payload = payloads.telemetry_payload( device_id=DEVICE_ID, microwave_states=microwave_states_snapshot, button_state=button_state, cloud_alert=cloud_alert, gps_data=gps.get_gps_data(), ambient_temp=ambient_temp, ambient_humidity=ambient_humidity, connected_components=get_connected_components(), logs=logs_list ) # 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint if do_timeout: do_timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL print(f"[Telemetry] Waiting for {do_timeout}s before sending telemetry to avoid collisions...") await asyncio.sleep(do_timeout) # 5. Offload blocking HTTP POST to thread pool print(f"[Telemetry] Sending payload with {len(logs_list)} log entries...") response = await asyncio.to_thread( requests.post, endpoint, data=telemetry_payload, # <-- Changed from json=telemetry_payload headers={"Content-Type": "application/json"}, timeout=15 ) response.raise_for_status() set_cloud_alert(False) # Clear any previous cloud alert on success print(f"[Telemetry] Successfully dispatched telemetry (HTTP {response.status_code}).") except requests.exceptions.RequestException as req_err: print(f"[Telemetry] HTTP Request failed to {endpoint}: {req_err}") set_cloud_alert(True) # Set cloud alert on HTTP failure except Exception as e: print(f"[Telemetry] Error handling telemetry request: {e}") async def get_filtered_dish_height(samples=3, delay=0.04): """Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros.""" valid_samples = [] for _ in range(samples): h = await asyncio.to_thread(ultrasonicRanger.get_dish_height) # Discard negative glitches if h is not None and h >= 0.0: valid_samples.append(h) await asyncio.sleep(delay) if valid_samples: valid_samples.sort() return valid_samples[len(valid_samples) // 2] # Median sample return None # All reads failed or out of range async def monitor_dish_height_task(): """Monitors presence of dish with hysteresis and debouncing.""" global cloud_alert mw_id = "2" consecutive_present = 0 consecutive_absent = 0 REQUIRED_STABLE_READS = 3 # Must see 3 stable states in a row (~1 second) while True: dist = await get_filtered_dish_height() try: current_state = microwave_states.get(mw_id).state except AttributeError: current_state = MicrowaveState.IDLE if dist is not None: # Hysteresis Thresholds: # - Must be > 2.5 cm to detect dish insertion # - Must be < 1.2 cm to detect dish removal if dist > 2.5: consecutive_present += 1 consecutive_absent = 0 elif dist < 1.2: consecutive_absent += 1 consecutive_present = 0 else: # Dead-zone (1.2cm to 2.5cm) -> Noise buffer consecutive_present = 0 consecutive_absent = 0 # --- DISH INSERTED CONFIRMED --- if consecutive_present >= REQUIRED_STABLE_READS and current_state == MicrowaveState.IDLE: consecutive_present = 0 asyncio.create_task(handle_new_dish(mw_id, dist)) # --- DISH REMOVED CONFIRMED --- elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE: consecutive_absent = 0 print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.") microwave_states[mw_id].set_state(MicrowaveState.IDLE) set_cloud_alert(False) if current_state == MicrowaveState.COOKING: _stop_hardware(mw_id) # Remove from IR cache and events ir_data_cache.pop(mw_id, None) ir_data_events.pop(mw_id, None) await asyncio.sleep(0.3) # --- BOOTSTRAP --- async def main(): global async_event_queue, display_update_queue print("Orchestrateur prêt. Lancement des tâches...") # Initialize the alert manager and set the callback global alert_manager alert_manager = AlertManager() alert_manager.set_on_alert_callback(on_new_alert) # Initialize SQLite database table init_db() # Start mDNS Display discovery await display_manager.start() save_connected_component(DEVICE_ID, "orchestrateur") async_event_queue = asyncio.Queue() display_update_queue = asyncio.Queue() await asyncio.gather( lora_listener_task(), mqtt_listener_task(), cloud_mqtt_listener_task(), process_messages_task(), monitor_dish_height_task(), display_broadcast_worker_task() ) if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\nArrêt manuel.") finally: asyncio.run(display_manager.stop()) if hasattr(mqtt_client._client, "loop_stop"): mqtt_client._client.loop_stop() if hasattr(cloud_mqtt_client._client, "loop_stop"): cloud_mqtt_client._client.loop_stop() mqtt_client.close() cloud_mqtt_client.close()