423 lines
16 KiB
Python
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) |