Files
Smartwave/micro_ondes/esp_lora/main.py
T
Ninluc 0f576467e1
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
send_reliably if cooking state update
2026-08-23 16:19:09 +02:00

423 lines
16 KiB
Python

import gc
import sys
import time
import uasyncio as asyncio
from machine import Pin, SoftI2C
import ssd1306
import ujson
# Clean memory immediately
gc.collect()
from shared.safeQueue import SafeQueue
from shared import get_lora, get_uart, deviceTypes, config, cookingState
from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED
from shared.logging import log
from shared.lora_device import LoraCommands
from shared.alerts import Alert, AlertType, AlertManager
from shared.microwave_state import MicrowaveState
from shared.payloads import lora_new_alert
from lib.microwaveScreen import MicrowaveScreen
# --- READ DEVICE ID ---
try:
with open("device_id.txt", "r") as f:
DEVICE_ID = f.read().strip()
except Exception:
DEVICE_ID = "ESP32_Inconnu"
# --- GLOBAL VARIABLES ---
cooking_state = None
cooking_start_time = None # Track start timestamp
microwave_state: str = None
data_queue = SafeQueue()
lora = None
uart_device = None
magnetron_led = None
microwave_screen = None
defrost_mode = False
current_temp = [None, None]
last_temp = [None, None]
temperature_asked = False
last_temp_request_time = 0
alert_manager = None
PING_PAYLOAD = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
last_cooking_update = {}
def send_cooking_update(state=None, start_time=None, estimated_remaining_time=None, paused=None):
"""Sends a flexible COOKING_UPDATE payload containing only populated fields."""
global last_cooking_update
if not lora:
return
send_reliably = False
payload = {
"id": DEVICE_ID,
"action": LoraCommands.COOKING_UPDATE
}
if state is not None and state != last_cooking_update.get("cooking_state"):
payload["cooking_state"] = state
send_reliably = True
if estimated_remaining_time is not None:
payload["estimated_remaining_time"] = estimated_remaining_time
if paused is not None:
payload["paused"] = paused
# Only transmit if at least one field beyond id and action was provided
if len(payload) > 2:
log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}")
last_cooking_update = payload.copy()
if send_reliably:
lora.send_reliable(payload)
else:
lora.send(payload)
def on_new_alert(alert: Alert):
global microwave_screen
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
microwave_screen.message(alert.small_message if alert.small_message else alert.message, True)
def init_hardware():
"""Initializes all hardware components."""
global lora, uart_device, magnetron_led, microwave_screen
print("[Main] Initializing hardware peripherals...")
# Power up VEXT (for LoRa/Display)
vext = Pin(19, Pin.OUT)
vext.value(0)
time.sleep_ms(100)
# Init OLED Display
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
microwave_screen = MicrowaveScreen(display)
microwave_screen.bootScreen()
# Init LoRa
lora = get_lora()
lora.configure(freq=868.1, sf=7)
# Init RGB LEDs
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off()
# Init UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
# --- ASYNC LORA TASK (REPLACES _THREAD) ---
async def lora_rx_and_heartbeat_task():
"""Replaces the hardware OS thread with a non-blocking async task to avoid SPI collisions."""
last_heartbeat_time = 0
while True:
now = time.time()
# 1. Send periodic heartbeat
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
last_heartbeat_time = now
log("\n[LoRa Task] Sending Heartbeat...")
if lora:
lora.send(PING_PAYLOAD)
# CRITICAL: Force the radio chip out of Standby mode and back into RX mode
if hasattr(lora, 'receive'):
lora.receive()
# 2. Listen for incoming packets (Must be longer than LoRa Time-on-Air)
if lora:
paquet = lora.receive_reliable(timeout_ms=500)
if paquet is not None:
log(f"[LoRa Task] New Packet Received: {paquet}")
data_queue.put(paquet)
# Give control back to event loop
await asyncio.sleep_ms(10)
# --- COOKING STATE CALLBACKS ---
def cooking_state_temperature_provider():
global current_temp, last_temp, temperature_asked, last_temp_request_time, uart_device
def temp_is_invalid(temp):
return (
temp is None
or not isinstance(temp, (list, tuple))
or len(temp) < 2
or temp[0] is None
or temp[1] is None
)
# 1. Check for UART request timeout (reset lock if 3 seconds pass without a response)
now = time.time()
if temperature_asked and (now - last_temp_request_time > 3):
print("[CookingState] Temperature request timed out. Retrying UART request...")
temperature_asked = False
# 2. Trigger new UART request if idle
if not temperature_asked and uart_device:
temperature_asked = True
last_temp_request_time = now
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
# 3. Handle fresh incoming reading
if not temp_is_invalid(current_temp):
temps = [float(current_temp[0]), float(current_temp[1])]
last_temp = [temps[0], temps[1]]
current_temp = [None, None]
if config.DEBUG_TEMPERATURE_ALERT or temps[0] > config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT or temps[1] > config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT:
print(f"[CookingState] ALERT: Unsafe temperature detected! Dish: {temps[0]}°C, Ambient: {temps[1]}°C")
# Send alert to orchestrator via LoRa
alert = Alert(
AlertType.TEMPERATURE_ALERT,
f"Unsafe temperature detected! Dish: {temps[0]}/{config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT}°C, Ambient: {temps[1]}/{config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT}°C",
small_message=f"Temp too high"
)
alert_manager.add_alert(alert)
if lora:
lora.send_reliable(lora_new_alert(alert, with_message=True), max_retries=5)
cooking_state.set_state(cookingState.CookingStates.ALERT)
cooking_state.pause()
return temps
# 4. Fallback: Use last valid reading
if not temp_is_invalid(last_temp):
return [float(last_temp[0]), float(last_temp[1])]
# 5. Default fallback if no data has ever arrived
return (0.0, 0.0)
def cooking_state_on_state_change(state):
global cooking_start_time
print(f"[CookingState] State changed to: {state.state}")
if state.paused or state.state in (cookingState.CookingStates.DONE, cookingState.CookingStates.IDLE, cookingState.CookingStates.ALERT):
magnetron_led.off()
else:
magnetron_led.on()
# Send state updates to WiFi board and Orchestrator
if uart_device:
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
# Send LoRa update with start time and remaining time if beginning cooking process
send_cooking_update(
state=state.state,
start_time=cooking_start_time if state.state == cookingState.CookingStates.COOKING else None,
estimated_remaining_time=state.get_remaining_time() if hasattr(state, "get_remaining_time") else None,
paused=state.paused
)
update_screen()
def cooking_state_on_refresh(state):
# Update OLED display
update_screen()
def cooking_state_on_pause(state):
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
state.set_state(cookingState.CookingStates.COOKING)
# Send update on pause/resume toggle
send_cooking_update(
state=state.state,
paused=state.paused,
estimated_remaining_time=state.get_remaining_time()
)
# --- SCREEN UPDATE ---
def update_screen():
global microwave_screen, cooking_state, defrost_mode, microwave_state
if microwave_screen:
microwave_screen.update(cooking_state, defrost_mode, microwave_state)
# --- ASYNC TASKS ---
async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board."""
global cooking_state, cooking_start_time, temperature_asked, microwave_state
while True:
if uart_device and uart_device.any():
command = uart_device.read_as_command()
if command:
log(f"[UART Task] Received command from WiFi Board: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_PARAMS:
params = command.payload
print(f"[UART Task] Cooking parameters received: {params}")
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
# Track start time timestamp
cooking_start_time = time.time()
cooking_state = cookingState.CookingState(
cook_time=params["cook_time"],
power_level=params["power_level"],
target_temp=params["target_temp"]
)
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
cooking_state.set_state_change_callback(cooking_state_on_state_change)
cooking_state.set_refresh_callback(cooking_state_on_refresh)
cooking_state.set_pause_callback(cooking_state_on_pause)
await asyncio.sleep_ms(200)
cooking_state_on_state_change(cooking_state)
microwave_state = MicrowaveState.COOKING
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
try:
# Check if payload is already a dict or needs JSON decoding
payload = ujson.loads(command.payload) if isinstance(command.payload, str) else command.payload
current_temp[0] = payload.get("dish_temp", 0.0)
current_temp[1] = payload.get("ambient_temp", 0.0)
except Exception as e:
print(f"[UART Task] Error parsing temperature payload: {e}")
finally:
temperature_asked = False
else:
print(f"[UART Task] Unknown command type received: {command.command_type}")
await asyncio.sleep_ms(50)
async def lora_process_task():
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
global cooking_state, defrost_mode, microwave_screen, microwave_state
while True:
while not data_queue.empty():
paquet = data_queue.get()
if paquet and not paquet.get("raw"):
data = paquet.get("data", {})
# RECIPIENT FILTERING: Drop packets not addressed to this ESP32 or ALL
target = data.get("target_id") or data.get("recipient")
if target and target not in [DEVICE_ID, "ALL", "BROADCAST"]:
log(f"[LoRa Process] Ignoring packet intended for {target}")
continue
if "action" in data:
action = data["action"]
if action == LoraCommands.TOGGLE_PAUSE:
if cooking_state is not None:
if cooking_state.state == cookingState.CookingStates.DONE:
print("[LoRa Process] Cooking is done. Resetting microwave for next session.")
cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20)
cooking_state = None
else:
cooking_state.toggle_pause()
print(f"[LoRa Process] Cooking paused state toggled to {cooking_state.paused}")
else:
log("[LoRa Process] No active cooking state to toggle pause/resume.")
elif action == LoraCommands.TOGGLE_DEFROST:
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
defrost_mode = data.get("defrost_state", False)
update_screen()
elif action == LoraCommands.NEW_ALERT:
alert: Alert | None = None
if data.get("alert_type") == AlertType.TEMPERATURE_ALERT:
alert = Alert(data.get("alert_type"), data.get("alert_message", "Temp too high"), redistributed=True)
elif data.get("alert_type") == AlertType.COOKING_SAFETY:
alert = Alert(data.get("alert_type"), data.get("alert_message", "Unsafe area"), redistributed=True)
if alert:
alert.timestamp = data.get("timestamp", time.time())
alert_manager.add_alert(alert)
elif action == LoraCommands.MICROVAVE_STATE_UPDATE:
new_state = data.get("new_microwave_state")
if new_state:
print(f"[LoRa Process] Microwave state update received: {new_state}")
microwave_state = new_state
if new_state not in (MicrowaveState.ALERT): # Not alert so we can show the error
update_screen()
if new_state == MicrowaveState.IDLE:
cooking_state = None
await asyncio.sleep_ms(20)
await asyncio.sleep_ms(100)
async def cooking_loop_task():
"""Ticks the cooking state and logs information periodically without flooding output."""
log_counter = 0
while True:
if cooking_state is not None:
cooking_state.update_tick()
log_counter += 1
# Print log output and send periodic updates every 5 seconds (10 ticks x 500ms)
if log_counter % 10 == 0:
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
# Send periodic time remaining update while actively cooking
if not cooking_state.paused and cooking_state.state == cookingState.CookingStates.COOKING:
send_cooking_update(
estimated_remaining_time=cooking_state.get_remaining_time()
)
await asyncio.sleep_ms(500)
async def memory_cleanup_task():
"""Periodically cleans up memory to prevent heap fragmentation."""
while True:
gc.collect()
await asyncio.sleep(10)
# --- BOOTSTRAP ---
async def main():
global alert_manager
print("[Main] Starting application...")
init_hardware()
alert_manager = AlertManager()
alert_manager.set_on_alert_callback(on_new_alert)
# Launch all tasks within the same single-threaded uasyncio event loop
asyncio.create_task(lora_rx_and_heartbeat_task())
asyncio.create_task(uart_polling_task())
asyncio.create_task(lora_process_task())
asyncio.create_task(cooking_loop_task())
asyncio.create_task(memory_cleanup_task())
print("[Main] All async tasks running concurrently!")
update_screen()
# Keep main task alive indefinitely
while True:
await asyncio.sleep(3600)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("[Main] Program stopped by user.")
except Exception as e:
sys.print_exception(e)