904 lines
36 KiB
Python
904 lines
36 KiB
Python
import base64
|
|
import json
|
|
import time
|
|
import asyncio
|
|
import requests
|
|
|
|
from shared.microwave_state import MicrowaveState, MicrowaveStateFields
|
|
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, gps, rfid_reader
|
|
from lib.systemd_logs import get_systemd_logs
|
|
from external_display_manager import DisplayManager
|
|
from rgb_lcd_manager import RGBLCDManager
|
|
from web_server import start_technician_web_server
|
|
|
|
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
|
|
TECHNICIAN_MODE = False
|
|
def is_technician_mode_active() -> bool:
|
|
return TECHNICIAN_MODE
|
|
|
|
# --- 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"
|
|
|
|
def get_api_key():
|
|
for path in ["device_api_key.txt", "/home/pi/SmartWave/orchestrateur/device_api_key.txt"]:
|
|
try:
|
|
with open(path, "r") as f:
|
|
return f.read().strip()
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
DEVICE_ID = get_device_id()
|
|
API_KEY = get_api_key()
|
|
|
|
# --- 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)
|
|
|
|
# RFID reader
|
|
rfid = rfid_reader.RFIDReader(rx_pin=17)
|
|
|
|
# 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 ---
|
|
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)
|
|
|
|
async def rfid_listener_task():
|
|
"""Polls RFID reader, verifies cards via OData API, and toggles TECHNICIAN_MODE."""
|
|
global TECHNICIAN_MODE
|
|
print("[RFID] Async task started on GPIO 17.")
|
|
|
|
last_scanned_tag = None
|
|
last_scan_time = 0
|
|
|
|
while True:
|
|
try:
|
|
# Quick non-blocking read via pigpio IPC
|
|
tag_id = await asyncio.to_thread(rfid.read_tag)
|
|
current_time = time.time()
|
|
|
|
if tag_id and tag_id != "0000000000":
|
|
# Debounce same tag within 3 seconds to avoid spamming the API
|
|
if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
|
|
last_scanned_tag = tag_id
|
|
last_scan_time = current_time
|
|
print(f"[RFID] Tag detected: {tag_id}. Verifying with API...")
|
|
|
|
is_valid = await check_rfid_card_api(tag_id)
|
|
|
|
if is_valid:
|
|
TECHNICIAN_MODE = True
|
|
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
|
|
# Optional: Sound positive feedback beep
|
|
buzzer.buzzer_siren(beatsNb=1)
|
|
else:
|
|
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
|
|
# Optional: Sound error feedback beep
|
|
buzzer.buzzer_siren(beatsNb=2)
|
|
|
|
except Exception as e:
|
|
print(f"[RFID Task] Error reading RFID tag: {e}")
|
|
|
|
# Sleep 100ms between checks to keep CPU usage near zero
|
|
await asyncio.sleep(0.1)
|
|
|
|
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
|
|
)
|
|
|
|
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}")
|
|
|
|
# === 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-<id>._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)
|
|
|
|
# --- Button ---
|
|
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",
|
|
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
|
|
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)
|
|
|
|
# === API CALLS ===
|
|
|
|
async def check_rfid_card_api(card_id: str) -> bool:
|
|
"""Queries the OData API asynchronously to verify if card_id is valid."""
|
|
url = "https://smartwave.matthiasg.dev/rfid-card"
|
|
|
|
params = {
|
|
"$filter": f"card_id eq '{card_id}'"
|
|
}
|
|
headers = {"Content-Type": "application/json"}
|
|
if API_KEY:
|
|
headers["X-API-Key"] = API_KEY
|
|
|
|
try:
|
|
response = await asyncio.to_thread(
|
|
requests.get,
|
|
url,
|
|
params=params,
|
|
headers=headers,
|
|
timeout=5
|
|
)
|
|
if response.status_code == 200:
|
|
set_cloud_alert(False) # Clear cloud alert on success
|
|
data = response.json()
|
|
|
|
if isinstance(data, dict):
|
|
items = data.get("value", data.get("data", []))
|
|
return len(items) > 0 if isinstance(items, list) else bool(data)
|
|
elif isinstance(data, list):
|
|
return len(data) > 0
|
|
else:
|
|
print(f"[RFID Task] OData API returned status {response.status_code}: {response.json()}")
|
|
set_cloud_alert(True) # Set cloud alert on API connection failure
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"[RFID Task] OData API check error: {e}")
|
|
set_cloud_alert(True) # Set cloud alert on API connection failure
|
|
return False
|
|
|
|
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,
|
|
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
|
|
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("plan", {}).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 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", "X-API-Key": API_KEY},
|
|
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(),
|
|
rfid_listener_task(),
|
|
start_technician_web_server(
|
|
get_logs_cb=get_systemd_logs,
|
|
telemetry_cb=handle_telemetry_request,
|
|
is_tech_mode_cb=is_technician_mode_active,
|
|
host=config.TECHNICIAN_WEB_SERVER_HOST,
|
|
port=config.TECHNICIAN_WEB_SERVER_PORT
|
|
)
|
|
)
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
print("\nArrêt manuel.")
|
|
finally:
|
|
rfid.close() # <-- ADDED CLEANUP HERE
|
|
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() |