Compare commits

..

12 Commits

Author SHA1 Message Date
Ninluc 25e635e207 MQTT hello is saved in database
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-06 16:55:24 +02:00
Ninluc fb41a2d07a Database logging and error handling 2026-08-06 16:55:03 +02:00
Ninluc fb6ee0099a Removed debug log
Build, push image, and notify Watchtower / build-image (push) Successful in 59s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-06 15:44:10 +02:00
Ninluc 038171d68d ESP-LORA refactor 2026-08-06 15:44:03 +02:00
Ninluc 5381631876 Reduced logging in non debug
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-06 15:20:12 +02:00
Ninluc 2df05fd684 Fix reset dish state and debug values 2026-08-06 15:19:54 +02:00
Ninluc 11357b9a0e DEBUG led somtimes don't update 2026-08-06 15:19:30 +02:00
Ninluc e2beef511c Send status updae after doing changes 2026-08-06 15:19:14 +02:00
Ninluc e748d53c94 Fix changing cooking state 2026-08-06 14:56:35 +02:00
Ninluc 6fa74c6b93 Retries with cloud
Build, push image, and notify Watchtower / build-image (push) Successful in 2m27s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
Still need to to be able to reset the cooking_state so hat the user can reinsert a dish
2026-08-05 21:34:15 +02:00
Ninluc af5ea45270 LoRa Fix 2026-08-05 21:33:41 +02:00
Ninluc 1626b392b3 Fix mqtt 2026-08-05 20:48:39 +02:00
7 changed files with 478 additions and 326 deletions
+149 -80
View File
@@ -1,38 +1,63 @@
import gc
import sys
import time
import _thread
import uasyncio as asyncio
from machine import Pin, SoftI2C
import framebuf
import ssd1306
# 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
import framebuf
import ssd1306
import time
# --- Configuration Matérielle ---
vext = Pin(19, Pin.OUT)
vext.value(0)
time.sleep_ms(100)
# --- Lecture de l'ID unique de l'ESP ---
# --- READ DEVICE ID ---
try:
with open("device_id.txt", "r") as f:
DEVICE_ID = f.read().strip()
except Exception:
DEVICE_ID = "ESP32_Inconnu"
# --- Initialisation LoRa ---
# --- GLOBAL VARIABLES ---
cooking_state = None
data_queue = SafeQueue()
lora = None
uart_device = None
magnetron_led = None
display = None
PING_PAYLOAD = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
def init_hardware():
"""Initializes all hardware components."""
global lora, uart_device, magnetron_led, display
print("[Main] Initializing hardware peripherals...")
# Power up VEXT (for LoRa/Display)
vext = Pin(19, Pin.OUT)
vext.value(0)
time.sleep_ms(100)
# Init LoRa
lora = get_lora()
lora.configure(freq=868.1, sf=7)
data_queue = SafeQueue()
# --- Création des lEDs RGB ---
# Init RGB LEDs
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off()
# --- Création de l'écran OLED ---
# 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)
@@ -40,98 +65,87 @@ display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
display.text("Booting...", 1, 2, 1)
display.show()
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
# Init UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
PING_PAYLOAD = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
def heartbeat_loop():
# --- DEDICATED LORA HARDWARE THREAD ---
def lora_hardware_thread():
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
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
print("\nESP32 : Envoi du Heartbeat...")
print("\n[LoRa Thread] Sending Heartbeat...")
if lora:
lora.send(PING_PAYLOAD)
# 2. Increase listen window to 300ms so radio stays active in RX mode
# 2. Blocking 300ms RX listen window (keeps radio actively listening)
if lora:
paquet = lora.receive_reliable(timeout_ms=300)
if paquet is not None:
log(f"[LoRa Thread] New Packet Received: {paquet}")
data_queue.put(paquet)
time.sleep_ms(10)
# UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
# Lancer la boucle de heartbeat dans un thread séparé
try:
_thread.stack_size(16 * 1024)
except Exception:
pass
_thread.start_new_thread(heartbeat_loop, ())
# Cooking parameters
cooking_state = None
# --- COOKING STATE CALLBACKS ---
def cooking_state_temperature_provider():
return 22.0, 29.0 # TODO Remplacer par la lecture réelle de la température du plat et de l'air ambiant
return 22.0, 29.0 # TODO: Replace with real temperature reading
def cooking_state_on_state_change(state):
print(f"[Main] Cooking state changed to: {state.state}")
# Send to the Wifi board the current state
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
# Send to the orchestrator the current state
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
display.show()
print(f"[CookingState] State changed to: {state.state}")
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
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}))
if lora:
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
if state.state == cookingState.CookingStates.COOKING:
pass
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
pass
if state.state == cookingState.CookingStates.DONE:
pass
if state.state == cookingState.CookingStates.ALERT:
pass
# Update OLED display
if display:
display.fill(0)
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
display.show()
def cooking_state_on_refresh(state):
# TODO Show screen information
# TODO: Show screen information
pass
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)
# TODO send_reliable lora message to orchestrator about pause/resume state
# TODO: send_reliable lora message to orchestrator about pause/resume state
# --- MAIN APPLICATION THREAD ---
print("[Main] Main execution path active.")
# --- ASYNC TASKS ---
async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board."""
global cooking_state
while True:
# 1. Listen for incoming UART serial packets from the WROOM board
while uart_device.any():
if uart_device and uart_device.any():
command = uart_device.read_as_command()
if command:
print(f"[Main] Received command from WiFi Board: {command.command_type}")
print(f"[UART Task] Received command from WiFi Board: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_PARAMS:
# Handle cooking parameters command
params = command.payload
print(f"[Main] Cooking parameters received: {params}")
print(f"[UART Task] Cooking parameters received: {params}")
cooking_state = cookingState.CookingState(
cook_time=params["cook_time"],
power_level=params["power_level"],
@@ -141,39 +155,94 @@ while True:
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)
time.sleep_ms(20) # Before sending back right away
cooking_state_on_state_change(cooking_state)
await asyncio.sleep_ms(20)
cooking_state_on_state_change(cooking_state)
else:
print(f"[Main] Unknown command type received: {command.command_type}")
# 2. Listen for incoming LoRa packets from the orchestrator
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
while True:
while not data_queue.empty():
paquet = data_queue.get()
if paquet and not paquet["raw"]:
data = paquet["data"]
# Commands
if paquet and not paquet.get("raw"):
data = paquet.get("data", {})
if "action" in data:
if data["action"] == LoraCommands.TOGGLE_PAUSE:
if cooking_state != None:
if (cooking_state.state == cookingState.CookingStates.DONE):
print("[Main] Cooking is done. We reset the microwave for the next cooking session.")
if cooking_state is not None:
if cooking_state.state == cookingState.CookingStates.DONE:
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.")
cooking_state.set_state(cookingState.CookingStates.IDLE)
time.sleep_ms(20) # Before sending back right away
await asyncio.sleep_ms(20)
cooking_state = None
else:
cooking_state.toggle_pause()
if cooking_state.paused:
print("[Main] Cooking paused via orchestrator command.")
print("[LoRa Process] Cooking paused via orchestrator command.")
else:
print("[Main] Cooking resumed via orchestrator command.")
print("[LoRa Process] Cooking resumed via orchestrator command.")
else:
log("[Main] No active cooking state to toggle pause/resume.")
log("[LoRa Process] No active cooking state to toggle pause/resume.")
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
await asyncio.sleep_ms(50)
# Cooking State Update
if cooking_state != None:
async def cooking_loop_task():
"""Ticks the cooking state and logs information periodically."""
while True:
if cooking_state is not None:
cooking_state.update_tick()
print(f"[Main] Cooking state : State : {cooking_state.state}, Temperature: {cooking_state.current_dish_temp}, Paused: {cooking_state.paused}, Remaining Time: {cooking_state.get_remaining_time():.2f}s, Estimated Remaining Time: {cooking_state.get_remaining_time_estimation():.2f}s")
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, "
f"Est. Remaining: {cooking_state.get_remaining_time_estimation():.2f}s")
time.sleep_ms(500)
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():
print("[Main] Starting application...")
init_hardware()
# 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
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!")
# 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)
+18 -31
View File
@@ -30,13 +30,7 @@ from shared import get_mqtt_client, config, payloads
MQTT_CA_FILE = "/certs/ca.crt"
mqtt_client = get_mqtt_client(
host="192.168.50.1",
client_id="smartwave-esp32-demo",
use_tls=True,
cafile=MQTT_CA_FILE,
keepalive=30,
)
mqtt_client = None # Will be initialized in connect_mqtt_async()
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
status_led = None
@@ -82,35 +76,31 @@ def init_hardware():
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
def on_received_cooking_state_update(state, is_error=False, is_terminated=False):
"""Callback executed when state changes are received from the LoRa board over UART."""
if cooking_state:
if is_error:
cooking_state.set_state(cookingState.CookingStates.ERROR)
elif is_terminated:
cooking_state.set_state(cookingState.CookingStates.ABORTED)
else:
cooking_state.set_state(state)
def on_cooking_state_change(state):
"""Callback executed whenever local cooking state transitions."""
BLINK_INTERVAL_MS = 500
log(f"[CookingState] State changed to: {state.state}")
if status_led and cookingState:
if state == cookingState.CookingStates.IDLE:
if state.state == cookingState.CookingStates.IDLE:
log(f"[CookingState] State changed to IDLE. Turning LED off and stopping blink.")
status_led.color = status_led.OFF
status_led.blink_off()
elif state == cookingState.CookingStates.COOKING:
elif state.state == cookingState.CookingStates.COOKING:
log(f"[CookingState] State changed to COOKING. Turning LED yellow and stopping blink.")
status_led.color = status_led.YELLOW
status_led.blink_off()
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
elif state.state == cookingState.CookingStates.STIRRING_REQUIRED:
log(f"[CookingState] State changed to STIRRING_REQUIRED. Turning LED orange and starting blink.")
status_led.color = status_led.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS)
elif state == cookingState.CookingStates.ALERT:
elif state.state == cookingState.CookingStates.ALERT:
log(f"[CookingState] State changed to ALERT. Turning LED red and starting blink.")
status_led.color = status_led.RED
status_led.blink_on(BLINK_INTERVAL_MS)
elif state == cookingState.CookingStates.DONE:
elif state.state == cookingState.CookingStates.DONE:
log(f"[CookingState] State changed to DONE. Turning LED green and stopping blink.")
status_led.color = status_led.GREEN
status_led.blink_off()
@@ -199,23 +189,20 @@ async def sensor_publisher_task():
async def uart_task():
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
global cooking_state
while True:
if uart_device:
try:
cmd = uart_device.read_as_command()
if cmd:
print("[UART] Command received from LoRa board:", cmd)
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
if (
hasattr(cmd, "command_type")
and cmd.command_type == UARTCommandType.STATE_UPDATE
and on_received_cooking_state_update
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
):
on_received_cooking_state_update(
cmd.payload.get("state"),
cmd.payload.get("is_error", False),
cmd.payload.get("is_terminated", False),
)
if cooking_state:
cooking_state.set_state(cmd.payload.get("state"))
except Exception as e:
print("[UART Task] Error reading command:", e)
+83 -13
View File
@@ -6,12 +6,40 @@ import asyncio
import requests
from orchestrateur.sensors import gps
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads
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 sensors import ultrasonicRanger, temp_hum, button, camera
# --- 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}")
# --- Read Unique Device ID ---
def get_device_id():
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
@@ -35,7 +63,9 @@ class MicrowaveState:
# Global state trackers
microwave_states = {"2": MicrowaveState.IDLE}
button_state = False
cloud_alert = False # Global status flag for screen / UI display
async_event_queue = None
# Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event()
@@ -109,7 +139,7 @@ button.start_button_monitoring_thread()
# --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str):
print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.")
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
# TODO: Add LoRa STOP command here
# --- ASYNC COOKING LOGIC ---
@@ -187,7 +217,8 @@ async def handle_new_dish(microwave_id, detected_height):
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud and starts the microwave if successful."""
"""Sends all data to the cloud with up to 3 retries (30s interval)."""
global cloud_alert
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params"
@@ -196,11 +227,20 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
try:
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
max_retries = 3
retry_delay_seconds = 30
for attempt in range(1, max_retries + 1):
if microwave_states.get(microwave_id) != 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}...")
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi)
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return
@@ -212,19 +252,37 @@ 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:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
microwave_states[microwave_id] = MicrowaveState.DONE
return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
cloud_alert = False # Reset alert flag on success
microwave_states[microwave_id] = MicrowaveState.COOKING
if config.DEBUG:
c_time = min(c_time, 10) # Limit to 10s for debug
c_temp = min(c_temp, 50) # Limit 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
)
return
except Exception as e:
print(f"[{microwave_id}] Cloud API Error: {e}")
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 user removes the dish mid-wait
for _ in range(retry_delay_seconds):
if microwave_states.get(microwave_id) != 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 3 cloud retries failed. Setting global alert flag.")
cloud_alert = True
microwave_states[microwave_id] = MicrowaveState.DONE
# --- MAIN LOGIC TASKS ---
@@ -257,11 +315,17 @@ async def process_messages_task():
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
mw_id = data.get("id_microwave")
print(f"[MQTT] Hello from {mw_id}. Sending ACK.")
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)
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
@@ -279,8 +343,8 @@ async def get_filtered_dish_height(samples=3, delay=0.04):
valid_samples = []
for _ in range(samples):
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
# Discard 0.0 or near-zero timeout glitches
if h is not None and h > 0.5:
# Discard negative glitches
if h is not None and h >= 0.0:
valid_samples.append(h)
await asyncio.sleep(delay)
@@ -292,6 +356,7 @@ async def get_filtered_dish_height(samples=3, delay=0.04):
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
@@ -326,6 +391,8 @@ async def monitor_dish_height_task():
consecutive_absent = 0
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
microwave_states[mw_id] = MicrowaveState.IDLE
cloud_alert = False # Reset error alert on dish removal
if current_state == MicrowaveState.COOKING:
_stop_hardware(mw_id)
# Remove from IR cache and events
@@ -339,6 +406,9 @@ async def main():
global async_event_queue
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
# Initialize SQLite database table
init_db()
async_event_queue = asyncio.Queue()
await asyncio.gather(
-2
View File
@@ -179,8 +179,6 @@ class CookingState:
elapsed_time = self.get_elapsed_time()
self.estimated_remaining_time = self.get_remaining_time_estimation()
print(elapsed_time, self.cook_time, self.current_dish_temp, self.target_temp, self._paused_duration, self._pause_started_at, now)
if self.current_dish_temp is not None:
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring
self.state = CookingStates.STIRRING_REQUIRED
+38 -5
View File
@@ -5,6 +5,8 @@ same code can run on CPython (`sqlite3`) and MicroPython (`sqlite3` or
`usqlite`, depending on the port).
"""
from shared.logging import log
try:
import sqlite3 as _sqlite
DRIVER_NAME = "sqlite3"
@@ -13,20 +15,25 @@ except ImportError:
import usqlite as _sqlite
DRIVER_NAME = "usqlite"
except ImportError as exc:
log("[DB Error] No sqlite driver found. Expected sqlite3 or usqlite.")
raise ImportError("No sqlite driver found. Expected sqlite3 or usqlite.") from exc
def _connect(database_path, **connect_kwargs):
try:
if connect_kwargs:
try:
return _sqlite.connect(database_path, **connect_kwargs)
except TypeError:
pass
return _sqlite.connect(database_path)
except Exception as e:
log(f"[DB Error] Driver connect failed for '{database_path}': {e}")
raise
class Database:
"""Lightweight connection wrapper with a consistent API."""
"""Lightweight connection wrapper with logging and consistent API."""
def __init__(self, database_path, **connect_kwargs):
self._database_path = database_path
@@ -40,29 +47,45 @@ class Database:
def close(self):
if self._connection is not None:
try:
self._connection.close()
except Exception as e:
log(f"[DB Error] Failed to close database '{self._database_path}': {e}")
finally:
self._connection = None
def commit(self):
connection = self.open()
if hasattr(connection, "commit"):
try:
connection.commit()
except Exception as e:
log(f"[DB Error] Commit failed on '{self._database_path}': {e}")
raise
def cursor(self):
return self.open().cursor()
def execute(self, sql, params=None):
cursor = self.cursor()
try:
if params is None:
cursor.execute(sql)
else:
cursor.execute(sql, params)
return cursor
except Exception as e:
log(f"[DB Error] Query failed on '{self._database_path}' | SQL: {sql} | Params: {params} | Error: {e}")
raise
def executemany(self, sql, params_list):
cursor = self.cursor()
try:
cursor.executemany(sql, params_list)
return cursor
except Exception as e:
log(f"[DB Error] Executemany failed on '{self._database_path}' | SQL: {sql} | Error: {e}")
raise
def fetchone(self, sql, params=None):
return self.execute(sql, params).fetchone()
@@ -73,16 +96,23 @@ class Database:
def executescript(self, script):
connection = self.open()
if hasattr(connection, "executescript"):
try:
return connection.executescript(script)
except Exception as e:
log(f"[DB Error] Executescript failed on '{self._database_path}': {e}")
raise
log("[DB Error] executescript is not available on this sqlite backend")
raise NotImplementedError("executescript is not available on this sqlite backend")
def __enter__(self):
self.open()
return self
def __exit__(self, exc_type, exc, traceback):
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.commit()
else:
log(f"[DB Error] Context exited with exception on '{self._database_path}': {exc_val}")
self.close()
@@ -91,12 +121,15 @@ def connect(database_path, **connect_kwargs):
def execute(database_path, sql, params=None, **connect_kwargs):
return connect(database_path, **connect_kwargs).execute(sql, params)
with connect(database_path, **connect_kwargs) as db_inst:
return db_inst.execute(sql, params)
def fetchone(database_path, sql, params=None, **connect_kwargs):
return connect(database_path, **connect_kwargs).fetchone(sql, params)
with connect(database_path, **connect_kwargs) as db_inst:
return db_inst.fetchone(sql, params)
def fetchall(database_path, sql, params=None, **connect_kwargs):
return connect(database_path, **connect_kwargs).fetchall(sql, params)
with connect(database_path, **connect_kwargs) as db_inst:
return db_inst.fetchall(sql, params)
+53 -60
View File
@@ -1,6 +1,7 @@
import sys
import time
import random
from shared.logging import log
IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -32,9 +33,10 @@ class BaseLoraDevice:
"""Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON:
time.sleep_ms(10)
time.sleep_ms(100)
else:
time.sleep(0.01)
# Give LA66 chip time to finish logging RSSI and reset RF frontend
time.sleep(0.25)
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload)
@@ -49,7 +51,7 @@ class BaseLoraDevice:
# 1. Handle incoming ACK response
if data.get("_type") == "_ack":
ack_id = data.get("_ack_id")
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
log(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
if ack_id is not None:
self.received_acks.add(ack_id)
if len(self.received_acks) > 100:
@@ -59,11 +61,11 @@ class BaseLoraDevice:
# 2. Handle incoming command expecting an ACK
msg_id = data.get("_msg_id")
if msg_id is not None:
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
log(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
self._send_ack(msg_id)
if msg_id in self.processed_msg_ids:
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
log(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
return None # Discard duplicate retry
self.processed_msg_ids.add(msg_id)
@@ -72,8 +74,8 @@ class BaseLoraDevice:
return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
"""Sends a payload and retries until an ACK is received or max retries are reached."""
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."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
@@ -84,26 +86,19 @@ class BaseLoraDevice:
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
print(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):
print(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)
start_time = time.time()
while (time.time() - start_time) < ack_timeout:
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
# 1. Open a single continuous RX window for the full timeout duration
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
timeout_ms = int(ack_timeout * 1000)
packet = self.receive_packet(timeout_ms=timeout_ms)
packet = self.receive_packet(timeout_ms=500)
# 2. Process incoming packet if received
if packet:
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
@@ -112,16 +107,17 @@ class BaseLoraDevice:
finally:
if lock: lock.release()
# 3. Check if matching ACK was received
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
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
@@ -247,36 +243,30 @@ if IS_MICROPYTHON:
print(f"[LoRa SPI] Recv error caught: {e}")
return None
if state == 0 and data is not None and len(data) > 0:
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
group = self.default_group
payload_brute = data.strip(b'\x00 \r\n\t')
elif len(data) > 1:
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t')
else:
return None
# Decode to string, ignoring unprintable characters
try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
except UnicodeError:
return None
if text.startswith('{') or text.startswith('['):
decoded_text = text
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
try:
decoded_text = ubinascii.unhexlify(text).decode('utf-8').strip('\x00 \r\n\t')
raw_text = data.decode('utf-8', 'ignore').strip()
except Exception:
decoded_text = text
else:
decoded_text = text
return None
# Find where the actual JSON payload starts ({ or [)
idx_brace = raw_text.find('{')
idx_bracket = raw_text.find('[')
valid_indices = [i for i in (idx_brace, idx_bracket) if i != -1]
if valid_indices:
# Slice off all leading group bytes/control characters (\x02)
json_str = raw_text[min(valid_indices):]
try:
parsed_json = json.loads(decoded_text)
return {"group": group, "data": parsed_json, "raw": False}
except ValueError:
return {"group": group, "data": decoded_text, "raw": True}
parsed_json = json.loads(json_str)
return {"group": self.default_group, "data": parsed_json, "raw": False}
except Exception:
pass
return {"group": self.default_group, "data": raw_text, "raw": True}
return None
@@ -301,10 +291,15 @@ else:
# Initial configuration
self.configure(freq=868.1, sf=7, bw=125)
def _send_at_cmd(self, cmd, wait_time=0.15):
"""Helper to send AT command and purge response buffer."""
def _send_at_cmd(self, cmd, wait_time=0.3):
"""Sends AT command, draining unread serial noise first."""
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
if self.ser.in_waiting > 0:
self.ser.read_all()
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
resp = ""
while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore')
@@ -326,7 +321,7 @@ else:
self.ser.reset_input_buffer()
def send(self, payload, group=None):
"""Encodes payload into HEX AT command and re-enables continuous RX."""
"""Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
with self.lock:
if group is None:
group = self.default_group
@@ -337,17 +332,15 @@ else:
if isinstance(payload, str):
payload = payload.encode('utf-8')
paquet_physique = bytes([group]) + payload
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer()
hex_payload = payload.hex()
print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
cmd = f"AT+PSEND={hex_payload}"
resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
# Re-enable continuous receive mode after transmission completes
self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
cmd = f"AT+SEND={group},{hex_payload},0,3"
resp = self._send_at_cmd(cmd, wait_time=0.3)
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
def receive_packet(self, timeout_ms=500):
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
+3 -1
View File
@@ -1,4 +1,5 @@
from time import time
from shared.deviceTypes import DEVICE_TYPES
try:
@@ -16,7 +17,8 @@ def as_json(data):
def mqtt_hello(id_microwave):
return as_json({
"id_microwave": id_microwave
"id_microwave": id_microwave,
"type": DEVICE_TYPES["MICROWAVE"]
})
def mqtt_hello_ack(id_orchestrator, id_microwave):