From 7299a501989d297965990b7b1206354b9e4bd804 Mon Sep 17 00:00:00 2001 From: Matthias Guillitte Date: Fri, 31 Jul 2026 21:50:42 +0200 Subject: [PATCH] Beginning of cooking cycle --- micro_ondes/esp_lora/main.py | 69 +++++++++-- micro_ondes/esp_wifi/boot.py | 5 + micro_ondes/esp_wifi/main.py | 120 ++++++++++++------ orchestrateur/main.py | 16 ++- orchestrateur/sensors/temp_hum.py | 9 ++ shared/__init__.py | 5 +- shared/cookingState.py | 199 ++++++++++++++++++++++++++++++ shared/payloads.py | 8 ++ shared/sensors/__init__.py | 5 +- shared/sensors/rgb_led.py | 57 +++++++-- shared/uart_comm.py | 145 ++++++++++++++++++---- 11 files changed, 552 insertions(+), 86 deletions(-) create mode 100644 shared/cookingState.py diff --git a/micro_ondes/esp_lora/main.py b/micro_ondes/esp_lora/main.py index fd175d6..3fb37df 100644 --- a/micro_ondes/esp_lora/main.py +++ b/micro_ondes/esp_lora/main.py @@ -1,6 +1,9 @@ import _thread from machine import Pin -from sensors import RGBLED +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 import time # --- Configuration Matérielle --- @@ -21,7 +24,7 @@ lora.configure(freq=868.1, sf=7) # --- Création des lEDs RGB --- magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33) -magnetron_led.set_color(RGBLED.YELLOW) +magnetron_led.color = RGBLED.WHITE_YELLOW magnetron_led.off() print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})") @@ -58,17 +61,69 @@ uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45) # Lancer la boucle de heartbeat dans un thread séparé _thread.start_new_thread(heartbeat_loop, ()) +# Cooking parameters +cooking_state = None +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 + +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})) + + if state.paused or state.state == cookingState.CookingStates.DONE: + magnetron_led.off() + else: + magnetron_led.on() + + + if state.state == cookingState.CookingStates.COOKING: + pass + if state.state == cookingState.CookingStates.STIRRING_REQUIRED: + pass + if state.state == cookingState.CookingStates.DONE: + global cooking_state + cooking_state = None + if state.state == cookingState.CookingStates.ALERT: + pass + +def cooking_state_on_refresh(state): + # TODO Show screen information + pass + + # --- MAIN APPLICATION THREAD --- print("[Main] Main execution path active.") while True: # 1. Listen for incoming UART serial packets from the WROOM board while uart_device.any(): - command = uart_device.read() - print(f"[Main] Received command from WiFi Board: {command}") - + command = uart_device.read_as_command() + if command: + print(f"[Main] 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}") + 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) + time.sleep_ms(20) # Before sending back right away + cooking_state_on_state_change(cooking_state) + + else: + print(f"[Main] Unknown command type received: {command.command_type}") + # uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}") - # 2. Send local metrics over the wire to the WiFi board every few seconds - # uart_device.send("Data Pack: LoRa Link RSSI -72dBm") + # Cooking State Update + if cooking_state: + 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") time.sleep_ms(200) \ No newline at end of file diff --git a/micro_ondes/esp_wifi/boot.py b/micro_ondes/esp_wifi/boot.py index 8742daf..b587b00 100644 --- a/micro_ondes/esp_wifi/boot.py +++ b/micro_ondes/esp_wifi/boot.py @@ -1,5 +1,6 @@ # This file is executed on every boot (including wake-boot from deepsleep) import esp +from machine import Pin esp.osdebug(True) #import webrepl #webrepl.start() @@ -17,3 +18,7 @@ def do_connect(ssid, pwd): # Attempt to connect to WiFi network do_connect("Smartwave-1", 'Smartwave-prot-1') + +# Set PIN 27 as GND for the temperature sensor (MLX90614) +sensor_gnd = Pin(27, Pin.OUT) +sensor_gnd.value(0) \ No newline at end of file diff --git a/micro_ondes/esp_wifi/main.py b/micro_ondes/esp_wifi/main.py index 98371ba..5cc066d 100644 --- a/micro_ondes/esp_wifi/main.py +++ b/micro_ondes/esp_wifi/main.py @@ -2,7 +2,10 @@ import _thread import select from machine import Pin, I2C from sensors import temperature_sensor -from shared import get_mqtt_client, get_uart, config, payloads +from shared import get_mqtt_client, get_uart, config, payloads, cookingState +from shared.uart_comm import UARTCommand, UARTCommandType +from shared.sensors import RGBLED +from shared.logging import log import time import ujson as json import sys @@ -22,7 +25,10 @@ try: DEVICE_ID = f.read().strip() except Exception: DEVICE_ID = "ESP32_Inconnu" - + +# --- Cooking State --- +cooking_state = None # This will hold the current cooking state if any + # --- MQTT SETUP --- MQTT_CA_FILE = "/certs/ca.crt" @@ -59,16 +65,25 @@ def on_mqtt_message(message): # Handle cooking messages elif message['topic'] == config.MQTT_TOPIC_COOKING and payload_data and payload_data["id_microwave"] == DEVICE_ID: # Cooking sensors init request - if not "cook_time_seconds" in payload_data: + if not "cook_time" in payload_data: print("[MQTT Thread] Cooking sensors init received from the orchestrator") - obj_temp = temperature_sensor.read_object_temp() - amb_temp = temperature_sensor.read_ambient_temp() + obj_temp = mlx_temperature_sensor.read_object_temp() + amb_temp = mlx_temperature_sensor.read_ambient_temp() queue_publish(config.MQTT_TOPIC_SENSOR, payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)) # Received cooking parameters from the orchestrator else: print("[MQTT Thread] Cooking parameters received from the orchestrator:", payload_data) - # Here you would handle the cooking parameters, e.g., start a cooking process - # For now, we just print them + global cooking_state + cooking_state = cookingState.CookingState( + cook_time=payload_data["cook_time"], + power_level=payload_data["power_level"], + target_temp=payload_data["target_temp"] + ) + cooking_state.set_state_change_callback(on_cooking_state_change) + cooking_state.set_state(cookingState.CookingStates.IDLE) # Set initial state to IDLE + # Send to the LoRa board the cooking parameters + uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)) + print("[MQTT Thread] Cooking parameters sent to LoRa board.") print("[MQTT Thread] Message processing complete.") @@ -135,39 +150,47 @@ def mqtt_background_thread(): pass time.sleep(5) -# --- UART BACKGROUND THREAD --- -def uart_background_thread(): - """Background UART worker handling all serial operations safely.""" - print("[Thread] Background UART worker started.") - - uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16) - - while True: - try: - # 1. Check for incoming messages from the Heltec board - while uart_device.any(): - incoming_msg = uart_device.read() - print(f"[Thread] Received from esp-lora over UART: {incoming_msg}") - - # 2. Example: Send data to the Heltec board every 5 seconds - # uart_device.send("Status Check: WiFi Active") - - time.sleep(5) # Fast responsive polling loop for local UART - - except Exception as e: - print("[Thread] UART error encountered:", e) - time.sleep(5) - -# --- Launch background worker --- -_thread.start_new_thread(mqtt_background_thread, ()) -_thread.start_new_thread(uart_background_thread, ()) +# UART +uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16) +# Cooking Cycle +cooking_state = None +def on_received_cooking_state_update(new_state): + global cooking_state + if cooking_state is None: + print("[Main] No active cooking state to update.") + return + + log(f"[Main] Updating cooking state to: {new_state}") + cooking_state.set_state(new_state) + +# Status LED +status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18) +def on_cooking_state_change(state): + print(f"[Main] Cooking state changed to: {state.state}") + + # === STATUS LED UPDATE === + BLINK_INTERVAL_MS = 500 # Blink every 500ms + + if state.state == cookingState.CookingStates.IDLE: + status_led.color = RGBLED.OFF + status_led.blink_off() + if state.state == cookingState.CookingStates.COOKING: + status_led.color = RGBLED.YELLOW + status_led.blink_off() + if state.state == cookingState.CookingStates.STIRRING_REQUIRED: + status_led.color = RGBLED.ORANGE + status_led.blink_on(BLINK_INTERVAL_MS) + if state.state == cookingState.CookingStates.DONE: + status_led.color = RGBLED.GREEN + status_led.blink_off() + if state.state == cookingState.CookingStates.ALERT: + status_led.color = RGBLED.RED + status_led.blink_on(BLINK_INTERVAL_MS) + # --- MAIN APPLICATION THREAD (Core 0) --- print("[Main] Main execution path active.") -time.sleep(2) # Give the thread a moment to initial connect -mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL -mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) # Temperature sensor setup temperature_sensor_i2c = I2C(0, scl=Pin(25, Pin.IN, Pin.PULL_UP), sda=Pin(26, Pin.IN, Pin.PULL_UP), freq=100000) @@ -178,7 +201,13 @@ if 0x5A in devices: print("MLX90614 found at address 0x5A!") else: print("MLX90614 not found. Please check your wiring.") -temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c) +mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c) + +# --- Launch background worker --- +_thread.start_new_thread(mqtt_background_thread, ()) +time.sleep(2) # Give the thread a moment to initial connect +mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL +mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) while True: # MQTT HELLO sent every x seconds until we get a response from the orchestrator @@ -187,6 +216,23 @@ while True: queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID)) mqtt_hello_sent_timestamp = time.time() pass + + # 1. Listen for incoming UART serial packets from the WROOM board + while uart_device.any(): + command = uart_device.read_as_command() + if command: + print(f"[Main] Received command from LoRa Board: {command.command_type}") + if command.command_type == UARTCommandType.COOKING_STATE_UPDATE: + # Handle cooking state update command + new_state = command.payload.get("state", None) + print(f"[Main] Cooking state update received: {new_state}") + on_received_cooking_state_update(new_state) + else: + print(f"[Main] Unknown command type received: {command.command_type}") + else: + # Fallback to reading as a raw string if parsing fails + raw_command = uart_device.read() + print(f"[Main] Received raw command from WiFi Board: {raw_command}") # 2. Example: Send data to the Heltec board every 5 seconds # uart_device.send("Status Check: WiFi Active") diff --git a/orchestrateur/main.py b/orchestrateur/main.py index f468a2b..9d8ffd4 100644 --- a/orchestrateur/main.py +++ b/orchestrateur/main.py @@ -142,7 +142,7 @@ def read_sensors_for_cooking(microwave_id): log(f"\nLecture du capteur Ultrason : {sensor_data['ultrasonic_distance']}") # Read Temperature and Humidity - temperature, humidity = temp_hum.get_temperature_and_humidity() + temperature, humidity = temp_hum.get_temperature_and_humidity_with_retry() if temperature is not None and humidity is not None: log(f"\nLecture du capteur Temp/Hum : {temperature}, {humidity}") sensor_data["temperature"] = temperature @@ -170,22 +170,20 @@ def _stop_hardware(microwave_id: str): """ Hardware driver stop — halts magnetron/turntable immediately. """ - log(f"[{microwave_id}] 🛑 Emergency stop issued to hardware.") + print(f"[{microwave_id}] 🛑 Emergency stop issued to hardware.") # TODO: Add physical hardware stop command here # e.g., gpio_controller.stop() -def _send_params_to_microwave(microwave_id: str, cook_time_seconds: int, power_level_pct: int, target_temp: float, cancel_event: threading.Event): +def _send_params_to_microwave(microwave_id: str, cook_time: int, power_level: int, target_temp: float, cancel_event: threading.Event): """ Triggers physical microwave execution. """ if cancel_event.is_set(): return - log(f"[{microwave_id}] ⚡ Starting microwave cooking : {cook_time_seconds}s @ {power_level_pct}W power, target temp {target_temp}°C.") - # TODO: Connect to microwave hardware driver here - # e.g., gpio_controller.start(time=cook_time_seconds, power=power_level_pct) - + print(f"[{microwave_id}] Sending microwave {microwave_id} cooking parameters : {cook_time}s @ {power_level}W power, target temp {target_temp}°C.") + mqtt_client.publish(config.MQTT_TOPIC_COOKING, payloads.mqtt_cooking_config(microwave_id, cook_time, power_level, target_temp), qos=config.MQTT_QOS) def _cooking_worker(microwave_id: str, sensors_data: dict, cancel_event: threading.Event): """Worker function executing cloud API calls and hardware triggers.""" @@ -211,9 +209,9 @@ def _cooking_worker(microwave_id: str, sensors_data: dict, cancel_event: threadi print(f"[{microwave_id}] Job was canceled while waiting for cloud response. Discarding result.") return - if (response.status_code != 200): + log(f"[{microwave_id}] Cloud API responded with : {response.json()}") + if response.status_code != 200 and response.status_code != 201: print(f"[{microwave_id}] Cloud API returned error {response.status_code}: {response.json()}") - return response.raise_for_status() # 2. Extract Response Parameters diff --git a/orchestrateur/sensors/temp_hum.py b/orchestrateur/sensors/temp_hum.py index 79eb246..3991228 100644 --- a/orchestrateur/sensors/temp_hum.py +++ b/orchestrateur/sensors/temp_hum.py @@ -1,5 +1,6 @@ import grovepi import math +import time from sensors.lock import grove_lock # Connect the Grove Temperature & Humidity Sensor Pro to digital port D3 @@ -20,3 +21,11 @@ def get_temperature_and_humidity(): else: print("Error reading from DHT sensor") return None, None + +def get_temperature_and_humidity_with_retry(max_retries=3): + for _ in range(max_retries): # Try up to max_retries times + temp, humidity = get_temperature_and_humidity() + if temp is not None and humidity is not None: + return temp, humidity + time.sleep(1) # Wait a bit before retrying + return None, None \ No newline at end of file diff --git a/shared/__init__.py b/shared/__init__.py index 663d86f..9bb5d8e 100644 --- a/shared/__init__.py +++ b/shared/__init__.py @@ -5,7 +5,10 @@ import shared.deviceTypes as deviceTypes import shared.config as config import shared.payloads as payloads import shared.cookingState as cookingState -import shared.uart_comm as uart_comm +try: + import shared.uart_comm as uart_comm +except ImportError: + pass # No need as we are on the RPI import shared.sensors def get_lora(*args, **kwargs): diff --git a/shared/cookingState.py b/shared/cookingState.py new file mode 100644 index 0000000..57ce827 --- /dev/null +++ b/shared/cookingState.py @@ -0,0 +1,199 @@ +import time + + +class CookingState: + TEMPERATURE_TOLERANCE = 1.0 + + def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None): + self.cook_time = cook_time + self.power_level = power_level + self.target_temp = target_temp + + self.start_time = time.time() + self.temperature_provider = temperature_provider + self.on_state_change = on_state_change + self.on_refresh = on_refresh + + self.state = CookingStates.COOKING + self.paused = False + self._pause_started_at = None + self._paused_duration = 0.0 + + self.current_dish_temp = None + self.current_ambient_temp = None + self.estimated_remaining_time = float(cook_time) + self._last_temperature_sample = None + self._last_refresh_signature = None + + def set_temperature_provider(self, temperature_provider): + self.temperature_provider = temperature_provider + + def set_state_change_callback(self, callback): + self.on_state_change = callback + + def set_refresh_callback(self, callback): + self.on_refresh = callback + + def pause(self): + if self.paused: + return + + self.paused = True + self._pause_started_at = time.time() + self._notify_refresh(force=True) + + def unpause(self): + if not self.paused: + return + + now = time.time() + if self._pause_started_at is not None: + self._paused_duration += now - self._pause_started_at + + self._pause_started_at = None + self.paused = False + self._notify_refresh(force=True) + + def toggle_pause(self): + if self.paused: + self.unpause() + else: + self.pause() + + def set_state(self, state): + if self.state == state: + return + + self.state = state + self._notify_state_change() + self._notify_refresh(force=True) + + def get_elapsed_time(self) -> float: + now = time.time() + elapsed = now - self.start_time - self._paused_duration + + if self.paused and self._pause_started_at is not None: + elapsed -= now - self._pause_started_at + + return max(0.0, elapsed) + + def get_remaining_time(self) -> int: + """Returns the estimated remaining cooking time in seconds.""" + return int(max(0.0, self.get_remaining_time_estimation())) + + def get_remaining_time_estimation(self) -> float: + elapsed_time = self.get_elapsed_time() + timer_remaining = max(0.0, float(self.cook_time) - elapsed_time) + + if self.current_dish_temp is None: + return timer_remaining + + if self.current_dish_temp >= self.target_temp: + return timer_remaining + + heating_rate = self._estimate_heating_rate() + if heating_rate <= 0: + return timer_remaining + + target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate + return max(timer_remaining, max(0.0, target_remaining)) + + def _read_temperatures(self): + if self.temperature_provider is None: + return None, None + + temperatures = self.temperature_provider() + if temperatures is None: + return None, None + + if isinstance(temperatures, (list, tuple)) and len(temperatures) >= 2: + return temperatures[0], temperatures[1] + + raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)") + + def _estimate_heating_rate(self): + if self._last_temperature_sample is None: + return 0.0 + + last_time, last_temp = self._last_temperature_sample + now = time.time() + current_temp = self.current_dish_temp + + if current_temp is None: + return 0.0 + + delta_time = now - last_time + if delta_time <= 0: + return 0.0 + + return (current_temp - last_temp) / delta_time + + def _notify_state_change(self): + if self.on_state_change is None: + return + + self.on_state_change(self) + + def _notify_refresh(self, force=False): + if self.on_refresh is None: + return + + signature = ( + int(self.get_elapsed_time()), + int(self.get_remaining_time_estimation()), + self.current_dish_temp, + self.current_ambient_temp, + self.state, + self.paused, + ) + + if not force and signature == self._last_refresh_signature: + return + + self._last_refresh_signature = signature + self.on_refresh(self) + + def update_tick(self): + if self.paused: + self._notify_refresh() + return self.state + + previous_state = self.state + previous_temperature = self.current_dish_temp + + try: + self.current_dish_temp, self.current_ambient_temp = self._read_temperatures() + except Exception: + self.current_dish_temp = previous_temperature + + now = time.time() + elapsed_time = self.get_elapsed_time() + self.estimated_remaining_time = self.get_remaining_time_estimation() + + if self.current_dish_temp is not None: + if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp: + self.state = CookingStates.STIRRING_REQUIRED + self.pause() + elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): + self.state = CookingStates.DONE + elif self.state == CookingStates.DONE and self.current_dish_temp < (self.target_temp - self.TEMPERATURE_TOLERANCE): + self.state = CookingStates.COOKING + elif elapsed_time >= self.cook_time * 1.25: # If the dish is not heating up + self.state = CookingStates.STIRRING_REQUIRED + self.pause() + + self._last_temperature_sample = (now, self.current_dish_temp) + + if self.state != previous_state: + self._notify_state_change() + + self._notify_refresh() + return self.state + + +class CookingStates: + COOKING = 0 + STIRRING_REQUIRED = 1 + DONE = 2 + ALERT = 3 # Microwave is too hot internally or other alerts + IDLE = 4 # Waiting for cooking parameters to be set, or after cooking is done \ No newline at end of file diff --git a/shared/payloads.py b/shared/payloads.py index 72ef79d..659641d 100644 --- a/shared/payloads.py +++ b/shared/payloads.py @@ -35,4 +35,12 @@ def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp): "id_microwave": id_microwave, "dish_temp": dish_temp, "ambient_temp": ambient_temp + }) + +def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp): + return as_json({ + "id_microwave": id_microwave, + "cook_time": cook_time, + "power_level": power_level, + "target_temp": target_temp }) \ No newline at end of file diff --git a/shared/sensors/__init__.py b/shared/sensors/__init__.py index 2b97a81..2ede1a1 100644 --- a/shared/sensors/__init__.py +++ b/shared/sensors/__init__.py @@ -1 +1,4 @@ -from shared.sensors.rgb_led import RGBLED \ No newline at end of file +try: + from shared.sensors.rgb_led import RGBLED +except ImportError: + pass # No need as we are on the RPI \ No newline at end of file diff --git a/shared/sensors/rgb_led.py b/shared/sensors/rgb_led.py index 6a18bab..3351ffe 100644 --- a/shared/sensors/rgb_led.py +++ b/shared/sensors/rgb_led.py @@ -1,27 +1,30 @@ -from machine import Pin, PWM +from machine import Pin, PWM, Timer +import time class RGBLED: """ MicroPython driver for 4-pin RGB LEDs on ESP32 / Heltec boards. - Supports state tracking, color setting, brightness scaling, and state toggling. + Supports state tracking, color setting, brightness scaling, + state toggling, and non-blocking blinking via machine.Timer. """ - # Preset RGB tuples for quick use RED = (255, 0, 0) GREEN = (0, 255, 0) BLUE = (0, 0, 255) - YELLOW = (255, 150, 0) + YELLOW = (255, 120, 0) + WHITE_YELLOW = (150, 30, 0) ORANGE = (255, 50, 0) WHITE = (255, 255, 255) OFF = (0, 0, 0) - def __init__(self, red_pin, green_pin, blue_pin, common_anode=False, freq=1000): + def __init__(self, red_pin, green_pin, blue_pin, common_anode=False, freq=1000, timer_id=1): """ :param red_pin: GPIO pin number for Red channel :param green_pin: GPIO pin number for Green channel :param blue_pin: GPIO pin number for Blue channel :param common_anode: Set True if cathode is connected to 3.3V instead of GND :param freq: PWM frequency in Hz (default 1000Hz) + :param timer_id: Hardware/software timer ID for non-blocking blinks (-1 uses soft timers on ESP32). """ self._r_pwm = PWM(Pin(red_pin, Pin.OUT), freq=freq) self._g_pwm = PWM(Pin(green_pin, Pin.OUT), freq=freq) @@ -34,6 +37,10 @@ class RGBLED: self._brightness = 1.0 # Brightness factor [0.0 to 1.0] self._is_on = True # Master power state + # Blink state variables + self._timer = Timer(timer_id) + self._is_blinking = False + self._apply() def _apply(self): @@ -88,7 +95,11 @@ class RGBLED: """Returns True if the LED is currently powered on.""" return self._is_on - # --- Helper Methods --- + @property + def is_blinking(self): + return self._is_blinking + + # --- Basic Control Methods --- def set_rgb(self, r, g, b): """Alternative setter for individual R, G, B integer values.""" @@ -109,8 +120,40 @@ class RGBLED: self._is_on = not self._is_on self._apply() + # --- Non-Blocking Blinking Methods --- + + def _timer_callback(self, t): + """Internal callback executed by machine.Timer.""" + self.toggle() + + def blink_on(self, interval_ms=500): + """Starts background blinking at the specified interval in milliseconds.""" + if self._is_blinking: + self._timer.deinit() + + self._is_blinking = True + self.on() # Ensure initial state is on + self._timer.init( + period=interval_ms, + mode=Timer.PERIODIC, + callback=self._timer_callback + ) + + def blink_off(self): + """Stops blinking and returns control to steady state.""" + if self._is_blinking: + self._timer.deinit() + self._is_blinking = False + + def blink_toggle(self, interval_ms=500): + """Toggles blinking state (starts if stopped, stops if active).""" + if self._is_blinking: + self.blink_off() + else: + self.blink_on(interval_ms) + def deinit(self): - """Releases the hardware PWM pins when finished.""" + """Releases the hardware PWM pins and timer when finished.""" self._r_pwm.deinit() self._g_pwm.deinit() self._b_pwm.deinit() \ No newline at end of file diff --git a/shared/uart_comm.py b/shared/uart_comm.py index 2b701c2..846d49b 100644 --- a/shared/uart_comm.py +++ b/shared/uart_comm.py @@ -1,12 +1,12 @@ -# shared/uart_comm.py import _thread from machine import UART import time +import ujson class SafeUART: def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200): # Initialize the hardware UART channel - self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10) + self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024) # Core thread-safety assets self.lock = _thread.allocate_lock() @@ -21,37 +21,87 @@ class SafeUART: print(f"[UART] Thread initialized on UART{uart_id} (TX:{tx_pin}, RX:{rx_pin})") def _listener_worker(self): - """Asynchronous internal loop parsing incoming stream lines into the queue.""" + """Worker loop that handles nested JSON structures by tracking brace depth.""" while True: - try: + messages_found = [] + + with self.lock: if self.uart.any(): - with self.lock: - # Pull all raw bytes waiting in the hardware ring buffer - chunk = self.uart.read(self.uart.any()) - if chunk: - self.buffer += chunk - - # Process complete lines terminated by a newline character - while b'\n' in self.buffer: - line, self.buffer = self.buffer.split(b'\n', 1) - try: - decoded_line = line.decode('utf-8').strip() - if decoded_line: - self.rx_queue.append(decoded_line) - except Exception: - pass # Discard corrupt data frames safely - except Exception as e: - print("[UART Thread Error]:", e) + chunk = self.uart.read() + if chunk is not None and isinstance(chunk, bytes): + self.buffer += chunk - time.sleep_ms(20) # Give other background threads breathing room + # Extract complete JSON objects while accounting for nested braces + while True: + start_idx = self.buffer.find(b'{') + if start_idx == -1: + # No starting brace; clear any garbage bytes currently in buffer + self.buffer = b"" + break + + # Trim any leading noise before the first '{' + if start_idx > 0: + self.buffer = self.buffer[start_idx:] + + # Track depth to find the matching OUTER '}' + depth = 0 + in_string = False + escape = False + end_idx = -1 + + for i in range(len(self.buffer)): + b = self.buffer[i] + + # Ignore braces inside string literals ("...") + if b == 34 and not escape: # 34 is ASCII for '"' + in_string = not in_string + elif b == 92 and in_string: # 92 is ASCII for '\' + escape = not escape + continue + elif not in_string: + if b == 123: # '{' + depth += 1 + elif b == 125: # '}' + depth -= 1 + if depth == 0: + end_idx = i + break + escape = False + + if end_idx != -1: + # Full nested JSON object extracted safely + json_bytes = self.buffer[:end_idx + 1] + self.buffer = self.buffer[end_idx + 1:] + messages_found.append(json_bytes) + else: + # The complete outer JSON hasn't fully arrived yet; wait for next UART chunk + break + + # Process valid complete frames outside the lock + for json_bytes in messages_found: + try: + decoded_str = json_bytes.decode('utf-8') + with self.lock: + self.rx_queue.append(decoded_str) + except Exception as e: + print(f"[UART Parse Error]: {e}") + + time.sleep_ms(10) def send(self, message): """Safely pushes strings across the serial wire from any thread context.""" if not message.endswith('\n'): message += '\n' + data = message.encode('utf-8') + with self.lock: - self.uart.write(message.encode('utf-8')) + self.uart.write(data) + + def send_as_command(self, command: 'UARTCommand'): + """Safely sends a structured command over UART.""" + json_message = command.to_json() + self.send(json_message) def any(self): """Checks if any complete messages are waiting to be read.""" @@ -63,4 +113,51 @@ class SafeUART: with self.lock: if self.rx_queue: return self.rx_queue.pop(0) - return None \ No newline at end of file + return None + + def read_as_command(self) -> 'UARTCommand | None': + """Attempts to read the oldest unread string and parse it as a UARTCommand. Returns None if empty or invalid.""" + raw_message = self.read() + if raw_message is not None: + cmd = UARTCommand.from_json(raw_message) + if cmd is None: + print("[UART] Impossible de traiter le message brut :", raw_message) + return cmd + return None + + +class UARTCommand: + """A simple wrapper for commands sent over UART, allowing for structured data.""" + def __init__(self, command_type: str, payload): + self.command_type = command_type + self.payload = payload + + def to_json(self): + """Serializes the command to a JSON string.""" + return ujson.dumps({ + "command_type": self.command_type, + "payload": self.payload + }) + + @staticmethod + def from_json(json_string: str) -> 'UARTCommand | None': + """Deserializes a JSON string into a UARTCommand object.""" + try: + # Remplacement préventif si des guillemets simples sont reçus + clean_str = json_string.replace("'", '"') if "'" in json_string else json_string + data = ujson.loads(clean_str) + + if not isinstance(data, dict): + return None + + return UARTCommand(data.get("command_type"), data.get("payload")) + except Exception as err: + # Affiche l'erreur exacte rencontrée par ujson (ex: syntax error) + print(f"[UARTCommand Parsing Error]: {err} -> Contenu: {json_string}") + return None + + +class UARTCommandType: + """Enumeration of known UART command types.""" + COOKING_PARAMS = "COOKING_PARAMS" + COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE" \ No newline at end of file