Compare commits
5 Commits
0469b3d406
...
83b269cc88
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b269cc88 | |||
| 01eccf47c6 | |||
| 9b0ecf944a | |||
| c6e56b58a0 | |||
| 1a77794fd3 |
@@ -2,6 +2,7 @@ import time
|
|||||||
from shared.cookingState import CookingState, CookingStates
|
from shared.cookingState import CookingState, CookingStates
|
||||||
from shared import config
|
from shared import config
|
||||||
import framebuf
|
import framebuf
|
||||||
|
from shared.microwave_state import MicrowaveState
|
||||||
|
|
||||||
# Icon and bitmaps
|
# Icon and bitmaps
|
||||||
image_weather_frost_bits = bytearray(b'\x01\x00\x13\x901\x18s\x9c\x09 \x05@S\x94\xfe\xfeS\x94\x05@\x09 s\x9c1\x18\x13\x90\x01\x00\x00\x00')
|
image_weather_frost_bits = bytearray(b'\x01\x00\x13\x901\x18s\x9c\x09 \x05@S\x94\xfe\xfeS\x94\x05@\x09 s\x9c1\x18\x13\x90\x01\x00\x00\x00')
|
||||||
@@ -10,12 +11,16 @@ image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\x
|
|||||||
image_CoolLoSmall_bits = bytearray(b'\x1f\xff\xc0 \x00 @\x00\x10\x80 \x08\x80\xa8\x08\x82r\x08\x86#\x08\x81$\x08\x88\xa8\x88\x84q\x08\x9f\xff\xc8\x84q\x08\x88\xa8\x88\x81$\x08\x86#\x08\x82r\x08\x80\xa8\x08\x80 \x08@\x00\x10 \x00 \x1f\xff\xc0')
|
image_CoolLoSmall_bits = bytearray(b'\x1f\xff\xc0 \x00 @\x00\x10\x80 \x08\x80\xa8\x08\x82r\x08\x86#\x08\x81$\x08\x88\xa8\x88\x84q\x08\x9f\xff\xc8\x84q\x08\x88\xa8\x88\x81$\x08\x86#\x08\x82r\x08\x80\xa8\x08\x80 \x08@\x00\x10 \x00 \x1f\xff\xc0')
|
||||||
image_music_sound_wave_bits = bytearray(b'\x00\x00\x00\x02\x00\x00\x02 \x00\x02 \x00\x02 \x00\x0a(\x00\x0a\xa8\x00*\xa8\x00\xaa\xaa\x80\x0a\xaa\x00\x0a\xa8\x00\x0a \x00\x02 \x00\x02 \x00\x02\x00\x00\x00\x00\x00')
|
image_music_sound_wave_bits = bytearray(b'\x00\x00\x00\x02\x00\x00\x02 \x00\x02 \x00\x02 \x00\x0a(\x00\x0a\xa8\x00*\xa8\x00\xaa\xaa\x80\x0a\xaa\x00\x0a\xa8\x00\x0a \x00\x02 \x00\x02 \x00\x02\x00\x00\x00\x00\x00')
|
||||||
image_weather_temperature_bits = bytearray(b'\x1c\x00\x22\x00+\x00*\x00+\x00*\x00+\x00*\x00*\x00I\x00\x9c\x80\xae\x80\xbe\x80\x9c\x80A\x00>\x00')
|
image_weather_temperature_bits = bytearray(b'\x1c\x00\x22\x00+\x00*\x00+\x00*\x00+\x00*\x00*\x00I\x00\x9c\x80\xae\x80\xbe\x80\x9c\x80A\x00>\x00')
|
||||||
|
image_camera_bits = bytearray(b'\x00x\x000\x84\x00\x7f{\x00\xc0\x00\x80\x91\xc6\x80\x860\x80\x85\xd0\x80\x8a(\x80\x8a(\x80\x8a(\x80\x85\xd0\x80\x860\x80\x91\xc6\x80\xc0\x00\x80\x7f\xff\x00\x00\x00\x00')
|
||||||
|
image_cloud_sync_bits = bytearray(b'\x00\x00\x00\x07\xc0\x00\x08 \x00\x10\x10\x000\x08\x00@\x0e\x00\x80\x01\x00\x82 \x80G \x80/\xaf\x00\x02 \x00\x02\xf8\x00\x02p\x00\x02 \x00\x00\x00\x00\x00\x00\x00')
|
||||||
fb_image_weather_frost_bits = framebuf.FrameBuffer(image_weather_frost_bits, 15, 16, framebuf.MONO_HLSB)
|
fb_image_weather_frost_bits = framebuf.FrameBuffer(image_weather_frost_bits, 15, 16, framebuf.MONO_HLSB)
|
||||||
fb_image_CoolHiSmall_bits = framebuf.FrameBuffer(image_CoolHiSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
fb_image_CoolHiSmall_bits = framebuf.FrameBuffer(image_CoolHiSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||||
fb_image_weather_temperature_bits = framebuf.FrameBuffer(image_weather_temperature_bits, 9, 16, framebuf.MONO_HLSB)
|
fb_image_weather_temperature_bits = framebuf.FrameBuffer(image_weather_temperature_bits, 9, 16, framebuf.MONO_HLSB)
|
||||||
fb_image_Alert_bits = framebuf.FrameBuffer(image_Alert_bits, 9, 8, framebuf.MONO_HLSB)
|
fb_image_Alert_bits = framebuf.FrameBuffer(image_Alert_bits, 9, 8, framebuf.MONO_HLSB)
|
||||||
fb_image_music_sound_wave_bits = framebuf.FrameBuffer(image_music_sound_wave_bits, 17, 16, framebuf.MONO_HLSB)
|
fb_image_music_sound_wave_bits = framebuf.FrameBuffer(image_music_sound_wave_bits, 17, 16, framebuf.MONO_HLSB)
|
||||||
fb_image_CoolLoSmall_bits = framebuf.FrameBuffer(image_CoolLoSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
fb_image_CoolLoSmall_bits = framebuf.FrameBuffer(image_CoolLoSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||||
|
fb_image_camera_bits = framebuf.FrameBuffer(image_camera_bits, 17, 16, framebuf.MONO_HLSB)
|
||||||
|
fb_image_cloud_sync_bits = framebuf.FrameBuffer(image_cloud_sync_bits, 17, 16, framebuf.MONO_HLSB)
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
CHARACTER_WIDTH = 6
|
CHARACTER_WIDTH = 6
|
||||||
@@ -70,44 +75,54 @@ class MicrowaveScreen:
|
|||||||
|
|
||||||
self.display.fill_rect(x, y, fill_width, height, color)
|
self.display.fill_rect(x, y, fill_width, height, color)
|
||||||
|
|
||||||
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False):
|
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False, microwave_state: str | None = None):
|
||||||
self.flick()
|
self.flick()
|
||||||
|
|
||||||
# self.lastUpdateTime = time.time() # Not Used right now
|
# self.lastUpdateTime = time.time() # Not Used right now
|
||||||
|
|
||||||
if not cooking_state or cooking_state.state == CookingStates.IDLE: # Is not currently cooking
|
if (not microwave_state or microwave_state == MicrowaveState.DONE) and (not cooking_state or cooking_state.state == CookingStates.IDLE): # Is not currently cooking
|
||||||
self.h_centered_text("Ready !", 28, 1)
|
self.h_centered_text("Ready !", 28, 1)
|
||||||
else:
|
else:
|
||||||
# Screen center
|
# Screen center
|
||||||
if cooking_state.state == CookingStates.STIRRING_REQUIRED:
|
if cooking_state and cooking_state.state == CookingStates.STIRRING_REQUIRED:
|
||||||
self._message("Pls Stir", True)
|
self._message("Pls Stir", True)
|
||||||
elif cooking_state.state == CookingStates.ALERT:
|
elif cooking_state and cooking_state.state == CookingStates.ALERT:
|
||||||
self._message("Alert !", True)
|
# nothing to do, the alert will be displayed by the alert manager
|
||||||
elif cooking_state.state == CookingStates.DONE:
|
return
|
||||||
|
elif cooking_state and cooking_state.state == CookingStates.DONE:
|
||||||
self._message("Done !", False)
|
self._message("Done !", False)
|
||||||
else: # Progress bar
|
elif microwave_state and microwave_state == MicrowaveState.ANALYZING:
|
||||||
|
self.display.blit(fb_image_camera_bits, 56, 24)
|
||||||
|
self._secondary_message("Analyzing...", False)
|
||||||
|
elif microwave_state and microwave_state == MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
self.display.blit(fb_image_cloud_sync_bits, 55, 24)
|
||||||
|
self._secondary_message("Receiving...", False)
|
||||||
|
elif microwave_state and microwave_state == MicrowaveState.ALERT:
|
||||||
|
# nothing to do, the alert will be displayed by the alert manager
|
||||||
|
pass
|
||||||
|
elif cooking_state: # Progress bar
|
||||||
elapsed_time = cooking_state.get_elapsed_time()
|
elapsed_time = cooking_state.get_elapsed_time()
|
||||||
estimated_progress = elapsed_time / (elapsed_time + cooking_state.estimated_remaining_time)
|
estimated_progress = elapsed_time / (elapsed_time + cooking_state.estimated_remaining_time)
|
||||||
self._progressBar(estimated_progress, 21, 25)
|
self._progressBar(estimated_progress, 21, 25)
|
||||||
|
|
||||||
# Estimated remaining time
|
# Estimated remaining time
|
||||||
if cooking_state.paused:
|
if cooking_state and cooking_state.paused:
|
||||||
self.h_centered_text("Paused", 40, 1)
|
self.h_centered_text("Paused", 40, 1)
|
||||||
elif cooking_state.state != CookingStates.DONE:
|
elif cooking_state and cooking_state.state != CookingStates.DONE:
|
||||||
remaining_time = int(cooking_state.estimated_remaining_time)
|
remaining_time = int(cooking_state.estimated_remaining_time)
|
||||||
minutes = remaining_time // 60
|
minutes = remaining_time // 60
|
||||||
seconds = remaining_time % 60
|
seconds = remaining_time % 60
|
||||||
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
|
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
|
||||||
|
|
||||||
# Current dish temperature
|
# Current dish temperature
|
||||||
if cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
|
if cooking_state and cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
|
||||||
self.display.blit(fb_image_weather_temperature_bits, 27, 2)
|
self.display.blit(fb_image_weather_temperature_bits, 27, 2)
|
||||||
self.display.text(str(int(cooking_state.current_dish_temp)), 37, 6, 1)
|
self.display.text(str(int(cooking_state.current_dish_temp)), 37, 6, 1)
|
||||||
self.display.ellipse(54, 6, 1, 1, 1, False)
|
self.display.ellipse(54, 6, 1, 1, 1, False)
|
||||||
self.display.text("C", 56, 6, 1)
|
self.display.text("C", 56, 6, 1)
|
||||||
|
|
||||||
# Power level
|
# Power level
|
||||||
if cooking_state.power_level is not None:
|
if cooking_state and cooking_state.power_level is not None:
|
||||||
self.display.blit(fb_image_music_sound_wave_bits, 74, 2)
|
self.display.blit(fb_image_music_sound_wave_bits, 74, 2)
|
||||||
self.display.text(f"{cooking_state.power_level}W", 92, 6, 1)
|
self.display.text(f"{cooking_state.power_level}W", 92, 6, 1)
|
||||||
if defrost_mode:
|
if defrost_mode:
|
||||||
@@ -118,12 +133,18 @@ class MicrowaveScreen:
|
|||||||
self.show()
|
self.show()
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _message(self, text, alert):
|
def _message_at(self, y, text, alert):
|
||||||
self.display.fill_rect(0, 27, self.width, CHARACTER_HEIGHT + 4, 1)
|
self.display.fill_rect(0, y, self.width, CHARACTER_HEIGHT + 4, 1)
|
||||||
if alert:
|
if alert:
|
||||||
self.display.blit(fb_image_Alert_bits, 10, 29)
|
self.display.blit(fb_image_Alert_bits, 1, y+2)
|
||||||
self.display.blit(fb_image_Alert_bits, self.width - 10, 29)
|
self.display.blit(fb_image_Alert_bits, self.width - 10, y+2)
|
||||||
self.h_centered_text(text, 29, 0)
|
self.h_centered_text(text, y+2, 0)
|
||||||
|
|
||||||
|
def _message(self, text, alert):
|
||||||
|
self._message_at(27, text, alert)
|
||||||
|
|
||||||
|
def _secondary_message(self, text, alert):
|
||||||
|
self._message_at(40, text, alert)
|
||||||
|
|
||||||
def message(self, text, alert):
|
def message(self, text, alert):
|
||||||
# self.flick()
|
# self.flick()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import gc
|
import gc
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import _thread
|
|
||||||
from lib.microwaveScreen import MicrowaveScreen
|
|
||||||
import uasyncio as asyncio
|
import uasyncio as asyncio
|
||||||
from machine import Pin, SoftI2C
|
from machine import Pin, SoftI2C
|
||||||
import ssd1306
|
import ssd1306
|
||||||
@@ -18,6 +16,9 @@ from shared.sensors import RGBLED
|
|||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
from shared.lora_device import LoraCommands
|
from shared.lora_device import LoraCommands
|
||||||
from shared.alerts import Alert, AlertType, AlertManager
|
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 ---
|
# --- READ DEVICE ID ---
|
||||||
try:
|
try:
|
||||||
@@ -29,6 +30,7 @@ except Exception:
|
|||||||
# --- GLOBAL VARIABLES ---
|
# --- GLOBAL VARIABLES ---
|
||||||
cooking_state = None
|
cooking_state = None
|
||||||
cooking_start_time = None # Track start timestamp
|
cooking_start_time = None # Track start timestamp
|
||||||
|
microwave_state: str = None
|
||||||
data_queue = SafeQueue()
|
data_queue = SafeQueue()
|
||||||
lora = None
|
lora = None
|
||||||
uart_device = None
|
uart_device = None
|
||||||
@@ -75,7 +77,7 @@ def send_cooking_update(state=None, start_time=None, estimated_remaining_time=No
|
|||||||
def on_new_alert(alert: Alert):
|
def on_new_alert(alert: Alert):
|
||||||
global microwave_screen
|
global microwave_screen
|
||||||
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
|
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
|
||||||
microwave_screen.message(alert.message, True)
|
microwave_screen.message(alert.small_message if alert.small_message else alert.message, True)
|
||||||
|
|
||||||
def init_hardware():
|
def init_hardware():
|
||||||
"""Initializes all hardware components."""
|
"""Initializes all hardware components."""
|
||||||
@@ -110,10 +112,9 @@ def init_hardware():
|
|||||||
|
|
||||||
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
||||||
|
|
||||||
|
# --- ASYNC LORA TASK (REPLACES _THREAD) ---
|
||||||
# --- DEDICATED LORA HARDWARE THREAD ---
|
async def lora_rx_and_heartbeat_task():
|
||||||
def lora_hardware_thread():
|
"""Replaces the hardware OS thread with a non-blocking async task to avoid SPI collisions."""
|
||||||
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
|
|
||||||
last_heartbeat_time = 0
|
last_heartbeat_time = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -122,19 +123,22 @@ def lora_hardware_thread():
|
|||||||
# 1. Send periodic heartbeat
|
# 1. Send periodic heartbeat
|
||||||
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||||
last_heartbeat_time = now
|
last_heartbeat_time = now
|
||||||
log("\n[LoRa Thread] Sending Heartbeat...")
|
log("\n[LoRa Task] Sending Heartbeat...")
|
||||||
if lora:
|
if lora:
|
||||||
lora.send(PING_PAYLOAD)
|
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. Blocking 300ms RX listen window (keeps radio actively listening)
|
# 2. Listen for incoming packets (Must be longer than LoRa Time-on-Air)
|
||||||
if lora:
|
if lora:
|
||||||
paquet = lora.receive_reliable(timeout_ms=300)
|
paquet = lora.receive_reliable(timeout_ms=500)
|
||||||
if paquet is not None:
|
if paquet is not None:
|
||||||
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
log(f"[LoRa Task] New Packet Received: {paquet}")
|
||||||
data_queue.put(paquet)
|
data_queue.put(paquet)
|
||||||
|
|
||||||
time.sleep_ms(10)
|
# Give control back to event loop
|
||||||
|
await asyncio.sleep_ms(10)
|
||||||
|
|
||||||
# --- COOKING STATE CALLBACKS ---
|
# --- COOKING STATE CALLBACKS ---
|
||||||
def cooking_state_temperature_provider():
|
def cooking_state_temperature_provider():
|
||||||
@@ -166,6 +170,21 @@ def cooking_state_temperature_provider():
|
|||||||
temps = [float(current_temp[0]), float(current_temp[1])]
|
temps = [float(current_temp[0]), float(current_temp[1])]
|
||||||
last_temp = [temps[0], temps[1]]
|
last_temp = [temps[0], temps[1]]
|
||||||
current_temp = [None, None]
|
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
|
return temps
|
||||||
|
|
||||||
# 4. Fallback: Use last valid reading
|
# 4. Fallback: Use last valid reading
|
||||||
@@ -179,7 +198,7 @@ def cooking_state_on_state_change(state):
|
|||||||
global cooking_start_time
|
global cooking_start_time
|
||||||
print(f"[CookingState] State changed to: {state.state}")
|
print(f"[CookingState] State changed to: {state.state}")
|
||||||
|
|
||||||
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
if state.paused or state.state in (cookingState.CookingStates.DONE, cookingState.CookingStates.IDLE, cookingState.CookingStates.ALERT):
|
||||||
magnetron_led.off()
|
magnetron_led.off()
|
||||||
else:
|
else:
|
||||||
magnetron_led.on()
|
magnetron_led.on()
|
||||||
@@ -196,12 +215,11 @@ def cooking_state_on_state_change(state):
|
|||||||
paused=state.paused
|
paused=state.paused
|
||||||
)
|
)
|
||||||
|
|
||||||
microwave_screen.update(cooking_state, defrost_mode)
|
update_screen()
|
||||||
|
|
||||||
def cooking_state_on_refresh(state):
|
def cooking_state_on_refresh(state):
|
||||||
# Update OLED display
|
# Update OLED display
|
||||||
if microwave_screen:
|
update_screen()
|
||||||
microwave_screen.update(state, defrost_mode)
|
|
||||||
|
|
||||||
def cooking_state_on_pause(state):
|
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 the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
||||||
@@ -215,12 +233,19 @@ def cooking_state_on_pause(state):
|
|||||||
estimated_remaining_time=state.get_remaining_time()
|
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 TASKS ---
|
||||||
|
|
||||||
async def uart_polling_task():
|
async def uart_polling_task():
|
||||||
"""Polls UART for incoming messages from the WiFi board."""
|
"""Polls UART for incoming messages from the WiFi board."""
|
||||||
global cooking_state, cooking_start_time, temperature_asked
|
global cooking_state, cooking_start_time, temperature_asked, microwave_state
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if uart_device and uart_device.any():
|
if uart_device and uart_device.any():
|
||||||
@@ -248,6 +273,7 @@ async def uart_polling_task():
|
|||||||
|
|
||||||
await asyncio.sleep_ms(200)
|
await asyncio.sleep_ms(200)
|
||||||
cooking_state_on_state_change(cooking_state)
|
cooking_state_on_state_change(cooking_state)
|
||||||
|
microwave_state = MicrowaveState.COOKING
|
||||||
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
|
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
|
||||||
try:
|
try:
|
||||||
# Check if payload is already a dict or needs JSON decoding
|
# Check if payload is already a dict or needs JSON decoding
|
||||||
@@ -266,7 +292,7 @@ async def uart_polling_task():
|
|||||||
|
|
||||||
async def lora_process_task():
|
async def lora_process_task():
|
||||||
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
|
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
|
||||||
global cooking_state, defrost_mode, microwave_screen
|
global cooking_state, defrost_mode, microwave_screen, microwave_state
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
while not data_queue.empty():
|
while not data_queue.empty():
|
||||||
@@ -274,31 +300,50 @@ async def lora_process_task():
|
|||||||
if paquet and not paquet.get("raw"):
|
if paquet and not paquet.get("raw"):
|
||||||
data = paquet.get("data", {})
|
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:
|
if "action" in data:
|
||||||
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
action = data["action"]
|
||||||
|
if action == LoraCommands.TOGGLE_PAUSE:
|
||||||
if cooking_state is not None:
|
if cooking_state is not None:
|
||||||
if cooking_state.state == cookingState.CookingStates.DONE:
|
if cooking_state.state == cookingState.CookingStates.DONE:
|
||||||
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.")
|
print("[LoRa Process] Cooking is done. Resetting microwave for next session.")
|
||||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||||
await asyncio.sleep_ms(20)
|
await asyncio.sleep_ms(20)
|
||||||
cooking_state = None
|
cooking_state = None
|
||||||
else:
|
else:
|
||||||
cooking_state.toggle_pause()
|
cooking_state.toggle_pause()
|
||||||
if cooking_state.paused:
|
print(f"[LoRa Process] Cooking paused state toggled to {cooking_state.paused}")
|
||||||
print("[LoRa Process] Cooking paused via orchestrator command.")
|
|
||||||
else:
|
|
||||||
print("[LoRa Process] Cooking resumed via orchestrator command.")
|
|
||||||
else:
|
else:
|
||||||
log("[LoRa Process] No active cooking state to toggle pause/resume.")
|
log("[LoRa Process] No active cooking state to toggle pause/resume.")
|
||||||
elif data["action"] == LoraCommands.TOGGLE_DEFROST:
|
|
||||||
|
elif action == LoraCommands.TOGGLE_DEFROST:
|
||||||
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
||||||
defrost_mode = data["defrost_state"]
|
defrost_mode = data.get("defrost_state", False)
|
||||||
microwave_screen.update(cooking_state, defrost_mode)
|
update_screen()
|
||||||
elif data["action"] == LoraCommands.NEW_ALERT:
|
|
||||||
alert = Alert(data.get("alert_type"), data.get("message", "Unsafe area"))
|
elif action == LoraCommands.NEW_ALERT:
|
||||||
alert.timestamp = data.get("timestamp", time.time())
|
alert: Alert | None = None
|
||||||
print(f"[LoRa Process] New alert received via orchestrator: {alert.to_dict()}")
|
if data.get("alert_type") == AlertType.TEMPERATURE_ALERT:
|
||||||
alert_manager.add_alert(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()
|
||||||
|
|
||||||
await asyncio.sleep_ms(100)
|
await asyncio.sleep_ms(100)
|
||||||
|
|
||||||
@@ -334,23 +379,16 @@ async def memory_cleanup_task():
|
|||||||
|
|
||||||
# --- BOOTSTRAP ---
|
# --- BOOTSTRAP ---
|
||||||
async def main():
|
async def main():
|
||||||
global cooking_state, defrost_mode, microwave_screen, alert_manager
|
global alert_manager
|
||||||
print("[Main] Starting application...")
|
print("[Main] Starting application...")
|
||||||
|
|
||||||
init_hardware()
|
init_hardware()
|
||||||
|
|
||||||
alert_manager = AlertManager()
|
alert_manager = AlertManager()
|
||||||
alert_manager.set_on_alert_callback(on_new_alert)
|
alert_manager.set_on_alert_callback(on_new_alert)
|
||||||
|
|
||||||
# Launch dedicated hardware thread for LoRa RX
|
|
||||||
try:
|
|
||||||
_thread.stack_size(16 * 1024)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_thread.start_new_thread(lora_hardware_thread, ())
|
|
||||||
print("[Main] LoRa hardware background thread started.")
|
|
||||||
|
|
||||||
# Launch background async tasks
|
# 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(uart_polling_task())
|
||||||
asyncio.create_task(lora_process_task())
|
asyncio.create_task(lora_process_task())
|
||||||
asyncio.create_task(cooking_loop_task())
|
asyncio.create_task(cooking_loop_task())
|
||||||
@@ -358,8 +396,7 @@ async def main():
|
|||||||
|
|
||||||
print("[Main] All async tasks running concurrently!")
|
print("[Main] All async tasks running concurrently!")
|
||||||
|
|
||||||
microwave_screen.update(None, defrost_mode)
|
update_screen()
|
||||||
|
|
||||||
# Keep main task alive indefinitely
|
# Keep main task alive indefinitely
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
|
|||||||
+50
-14
@@ -4,7 +4,7 @@ import time
|
|||||||
import asyncio
|
import asyncio
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from orchestrateur.microwave_state import MicrowaveState
|
from shared.microwave_state import MicrowaveState, MicrowaveStateFields
|
||||||
from orchestrateur.sensors import gps
|
from orchestrateur.sensors import gps
|
||||||
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
@@ -81,6 +81,15 @@ def notify_display_update():
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
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):
|
def set_cloud_alert(state: bool):
|
||||||
"""Setter for cloud_alert that triggers a display update when changed."""
|
"""Setter for cloud_alert that triggers a display update when changed."""
|
||||||
global cloud_alert
|
global cloud_alert
|
||||||
@@ -91,7 +100,7 @@ def set_cloud_alert(state: bool):
|
|||||||
update_lcd_cloud_alert()
|
update_lcd_cloud_alert()
|
||||||
|
|
||||||
# Global state trackers
|
# Global state trackers
|
||||||
microwave_states = {"2": MicrowaveState("2", on_change_callback=notify_display_update)}
|
microwave_states = {"2": MicrowaveState("2", on_change_callback=on_microwave_change)}
|
||||||
button_state = False
|
button_state = False
|
||||||
cloud_alert = None # Global status flag for screen / UI display
|
cloud_alert = None # Global status flag for screen / UI display
|
||||||
async_event_queue = None
|
async_event_queue = None
|
||||||
@@ -257,13 +266,13 @@ async def display_broadcast_worker_task():
|
|||||||
def button_callback():
|
def button_callback():
|
||||||
"""Button physical interrupt callback."""
|
"""Button physical interrupt callback."""
|
||||||
global button_state
|
global button_state
|
||||||
if microwave_states.get("2").cooking_state == CookingStates.COOKING or microwave_states.get("2").cooking_state == CookingStates.DONE or microwave_states.get("2").cooking_state == CookingStates.STIRRING_REQUIRED:
|
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'.")
|
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}, max_retries=6)
|
||||||
else:
|
else:
|
||||||
button_state = not button_state
|
button_state = not button_state
|
||||||
print(f"[Button] Defrost state toggled to: {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})
|
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.set_callback(button_callback)
|
||||||
button.start_button_monitoring_thread()
|
button.start_button_monitoring_thread()
|
||||||
@@ -271,7 +280,7 @@ button.start_button_monitoring_thread()
|
|||||||
# --- Alert manager ---
|
# --- Alert manager ---
|
||||||
alert_manager = None
|
alert_manager = None
|
||||||
|
|
||||||
def on_new_alert(alert):
|
def on_new_alert(alert: Alert):
|
||||||
# Sends to cloud server
|
# Sends to cloud server
|
||||||
alert_dict = alert.to_dict()
|
alert_dict = alert.to_dict()
|
||||||
alert_dict["orchestrator_id"] = DEVICE_ID
|
alert_dict["orchestrator_id"] = DEVICE_ID
|
||||||
@@ -285,10 +294,12 @@ def on_new_alert(alert):
|
|||||||
res_json = response.json()
|
res_json = response.json()
|
||||||
print(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
|
print(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
|
||||||
|
|
||||||
# Send the alert to the lora device
|
if not alert.redistributed: # If not redistributed from microwaves
|
||||||
lora.send_reliable(payloads.lora_new_alert(alert))
|
# Send the alert to the lora device
|
||||||
|
payload = payloads.lora_new_alert(alert)
|
||||||
microwave_states["2"].set_cooking_state(CookingStates.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 ---
|
# --- HARDWARE CONTROLLERS ---
|
||||||
def _stop_hardware(microwave_id: str):
|
def _stop_hardware(microwave_id: str):
|
||||||
@@ -311,6 +322,14 @@ def read_local_sensors(microwave_id, initial_dish_height):
|
|||||||
if temp is not None:
|
if temp is not None:
|
||||||
sensor_data["temperature"] = temp
|
sensor_data["temperature"] = temp
|
||||||
sensor_data["humidity"] = hum
|
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}%"
|
||||||
|
)
|
||||||
|
lora.send_reliable(payloads.lora_new_alert(alert), max_retries=5)
|
||||||
|
return None # Abort further processing if unsafe temperature detected
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"[{microwave_id}] DHT read warning: {e}")
|
log(f"[{microwave_id}] DHT read warning: {e}")
|
||||||
|
|
||||||
@@ -345,6 +364,11 @@ async def handle_new_dish(microwave_id, detected_height):
|
|||||||
|
|
||||||
# 4. Wait for local sensors to finish reading
|
# 4. Wait for local sensors to finish reading
|
||||||
sensors_data = await sensor_task
|
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
|
# Check if dish was removed while reading sensors
|
||||||
if microwave_states.get(microwave_id).state != MicrowaveState.ANALYZING:
|
if microwave_states.get(microwave_id).state != MicrowaveState.ANALYZING:
|
||||||
@@ -434,7 +458,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
|
|
||||||
if c_time is None or c_power is None or c_temp is None:
|
if c_time is None or c_power is None or c_temp is None:
|
||||||
print(f"[{microwave_id}] Invalid plan received: {res_json}")
|
print(f"[{microwave_id}] Invalid plan received: {res_json}")
|
||||||
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
|
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||||
@@ -501,6 +525,9 @@ async def process_messages_task():
|
|||||||
microwave_states[mw_id].set_state(MicrowaveState.DONE)
|
microwave_states[mw_id].set_state(MicrowaveState.DONE)
|
||||||
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
||||||
if "action" in data.get("data", {}):
|
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"]
|
action = data["data"]["action"]
|
||||||
if action == LoraCommands.COOKING_UPDATE:
|
if action == LoraCommands.COOKING_UPDATE:
|
||||||
mw_id = data["data"].get("id")
|
mw_id = data["data"].get("id")
|
||||||
@@ -509,6 +536,12 @@ async def process_messages_task():
|
|||||||
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
|
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_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))
|
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:
|
else:
|
||||||
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
|
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
|
||||||
elif source == "MQTT":
|
elif source == "MQTT":
|
||||||
@@ -530,7 +563,7 @@ async def process_messages_task():
|
|||||||
# Pass change callback to newly connected microwave
|
# Pass change callback to newly connected microwave
|
||||||
microwave_states[component_id] = MicrowaveState(
|
microwave_states[component_id] = MicrowaveState(
|
||||||
component_id,
|
component_id,
|
||||||
on_change_callback=notify_display_update
|
on_change_callback=on_microwave_change
|
||||||
)
|
)
|
||||||
notify_display_update() # Update the external screens
|
notify_display_update() # Update the external screens
|
||||||
update_lcd_microwave_count() # Update the LCD with new microwave count
|
update_lcd_microwave_count() # Update the LCD with new microwave count
|
||||||
@@ -641,8 +674,11 @@ async def monitor_dish_height_task():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
dist = await get_filtered_dish_height()
|
dist = await get_filtered_dish_height()
|
||||||
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE).state
|
try:
|
||||||
|
current_state = microwave_states.get(mw_id).state
|
||||||
|
except AttributeError:
|
||||||
|
current_state = MicrowaveState.IDLE
|
||||||
|
|
||||||
if dist is not None:
|
if dist is not None:
|
||||||
# Hysteresis Thresholds:
|
# Hysteresis Thresholds:
|
||||||
# - Must be > 2.5 cm to detect dish insertion
|
# - Must be > 2.5 cm to detect dish insertion
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import shared.payloads as payloads
|
|||||||
import shared.cookingState as cookingState
|
import shared.cookingState as cookingState
|
||||||
import shared.safeQueue as safeQueue
|
import shared.safeQueue as safeQueue
|
||||||
import shared.alerts as alerts
|
import shared.alerts as alerts
|
||||||
|
import shared.microwave_state as microwave_state
|
||||||
try:
|
try:
|
||||||
import shared.lora_device as lora_device
|
import shared.lora_device as lora_device
|
||||||
except (ImportError, SyntaxError):
|
except (ImportError, SyntaxError):
|
||||||
|
|||||||
+5
-2
@@ -20,10 +20,12 @@ class AlertManager:
|
|||||||
self.alerts.clear()
|
self.alerts.clear()
|
||||||
|
|
||||||
class Alert:
|
class Alert:
|
||||||
def __init__(self, alertType: "AlertType", message: str):
|
def __init__(self, alertType: "AlertType", message: str, redistributed: bool = False, small_message: str = None):
|
||||||
self.alertType = alertType
|
self.alertType = alertType
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.small_message = small_message
|
||||||
self.timestamp = time.time()
|
self.timestamp = time.time()
|
||||||
|
self.redistributed = redistributed
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -44,4 +46,5 @@ class Alert:
|
|||||||
return alert
|
return alert
|
||||||
|
|
||||||
class AlertType:
|
class AlertType:
|
||||||
COOKING_SAFETY = "COOKING_SAFETY"
|
COOKING_SAFETY = "COOKING_SAFETY"
|
||||||
|
TEMPERATURE_ALERT = "TEMPERATURE_ALERT"
|
||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
# Debugging and others
|
# Debugging and others
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
DEBUG_DANGEROUS_AREA=False
|
DEBUG_DANGEROUS_AREA=False
|
||||||
|
DEBUG_TEMPERATURE_ALERT=True
|
||||||
|
|
||||||
# LoRa
|
# LoRa
|
||||||
LORA_HEARTBEAT_INTERVAL = 30
|
LORA_HEARTBEAT_INTERVAL = 30
|
||||||
@@ -19,8 +20,9 @@ MQTT_HELLO_INTERVAL = 30
|
|||||||
|
|
||||||
# Microwave Model
|
# Microwave Model
|
||||||
COOKING_COMPARTMENT_HEIGHT = 30 # cm
|
COOKING_COMPARTMENT_HEIGHT = 30 # cm
|
||||||
|
|
||||||
BUZZER_ACTIVATED = False
|
BUZZER_ACTIVATED = False
|
||||||
|
MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT = 70
|
||||||
|
MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT = 200
|
||||||
|
|
||||||
# TELEMETRY
|
# TELEMETRY
|
||||||
TELEMETRY_SEND_INTERVAL = 3
|
TELEMETRY_SEND_INTERVAL = 3
|
||||||
+109
-89
@@ -27,6 +27,12 @@ class BaseLoraDevice:
|
|||||||
self.received_acks = set()
|
self.received_acks = set()
|
||||||
self.pending_rx_queue = []
|
self.pending_rx_queue = []
|
||||||
self.default_group = 2
|
self.default_group = 2
|
||||||
|
|
||||||
|
# NEW: Global TX lock to prevent concurrent overlapping transmissions
|
||||||
|
if IS_MICROPYTHON:
|
||||||
|
self.tx_lock = _thread.allocate_lock()
|
||||||
|
else:
|
||||||
|
self.tx_lock = threading.Lock()
|
||||||
|
|
||||||
def _generate_msg_id(self):
|
def _generate_msg_id(self):
|
||||||
return random.getrandbits(16)
|
return random.getrandbits(16)
|
||||||
@@ -77,51 +83,49 @@ class BaseLoraDevice:
|
|||||||
|
|
||||||
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
|
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
|
||||||
"""Sends a payload and listens in a single continuous RX window for the ACK."""
|
"""Sends a payload and listens in a single continuous RX window for the ACK."""
|
||||||
lock = getattr(self, 'lock', None)
|
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
with self.tx_lock:
|
||||||
payload = dict(payload)
|
lock = getattr(self, 'lock', None)
|
||||||
else:
|
|
||||||
payload = {"data": payload}
|
if isinstance(payload, dict):
|
||||||
|
payload = dict(payload)
|
||||||
|
else:
|
||||||
|
payload = {"data": payload}
|
||||||
|
|
||||||
msg_id = self._generate_msg_id()
|
msg_id = self._generate_msg_id()
|
||||||
payload["_msg_id"] = msg_id
|
payload["_msg_id"] = msg_id
|
||||||
|
|
||||||
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||||
self.send(payload)
|
self.send(payload)
|
||||||
|
|
||||||
# 1. Open a single continuous RX window for the full timeout duration
|
timeout_ms = int(ack_timeout * 1000)
|
||||||
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
|
packet = self.receive_packet(timeout_ms=timeout_ms)
|
||||||
timeout_ms = int(ack_timeout * 1000)
|
|
||||||
packet = self.receive_packet(timeout_ms=timeout_ms)
|
if packet:
|
||||||
|
if lock: lock.acquire()
|
||||||
|
try:
|
||||||
|
filtered_packet = self._process_incoming_packet(packet)
|
||||||
|
if filtered_packet:
|
||||||
|
self.pending_rx_queue.append(filtered_packet)
|
||||||
|
finally:
|
||||||
|
if lock: lock.release()
|
||||||
|
|
||||||
# 2. Process incoming packet if received
|
|
||||||
if packet:
|
|
||||||
if lock: lock.acquire()
|
if lock: lock.acquire()
|
||||||
try:
|
try:
|
||||||
filtered_packet = self._process_incoming_packet(packet)
|
if msg_id in self.received_acks:
|
||||||
if filtered_packet:
|
self.received_acks.remove(msg_id)
|
||||||
self.pending_rx_queue.append(filtered_packet)
|
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||||
|
return True
|
||||||
finally:
|
finally:
|
||||||
if lock: lock.release()
|
if lock: lock.release()
|
||||||
|
|
||||||
# 3. Check if matching ACK was received
|
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||||
if lock: lock.acquire()
|
|
||||||
try:
|
|
||||||
if msg_id in self.received_acks:
|
|
||||||
self.received_acks.remove(msg_id)
|
|
||||||
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
if lock: lock.release()
|
|
||||||
|
|
||||||
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
||||||
|
return False
|
||||||
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def receive_reliable(self, timeout_ms=1000):
|
def receive_reliable(self, timeout_ms=1000):
|
||||||
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
|
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
|
||||||
@@ -296,19 +300,28 @@ else:
|
|||||||
self.ser.reset_output_buffer()
|
self.ser.reset_output_buffer()
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
# Initial configuration
|
self._emergency_rx_buffer = []
|
||||||
|
|
||||||
self.configure(freq=868.1, sf=7, bw=125)
|
self.configure(freq=868.1, sf=7, bw=125)
|
||||||
|
|
||||||
def _send_at_cmd(self, cmd, timeout=1.5):
|
def _send_at_cmd(self, cmd, timeout=1.5):
|
||||||
"""Sends AT command and reads response until line received or timeout."""
|
"""Sends AT command and safely handles async incoming packets."""
|
||||||
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
resp = ""
|
resp = ""
|
||||||
while (time.time() - start) < timeout:
|
while (time.time() - start) < timeout:
|
||||||
if self.ser.in_waiting > 0:
|
if self.ser.in_waiting > 0:
|
||||||
line = self.ser.readline().decode('utf-8', errors='ignore')
|
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
||||||
resp += line
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(marker in line for marker in ["+RECV:", "+RCV=", "+DRX:", "(HEX:)", "Data:"]):
|
||||||
|
self._emergency_rx_buffer.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp += line + "\n"
|
||||||
if "OK" in line or "ERROR" in line or "AT_BUSY" in line:
|
if "OK" in line or "ERROR" in line or "AT_BUSY" in line:
|
||||||
break
|
break
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
@@ -364,64 +377,70 @@ else:
|
|||||||
timeout_s = timeout_ms / 1000.0
|
timeout_s = timeout_ms / 1000.0
|
||||||
|
|
||||||
while (time.time() - start_time) < timeout_s:
|
while (time.time() - start_time) < timeout_s:
|
||||||
if self.ser.in_waiting > 0:
|
line = ""
|
||||||
|
|
||||||
|
# NEW: Process emergency buffer first before checking serial
|
||||||
|
if self._emergency_rx_buffer:
|
||||||
|
line = self._emergency_rx_buffer.pop(0)
|
||||||
|
elif self.ser.in_waiting > 0:
|
||||||
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
||||||
if line:
|
|
||||||
payload_bytes = None
|
if line:
|
||||||
|
payload_bytes = None
|
||||||
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
|
|
||||||
if "+RECV:" in line:
|
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
|
||||||
parts = line.split("+RECV:")[1].strip().split(",")
|
if "+RECV:" in line:
|
||||||
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
|
parts = line.split("+RECV:")[1].strip().split(",")
|
||||||
try: payload_bytes = bytes.fromhex(hex_str)
|
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
|
||||||
|
try: payload_bytes = bytes.fromhex(hex_str)
|
||||||
|
except ValueError: pass
|
||||||
|
elif "+RCV=" in line:
|
||||||
|
parts = line.split("+RCV=")[1].strip().split(",")
|
||||||
|
if len(parts) >= 4:
|
||||||
|
try: payload_bytes = bytes.fromhex(parts[3].strip())
|
||||||
except ValueError: pass
|
except ValueError: pass
|
||||||
elif "+RCV=" in line:
|
elif "+DRX:" in line:
|
||||||
parts = line.split("+RCV=")[1].strip().split(",")
|
parts = line.split("+DRX:")[1].strip().split(",")
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 2:
|
||||||
try: payload_bytes = bytes.fromhex(parts[3].strip())
|
try: payload_bytes = bytes.fromhex(parts[1].strip())
|
||||||
except ValueError: pass
|
|
||||||
elif "+DRX:" in line:
|
|
||||||
parts = line.split("+DRX:")[1].strip().split(",")
|
|
||||||
if len(parts) >= 2:
|
|
||||||
try: payload_bytes = bytes.fromhex(parts[1].strip())
|
|
||||||
except ValueError: pass
|
|
||||||
elif "(HEX:)" in line:
|
|
||||||
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
|
|
||||||
try: payload_bytes = bytes.fromhex(hex_part)
|
|
||||||
except ValueError: pass
|
except ValueError: pass
|
||||||
elif "Data:" in line:
|
elif "(HEX:)" in line:
|
||||||
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
|
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
|
||||||
|
try: payload_bytes = bytes.fromhex(hex_part)
|
||||||
|
except ValueError: pass
|
||||||
|
elif "Data:" in line:
|
||||||
|
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
|
||||||
|
|
||||||
|
if payload_bytes and len(payload_bytes) > 0:
|
||||||
|
if payload_bytes[0] in (0x7B, 0x5B):
|
||||||
|
group = self.default_group
|
||||||
|
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
|
||||||
|
elif len(payload_bytes) > 1:
|
||||||
|
group = payload_bytes[0]
|
||||||
|
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
if payload_bytes and len(payload_bytes) > 0:
|
try:
|
||||||
if payload_bytes[0] in (0x7B, 0x5B):
|
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
|
||||||
group = self.default_group
|
except UnicodeError:
|
||||||
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
|
continue
|
||||||
elif len(payload_bytes) > 1:
|
|
||||||
group = payload_bytes[0]
|
if text.startswith('{') or text.startswith('['):
|
||||||
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
|
decoded_text = text
|
||||||
else:
|
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
|
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
|
||||||
except UnicodeError:
|
except Exception:
|
||||||
continue
|
|
||||||
|
|
||||||
if text.startswith('{') or text.startswith('['):
|
|
||||||
decoded_text = text
|
decoded_text = text
|
||||||
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
|
else:
|
||||||
try:
|
decoded_text = text
|
||||||
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
|
|
||||||
except Exception:
|
try:
|
||||||
decoded_text = text
|
parsed_json = json.loads(decoded_text)
|
||||||
else:
|
return {"group": group, "data": parsed_json, "raw": False}
|
||||||
decoded_text = text
|
except json.JSONDecodeError:
|
||||||
|
return {"group": group, "data": decoded_text, "raw": True}
|
||||||
try:
|
|
||||||
parsed_json = json.loads(decoded_text)
|
|
||||||
return {"group": group, "data": parsed_json, "raw": False}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {"group": group, "data": decoded_text, "raw": True}
|
|
||||||
|
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
return None
|
return None
|
||||||
@@ -438,6 +457,7 @@ def get_lora_device(port_or_pins=None):
|
|||||||
|
|
||||||
class LoraCommands:
|
class LoraCommands:
|
||||||
PING = "ping"
|
PING = "ping"
|
||||||
|
MICROVAVE_STATE_UPDATE = "microwave_state_update"
|
||||||
COOKING_STATE_UPDATE = "cooking_state_update"
|
COOKING_STATE_UPDATE = "cooking_state_update"
|
||||||
COOKING_UPDATE = "cooking_update"
|
COOKING_UPDATE = "cooking_update"
|
||||||
TOGGLE_PAUSE = "toggle_pause"
|
TOGGLE_PAUSE = "toggle_pause"
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from shared.cookingState import CookingStates
|
from shared.cookingState import CookingStates
|
||||||
import shared.config as config
|
import shared.config as config
|
||||||
|
|
||||||
|
class MicrowaveStateFields:
|
||||||
|
STATE = "state"
|
||||||
|
PAUSED = "paused"
|
||||||
|
COOKING_STATE = "cooking_state"
|
||||||
|
COOKING_ESTIMATED_REMAINING_TIME = "cooking_estimated_remaining_time"
|
||||||
|
COOKING_START_TIME = "cooking_start_time"
|
||||||
|
|
||||||
class MicrowaveState:
|
class MicrowaveState:
|
||||||
state = None
|
state = None
|
||||||
paused = False
|
paused = False
|
||||||
@@ -14,11 +21,11 @@ class MicrowaveState:
|
|||||||
self.cooking_state = CookingStates.IDLE
|
self.cooking_state = CookingStates.IDLE
|
||||||
self.on_change_callback = on_change_callback
|
self.on_change_callback = on_change_callback
|
||||||
|
|
||||||
def _notify_change(self):
|
def _notify_change(self, field: MicrowaveStateFields = None):
|
||||||
"""Invokes callback if registered on state attribute changes."""
|
"""Invokes callback if registered on state attribute changes."""
|
||||||
if self.on_change_callback:
|
if self.on_change_callback:
|
||||||
try:
|
try:
|
||||||
self.on_change_callback()
|
self.on_change_callback(self, field)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[MicrowaveState] Error in change callback: {e}")
|
print(f"[MicrowaveState] Error in change callback: {e}")
|
||||||
|
|
||||||
@@ -26,24 +33,24 @@ class MicrowaveState:
|
|||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
assert new_state in (
|
assert new_state in (
|
||||||
MicrowaveState.IDLE,
|
MicrowaveState.IDLE,
|
||||||
MicrowaveState.ANALYZING,
|
MicrowaveState.ANALYZING,
|
||||||
MicrowaveState.WAITING_FOR_CLOUD,
|
MicrowaveState.WAITING_FOR_CLOUD,
|
||||||
MicrowaveState.COOKING,
|
MicrowaveState.COOKING,
|
||||||
MicrowaveState.DONE
|
MicrowaveState.DONE,
|
||||||
|
MicrowaveState.ALERT,
|
||||||
), f"Invalid state: {new_state}"
|
), f"Invalid state: {new_state}"
|
||||||
|
|
||||||
if self.state != new_state:
|
if self.state != new_state:
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
# self._notify_change()
|
self._notify_change(MicrowaveStateFields.STATE)
|
||||||
|
|
||||||
def set_paused(self, is_paused):
|
def set_paused(self, is_paused):
|
||||||
if self.paused != is_paused:
|
if self.paused != is_paused:
|
||||||
self.paused = is_paused
|
self.paused = is_paused
|
||||||
self._notify_change()
|
self._notify_change(MicrowaveStateFields.PAUSED)
|
||||||
|
|
||||||
def toggle_pause(self):
|
def toggle_pause(self):
|
||||||
self.paused = not self.paused
|
self.set_paused(not self.paused)
|
||||||
self._notify_change()
|
|
||||||
|
|
||||||
def set_cooking_state(self, new_cooking_state):
|
def set_cooking_state(self, new_cooking_state):
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
@@ -56,18 +63,20 @@ class MicrowaveState:
|
|||||||
), "Invalid cooking state: " + str(new_cooking_state)
|
), "Invalid cooking state: " + str(new_cooking_state)
|
||||||
|
|
||||||
if self.cooking_state != new_cooking_state:
|
if self.cooking_state != new_cooking_state:
|
||||||
|
if self.cooking_state == CookingStates.COOKING and new_cooking_state == CookingStates.DONE:
|
||||||
|
self.set_state(MicrowaveState.DONE)
|
||||||
self.cooking_state = new_cooking_state
|
self.cooking_state = new_cooking_state
|
||||||
self._notify_change()
|
self._notify_change(MicrowaveStateFields.COOKING_STATE)
|
||||||
|
|
||||||
def set_cooking_estimated_remaining_time(self, time_seconds):
|
def set_cooking_estimated_remaining_time(self, time_seconds):
|
||||||
if self.cooking_estimated_remaining_time != time_seconds:
|
if self.cooking_estimated_remaining_time != time_seconds:
|
||||||
self.cooking_estimated_remaining_time = time_seconds
|
self.cooking_estimated_remaining_time = time_seconds
|
||||||
self._notify_change()
|
self._notify_change(MicrowaveStateFields.COOKING_ESTIMATED_REMAINING_TIME)
|
||||||
|
|
||||||
def set_cooking_start_time(self, start_time):
|
def set_cooking_start_time(self, start_time):
|
||||||
if self.cooking_start_time != start_time:
|
if self.cooking_start_time != start_time:
|
||||||
self.cooking_start_time = start_time
|
self.cooking_start_time = start_time
|
||||||
self._notify_change()
|
self._notify_change(MicrowaveStateFields.COOKING_START_TIME)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -82,4 +91,5 @@ class MicrowaveState:
|
|||||||
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
||||||
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
||||||
COOKING = "COOKING" # Microwave is active
|
COOKING = "COOKING" # Microwave is active
|
||||||
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
||||||
|
ALERT = "ALERT" # Alert state, waiting for user action
|
||||||
+19
-3
@@ -69,13 +69,29 @@ def telemetry_payload(device_id, microwave_states, button_state, cloud_alert, gp
|
|||||||
"logs": logs
|
"logs": logs
|
||||||
})
|
})
|
||||||
|
|
||||||
def lora_new_alert(alert: Alert):
|
def lora_new_alert(alert: Alert, with_message=False):
|
||||||
|
try:
|
||||||
|
from shared.lora_device import LoraCommands
|
||||||
|
except ImportError:
|
||||||
|
pass # No need
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"action": LoraCommands.NEW_ALERT,
|
||||||
|
"alert_type": alert.alertType
|
||||||
|
}
|
||||||
|
|
||||||
|
if with_message:
|
||||||
|
payload["alert_message"] = alert.message
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def lora_microwave_state(state: str):
|
||||||
try:
|
try:
|
||||||
from shared.lora_device import LoraCommands
|
from shared.lora_device import LoraCommands
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass # No need
|
pass # No need
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": LoraCommands.NEW_ALERT,
|
"action": LoraCommands.MICROVAVE_STATE_UPDATE,
|
||||||
"alert_type": alert.alertType
|
"new_microwave_state": state
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user