Compare commits

..

4 Commits

Author SHA1 Message Date
Ninluc 5c60017e8d Wait, is this peak ?
Build, push image, and notify Watchtower / build-image (push) Successful in 3m32s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-04 18:21:20 +02:00
Ninluc caf81d4bbb Asyncio refactor for wifi and rpi 2026-08-04 17:13:01 +02:00
Ninluc 43a1822547 Working MQTT back ! 2026-08-03 16:41:48 +02:00
Ninluc 9eac93c409 Simplified Uart comunications 2026-08-01 15:45:58 +02:00
15 changed files with 1258 additions and 770 deletions
+75 -25
View File
@@ -1,9 +1,13 @@
import _thread import _thread
from machine import Pin from machine import Pin, SoftI2C
from shared.safeQueue import SafeQueue
from shared import get_lora, get_uart, deviceTypes, config, cookingState from shared import get_lora, get_uart, deviceTypes, config, cookingState
from shared.uart_comm import UARTCommand, UARTCommandType from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED from shared.sensors import RGBLED
from shared.logging import log from shared.logging import log
from shared.lora_device import LoraCommands
import framebuf
import ssd1306
import time import time
# --- Configuration Matérielle --- # --- Configuration Matérielle ---
@@ -21,44 +25,56 @@ except Exception:
# --- Initialisation LoRa --- # --- Initialisation LoRa ---
lora = get_lora() lora = get_lora()
lora.configure(freq=868.1, sf=7) lora.configure(freq=868.1, sf=7)
data_queue = SafeQueue()
# --- Création des lEDs RGB --- # --- Création des lEDs RGB ---
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33) magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
magnetron_led.color = RGBLED.WHITE_YELLOW magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off() magnetron_led.off()
# --- Création de l'écran OLED ---
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
display.text("Booting...", 1, 2, 1)
display.show()
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})") print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
def heartbeat_loop(): PING_PAYLOAD = {
while True:
print(f"\nESP32 : Envoi du Heartbeat...")
# Envoi périodique
ping_payload = {
"id": DEVICE_ID, "id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"] "type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
} }
lora.send(ping_payload)
# Le receive_packet est maintenant protégé par le lock dans lora_device def heartbeat_loop():
# Si le main thread utilise la radio, ce thread attendra son tour last_heartbeat_time = 0
paquet = lora.receive_packet(timeout_ms=2000) while True:
now = time.time()
if paquet and not paquet["raw"]: # 1. Send periodic heartbeat
donnees = paquet["data"] if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur last_heartbeat_time = now
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]: print("\nESP32 : Envoi du Heartbeat...")
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]") lora.send(PING_PAYLOAD)
else:
print(f"ESP32 : Paquet reçu d'un type inattendu : {donnees.get('type')}")
else:
print("ESP32 : Pas de réponse de l'orchestrateur (Le RPI est-il éteint ?)")
time.sleep(config.HEARTBEAT_INTERVAL) # 2. Increase listen window to 300ms so radio stays active in RX mode
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
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45) uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
# Lancer la boucle de heartbeat dans un thread séparé # 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, ()) _thread.start_new_thread(heartbeat_loop, ())
# Cooking parameters # Cooking parameters
@@ -71,8 +87,13 @@ def cooking_state_on_state_change(state):
# Send to the Wifi board the current state # Send to the Wifi board the current state
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.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})
if state.paused or state.state == cookingState.CookingStates.DONE: display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
display.show()
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
magnetron_led.off() magnetron_led.off()
else: else:
magnetron_led.on() magnetron_led.on()
@@ -83,8 +104,7 @@ def cooking_state_on_state_change(state):
if state.state == cookingState.CookingStates.STIRRING_REQUIRED: if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
pass pass
if state.state == cookingState.CookingStates.DONE: if state.state == cookingState.CookingStates.DONE:
global cooking_state pass
cooking_state = None
if state.state == cookingState.CookingStates.ALERT: if state.state == cookingState.CookingStates.ALERT:
pass pass
@@ -92,6 +112,13 @@ def cooking_state_on_refresh(state):
# TODO Show screen information # TODO Show screen information
pass 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
# --- MAIN APPLICATION THREAD --- # --- MAIN APPLICATION THREAD ---
print("[Main] Main execution path active.") print("[Main] Main execution path active.")
@@ -113,17 +140,40 @@ while True:
cooking_state.set_temperature_provider(cooking_state_temperature_provider) cooking_state.set_temperature_provider(cooking_state_temperature_provider)
cooking_state.set_state_change_callback(cooking_state_on_state_change) cooking_state.set_state_change_callback(cooking_state_on_state_change)
cooking_state.set_refresh_callback(cooking_state_on_refresh) 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 time.sleep_ms(20) # Before sending back right away
cooking_state_on_state_change(cooking_state) cooking_state_on_state_change(cooking_state)
else: else:
print(f"[Main] Unknown command type received: {command.command_type}") print(f"[Main] Unknown command type received: {command.command_type}")
# 2. Listen for incoming LoRa packets from the orchestrator
while not data_queue.empty():
paquet = data_queue.get()
if paquet and not paquet["raw"]:
data = paquet["data"]
# Commands
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.")
cooking_state.set_state(cookingState.CookingStates.IDLE)
time.sleep_ms(20) # Before sending back right away
cooking_state = None
else:
cooking_state.toggle_pause()
if cooking_state.paused:
print("[Main] Cooking paused via orchestrator command.")
else:
print("[Main] Cooking resumed via orchestrator command.")
else:
log("[Main] No active cooking state to toggle pause/resume.")
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}") # uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
# Cooking State Update # Cooking State Update
if cooking_state: if cooking_state != None:
cooking_state.update_tick() 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"[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) time.sleep_ms(500)
+2 -2
View File
@@ -14,10 +14,10 @@ while True:
mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2} mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2}
# Envoi direct (le pilote s'occupe de mettre le groupe \x02) # Envoi direct (le pilote s'occupe de mettre le groupe \x02)
lora.send(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8')) lora.send_reliable(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8'))
# Réception propre # Réception propre
paquet = lora.receive_packet(3000) paquet = lora.receive_reliable(3000)
if paquet: if paquet:
# paquet est un dict : {"group": 2, "data": {...}, "raw": False} # paquet est un dict : {"group": 2, "data": {...}, "raw": False}
print(f"ESP32 : Message reçu du groupe {paquet['group']}") print(f"ESP32 : Message reçu du groupe {paquet['group']}")
+43 -9
View File
@@ -5,16 +5,50 @@ esp.osdebug(True)
#import webrepl #import webrepl
#webrepl.start() #webrepl.start()
def do_connect(ssid, pwd): # def do_connect(ssid, pwd):
# import network
# sta_if = network.WLAN(network.STA_IF)
# sta_if.config(pm=sta_if.PM_NONE)
# if not sta_if.isconnected():
# print('connecting to network...')
# sta_if.active(True)
# sta_if.connect(ssid, pwd)
# while not sta_if.isconnected():
# pass
# print('network config:', sta_if.ifconfig())
import network import network
sta_if = network.WLAN(network.STA_IF) import time
if not sta_if.isconnected():
print('connecting to network...') def do_connect(ssid, password):
sta_if.active(True) wlan = network.WLAN(network.STA_IF)
sta_if.connect(ssid, pwd)
while not sta_if.isconnected(): # 1. ALWAYS activate the interface FIRST
pass if not wlan.active():
print('network config:', sta_if.ifconfig()) wlan.active(True)
# 2. Configure Wi-Fi options AFTER activation
try:
# Disable Wi-Fi modem sleep (0 = PM_NONE)
wlan.config(pm=0)
except Exception as e:
print("[Wi-Fi] Warning: Failed to set power management:", e)
# 3. Connect to the access point
if not wlan.isconnected():
print(f"[Wi-Fi] Connecting to {ssid}...")
wlan.connect(ssid, password)
timeout = 15
start_time = time.time()
while not wlan.isconnected():
if time.time() - start_time > timeout:
print("[Wi-Fi] Connection timed out!")
return False
time.sleep(0.5)
print("[Wi-Fi] Connected! Network config:", wlan.ifconfig())
return True
# Attempt to connect to WiFi network # Attempt to connect to WiFi network
do_connect("Smartwave-1", 'Smartwave-prot-1') do_connect("Smartwave-1", 'Smartwave-prot-1')
+305 -190
View File
@@ -1,23 +1,12 @@
import _thread import gc
import select import sys
from machine import Pin, I2C
from sensors import temperature_sensor
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 time
import ujson as json import ujson as json
import sys import uasyncio as asyncio
from machine import Pin, I2C
# Simple thread-safe queue list # 1. Clean memory immediately before performing any operations
msg_queue = [] gc.collect()
queue_lock = _thread.allocate_lock()
def queue_publish(topic, payload):
"""Safely queues a message from the main thread."""
with queue_lock:
msg_queue.append((topic, payload))
# --- READ DEVICE ID --- # --- READ DEVICE ID ---
try: try:
@@ -26,214 +15,340 @@ try:
except Exception: except Exception:
DEVICE_ID = "ESP32_Inconnu" DEVICE_ID = "ESP32_Inconnu"
# --- Cooking State --- # --- GLOBAL APP STATE ---
cooking_state = None # This will hold the current cooking state if any orchestrator_id = None
cooking_state = None
mqtt_connected = False
should_unsubscribe_hello = False
# --- ASYNC SIGNALS & QUEUES ---
# Event to signal when orchestrator requests sensor data (prevents MQTT lock deadlock)
sensor_request_event = None
# --- MQTT SETUP --- # --- MQTT SETUP ---
from shared import get_mqtt_client, config, payloads
MQTT_CA_FILE = "/certs/ca.crt" MQTT_CA_FILE = "/certs/ca.crt"
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host=config.MQTT_BROKER_HOST, host="192.168.50.1",
client_id="smartwave-esp32-" + DEVICE_ID, client_id="smartwave-esp32-demo",
use_tls=config.USE_TLS, use_tls=True,
cafile=MQTT_CA_FILE, cafile=MQTT_CA_FILE,
keepalive=config.MQTT_KEEPALIVE, keepalive=30,
) )
global orchestrator_id # --- HARDWARE & MODULE DEFERRED IMPORTS ---
orchestrator_id = None status_led = None
def on_mqtt_message(message): uart_device = None
print("[MQTT Thread] Received message:", message) mlx_temperature_sensor = None
cookingState = None
log = None
UARTCommand = None
UARTCommandType = None
def init_hardware():
"""Initializes hardware peripherals AFTER MQTT TLS has reserved its RAM."""
global status_led, uart_device, mlx_temperature_sensor
global cookingState, log, UARTCommand, UARTCommandType
print("[Main] Initializing hardware peripherals...")
from shared import get_uart, cookingState as cs, logging
from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
from shared.sensors import RGBLED
from sensors import temperature_sensor
cookingState = cs
log = logging.log
UARTCommand = UC
UARTCommandType = UCT
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
temperature_sensor_i2c = I2C(
0,
scl=Pin(25, Pin.IN, Pin.PULL_UP),
sda=Pin(26, Pin.IN, Pin.PULL_UP),
freq=100000,
)
devices = temperature_sensor_i2c.scan()
if 0x5A in devices:
print("[Main] MLX90614 found at address 0x5A!")
else:
print("[Main] MLX90614 not found on I2C bus.")
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
if status_led and cookingState:
if state == cookingState.CookingStates.IDLE:
status_led.color = status_led.OFF
status_led.blink_off()
elif state == cookingState.CookingStates.COOKING:
status_led.color = status_led.YELLOW
status_led.blink_off()
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
status_led.color = status_led.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS)
elif state == cookingState.CookingStates.ALERT:
status_led.color = status_led.RED
status_led.blink_on(BLINK_INTERVAL_MS)
elif state == cookingState.CookingStates.DONE:
status_led.color = status_led.GREEN
status_led.blink_off()
def on_mqtt_message(message):
"""Sync callback: Lightweight! Only updates variables or triggers async signals."""
global orchestrator_id, cooking_state, should_unsubscribe_hello
print("[MQTT] Received message on topic:", message.get("topic"))
# Try and parse the payload as json, but if it fails, just print the raw payload
payload_data = None payload_data = None
try: try:
payload_data = json.loads(message['payload']) payload_data = json.loads(message["payload"])
except Exception as e: except Exception as e:
print("[MQTT Thread] Error parsing JSON:", e) print("[MQTT] Payload parsing warning:", e)
sys.print_exception(e)
pass # Maybe it's not JSON
if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID: topic = message.get("topic")
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"])
global orchestrator_id
orchestrator_id = payload_data["id_orchestrator"]
# Unsubscribe from the hello topic since we got a response
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
# Handle cooking messages # 1. Orchestrator Hello Response
elif message['topic'] == config.MQTT_TOPIC_COOKING and payload_data and payload_data["id_microwave"] == DEVICE_ID: if (
# Cooking sensors init request topic == config.MQTT_TOPIC_HELLO
if not "cook_time" in payload_data: and payload_data
print("[MQTT Thread] Cooking sensors init received from the orchestrator") and payload_data.get("id_microwave") == DEVICE_ID
obj_temp = mlx_temperature_sensor.read_object_temp() ):
amb_temp = mlx_temperature_sensor.read_ambient_temp() orchestrator_id = payload_data.get("id_orchestrator")
queue_publish(config.MQTT_TOPIC_SENSOR, payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)) print("[MQTT] Hello response received from orchestrator:", orchestrator_id)
# Received cooking parameters from the orchestrator should_unsubscribe_hello = True
# 2. Cooking Parameters / Sensor Request
elif (
topic == config.MQTT_TOPIC_COOKING
and payload_data
and payload_data.get("id_microwave") == DEVICE_ID
):
if "cook_time" not in payload_data:
print("[MQTT] Sensor data requested! Triggering async publisher...")
# Trigger async event instead of calling publish() directly inside lock context!
sensor_request_event.set()
else: else:
print("[MQTT Thread] Cooking parameters received from the orchestrator:", payload_data) print("[MQTT] Cooking parameters received:", payload_data)
global cooking_state if cookingState:
cooking_state = cookingState.CookingState( cooking_state = cookingState.CookingState(
cook_time=payload_data["cook_time"], cook_time=payload_data["cook_time"],
power_level=payload_data["power_level"], power_level=payload_data["power_level"],
target_temp=payload_data["target_temp"] target_temp=payload_data["target_temp"],
) )
cooking_state.set_state_change_callback(on_cooking_state_change) cooking_state.set_state_change_callback(on_cooking_state_change)
cooking_state.set_state(cookingState.CookingStates.IDLE) # Set initial state to IDLE cooking_state.set_state(cookingState.CookingStates.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.") if uart_device and UARTCommand:
uart_device.send_as_command(
UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)
)
print("[MQTT] Cooking parameters sent to LoRa board over UART.")
# --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
async def sensor_publisher_task():
"""Waits for sensor_request_event, reads hardware, and publishes outside the MQTT lock."""
while True:
await sensor_request_event.wait()
sensor_request_event.clear()
print("[Sensor Task] Reading temperature sensors...")
obj_temp = (
mlx_temperature_sensor.read_object_temp()
if mlx_temperature_sensor
else 0
)
amb_temp = (
mlx_temperature_sensor.read_ambient_temp()
if mlx_temperature_sensor
else 0
)
sensor_payload = payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)
try:
print("[Sensor Task] Publishing sensor data to MQTT...")
mqtt_client.publish(
config.MQTT_TOPIC_SENSOR, sensor_payload, qos=config.MQTT_QOS
)
print("[Sensor Task] Sensor data successfully published:", sensor_payload)
except Exception as e:
print("[Sensor Task] Failed to publish sensor data:", e)
async def uart_task():
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
while True:
if uart_device:
try:
cmd = uart_device.read_as_command()
if cmd:
print("[UART] Command received from LoRa board:", cmd)
if (
hasattr(cmd, "command_type")
and cmd.command_type == UARTCommandType.STATE_UPDATE
and on_received_cooking_state_update
):
on_received_cooking_state_update(
cmd.payload.get("state"),
cmd.payload.get("is_error", False),
cmd.payload.get("is_terminated", False),
)
except Exception as e:
print("[UART Task] Error reading command:", e)
await asyncio.sleep_ms(50)
async def connect_mqtt_async():
global mqtt_connected, mqtt_client
mqtt_connected = False
while True:
try:
print("[MQTT] Connecting to broker with TLS...")
# Re-instantiate client to clear old socket buffers
gc.collect()
mqtt_client = get_mqtt_client(
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
port=8884,
client_id="smartwave-esp32-demo",
use_tls=True,
cafile=MQTT_CA_FILE,
keepalive=30,
)
mqtt_client.set_callback(on_mqtt_message) mqtt_client.set_callback(on_mqtt_message)
def mqtt_background_thread():
"""Background MQTT worker handling ALL socket operations safely."""
print("[Thread] Background MQTT worker started.")
while True:
try:
print("[Thread] Attempting connection to MQTT broker...")
mqtt_client.connect() mqtt_client.connect()
print("[Thread] Connected! Subscribing to topic...") print("[MQTT] Connected! Subscribing to topics...")
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
print("[Thread] Successfully subscribed. Setting up poller...") mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
print("[MQTT] Subscribed successfully!")
poller = select.poll() mqtt_connected = True
poller.register(mqtt_client._client.sock, select.POLLIN) return
last_check = time.time()
while True:
# 1. Process outbound messages queued by the main thread
while len(msg_queue) > 0:
with queue_lock:
topic, payload = msg_queue.pop(0)
print(f"[Thread] Safely publishing queued message to {topic}...")
mqtt_client.publish(topic, payload, qos=config.MQTT_QOS)
# 2. Check for incoming messages (non-blocking poll)
events = poller.poll(200)
if events:
mqtt_client.wait()
# 3. Handle Keepalive tracking manually
if time.time() - last_check >= 15:
# print("[Thread] Sending keepalive ping...")
mqtt_client._client.ping()
last_check = time.time()
# Small breathe room for the CPU core
time.sleep_ms(50)
except Exception as e: except Exception as e:
print("[Thread] Connection dropped or error encountered:", e) print("[MQTT] Connection failed:", e)
sys.print_exception(e) sys.print_exception(e)
print("[Thread] Cleaning up socket context. Retrying in 5 seconds...")
# --- FIX FOR ERROR 23 (SOCKET LEAK) ---
# Manually force-kill the underlying socket file descriptor if it exists
try:
if mqtt_client._client and hasattr(mqtt_client._client, "sock"):
if mqtt_client._client.sock is not None:
mqtt_client._client.sock.close()
except Exception:
pass # Already dead or closed
# Now we let the wrapper do its normal cleanup safely
try: try:
mqtt_client.close() mqtt_client.close()
except Exception: except Exception:
pass pass
time.sleep(5)
# UART # Force heap cleanup before sleeping
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16) del mqtt_client
gc.collect()
print(f"[MQTT] Free RAM after cleanup: {gc.mem_free()} bytes")
print("[MQTT] Retrying connection in 5 seconds...")
await asyncio.sleep(5)
# Cooking Cycle async def mqtt_poll_task():
cooking_state = None global mqtt_connected
def on_received_cooking_state_update(new_state): last_ping = time.time()
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.")
# 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)
# Scan to verify the sensor is connected and detected
print("Scanning I2C bus...")
devices = temperature_sensor_i2c.scan()
if 0x5A in devices:
print("MLX90614 found at address 0x5A!")
else:
print("MLX90614 not found. Please check your wiring.")
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: while True:
# MQTT HELLO sent every x seconds until we get a response from the orchestrator if mqtt_connected:
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL): try:
print("[Main] Attempting to send initial hello to orchestrator...") mqtt_client.poll()
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID)) now = time.time()
mqtt_hello_sent_timestamp = time.time() if now - last_ping >= 15:
pass mqtt_client.ping()
last_ping = now
except OSError as e:
print("[MQTT Task] Socket error encountered during poll/ping:", e)
mqtt_connected = False
await connect_mqtt_async()
# 1. Listen for incoming UART serial packets from the WROOM board await asyncio.sleep_ms(30)
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") async def orchestrator_hello_task():
time.sleep(1) global mqtt_connected, should_unsubscribe_hello
while True:
if orchestrator_id is not None:
if should_unsubscribe_hello:
try:
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
should_unsubscribe_hello = False
print("[MQTT] Successfully unsubscribed from hello topic.")
except Exception as e:
print("[MQTT] Unsubscribe error:", e)
# Hello successfully acknowledged! Stop looping this task.
print("[Hello Task] Orchestrator acknowledged. Stopping hello task.")
break
if mqtt_connected:
print("[Hello Task] Sending initial hello to orchestrator...")
try:
if mqtt_client is None:
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
await connect_mqtt_async()
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello(DEVICE_ID),
qos=config.MQTT_QOS,
)
except OSError as e:
print("[Hello Task] Hello publish failed:", e)
# mqtt_connected = False
await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
async def memory_cleanup_task():
while True:
gc.collect()
await asyncio.sleep(10)
# --- MAIN ENTRY POINT ---
async def main():
global sensor_request_event
print("[Main] Starting application...")
# Initialize loop-bound events
sensor_request_event = asyncio.Event()
await connect_mqtt_async()
init_hardware()
# Launch background tasks
asyncio.create_task(mqtt_poll_task())
asyncio.create_task(orchestrator_hello_task())
asyncio.create_task(sensor_publisher_task())
asyncio.create_task(uart_task())
asyncio.create_task(memory_cleanup_task())
print("[Main] All tasks running concurrently!")
while True:
await asyncio.sleep(3600)
try:
asyncio.run(main())
except KeyboardInterrupt:
print("[Main] Program stopped by user.")
+288 -323
View File
@@ -1,58 +1,52 @@
import base64 import base64
import json import json
import threading
import queue
import time import time
import traceback import traceback
import asyncio
import requests import requests
from orchestrateur.sensors import gps 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
from shared.logging import log from shared.logging import log
from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands
from sensors import ultrasonicRanger, temp_hum, button, camera from sensors import ultrasonicRanger, temp_hum, button, camera
# --- Read Unique Device ID --- # --- Read Unique Device ID ---
def get_device_id():
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
try: try:
with open("device_id.txt", "r") as f: with open(path, "r") as f:
DEVICE_ID = f.read().strip() return f.read().strip()
except Exception: except Exception:
try: pass
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f: return "RPI_Orchestrateur_Default"
DEVICE_ID = f.read().strip()
except Exception:
DEVICE_ID = "RPI_Orchestrateur_Default"
# Thread-safe queue for application messages DEVICE_ID = get_device_id()
data_queue = queue.Queue()
cooking_queue = {}
active_cooks = {}
active_cooks_lock = threading.Lock()
# --- STATE MACHINE DEFINITIONS ---
class MicrowaveState:
IDLE = "IDLE" # Microwave is empty
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
COOKING = "COOKING" # Microwave is active
DONE = "DONE" # Finished/Stopped, waiting for dish removal
# Global state trackers
microwave_states = {"2": MicrowaveState.IDLE}
button_state = False
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()
# --- HARDWARE SETUP ---
lora = get_lora() lora = get_lora()
lora.configure() lora.configure()
def lora_listener():
"""Background Thread: Listens to LoRa traffic and responds to Heartbeats."""
print("Thread Écouteur LoRa démarré.")
while True:
paquet = lora.receive_packet(timeout_ms=1000)
if paquet:
donnees = paquet["data"]
expediteur_type = donnees.get("type")
if expediteur_type == deviceTypes.DEVICE_TYPES["MICROWAVE"]:
print(f"\n[Thread LoRa] Heartbeat reçu de {donnees.get('id')}")
reponse = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]
}
lora.send(reponse)
else:
data_queue.put({"source": "LoRa", "data": paquet})
# --- Setup & Connect MQTT ---
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host="192.168.50.1", # Using explicit gateway IP to dodge Docker loopback blocks host="192.168.50.1",
client_id="smartwave-orchestrateur-" + DEVICE_ID, client_id="smartwave-orchestrateur-" + DEVICE_ID,
use_tls=config.USE_TLS, use_tls=config.USE_TLS,
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt", cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
@@ -60,335 +54,306 @@ mqtt_client = get_mqtt_client(
) )
mqtt_client.connect() mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
print(f"Subscribed to topic: {config.MQTT_TOPIC_SENSOR}")
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
print(f"Subscribed to topic: {config.MQTT_TOPIC_HELLO}")
# --- THE CRUCIAL PAHO FIX ---
# Start Paho's internal background thread. This handles all network packets,
# automatic keepalive pings, and delivery receipts cleanly.
if hasattr(mqtt_client._client, "loop_start"): if hasattr(mqtt_client._client, "loop_start"):
mqtt_client._client.loop_start() mqtt_client._client.loop_start()
print("Paho MQTT asynchronous network loop started.") print("[MQTT] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) ---
async def lora_listener_task():
"""Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.")
while True:
# Run blocking lora receive in a thread to not block asyncio loop
paquet = await asyncio.to_thread(lora.receive_reliable, timeout_ms=100)
if paquet:
await async_event_queue.put({"source": "LoRa", "data": paquet})
await asyncio.sleep(0.05)
def mqtt_listener(): async def mqtt_listener_task():
"""Background Thread: Constantly inspects incoming MQTT message cache.""" """Polls MQTT cache and pushes to the async queue."""
print("Thread MQTT démarré.") print("[MQTT] Async listener started.")
while True: while True:
message = mqtt_client.get_message() message = mqtt_client.get_message()
if message: if message:
# Try to parse the payload as a python dictionary, but if it fails, just print the raw payload
try: try:
payload = json.loads(message['payload']) payload = json.loads(message['payload'])
except Exception as e: except Exception:
print(f"Error parsing MQTT payload: {e}") payload = message['payload']
payload = message['payload'] # Fallback to raw payload if parsing fails
print(f"\n[Thread MQTT] Message reçu : {message}") # --- SAFE TOPIC DECODING ---
data_queue.put({"source": "MQTT", "topic": message['topic'] ,"data": payload}) topic = message['topic']
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog. await async_event_queue.put({
time.sleep(0.2) "source": "MQTT",
"topic": topic,
"data": payload
})
await asyncio.sleep(0.1)
# Button
button_state = False
def button_callback(): def button_callback():
"""Button physical interrupt callback."""
global button_state global button_state
if microwave_states.get("2") == MicrowaveState.COOKING:
print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
else:
button_state = not button_state button_state = not button_state
print(f"\n[Thread Button] Button state changed to: {button_state}") print(f"[Button] Defrost state toggled to: {button_state}")
button.set_callback(button_callback) button.set_callback(button_callback)
# Launch background monitoring workers
threading.Thread(target=lora_listener, daemon=True).start()
threading.Thread(target=mqtt_listener, daemon=True).start()
# Launch button monitoring thread
button.start_button_monitoring_thread() button.start_button_monitoring_thread()
print("Orchestrateur prêt. Le main loop est libre.") # --- HARDWARE CONTROLLERS ---
def _tryReadSensorsWithRetries(func, exception=True, max_retries=3, delay=1):
"""
Tries to read the sensor max_retries times until the return value of func is not None.
It will then return the value of func. If it fails max_retries times, it will fail if exception is True, otherwise it will return None.
"""
for attempt in range(max_retries):
result = func()
if result is not None:
return result
else:
log(f"Attempt {attempt + 1} failed. Retrying in {delay} seconds...")
time.sleep(delay)
if exception:
raise Exception(f"Failed to read sensor after {max_retries} attempts.")
else:
return None
def read_sensors_for_cooking(microwave_id):
"""Read all sensors and return a dictionary of their values, including the microwave ID."""
log("\nLecture des capteurs...")
sensor_data = {}
sensor_data["microwave_id"] = microwave_id
# === Notify the microwave of needed sensor readings ===
mqtt_client.publish(config.MQTT_TOPIC_COOKING, payloads.mqtt_cooking_init(microwave_id), qos=config.MQTT_QOS)
# Read Ultrasonic Ranger
sensor_data["ultrasonic_distance"] = _tryReadSensorsWithRetries(ultrasonicRanger.get_dish_height)
log(f"\nLecture du capteur Ultrason : {sensor_data['ultrasonic_distance']}")
# Read 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
sensor_data["humidity"] = humidity
# Camera
def _getPicture():
picture_bytes = None
try:
picture_bytes = camera.get_picture()
return picture_bytes
except Exception as e:
log(f"Error reading camera data: {e}")
return None
sensor_data["camera_image"] = _tryReadSensorsWithRetries(_getPicture, exception=True)
log(f"\nPhoto de la Caméra : {len(sensor_data['camera_image'])} bytes")
# Read Button State (last because he can still change state while reading other sensors)
sensor_data["defrost_mode"] = button_state
cooking_queue[microwave_id] = sensor_data
def _stop_hardware(microwave_id: str): def _stop_hardware(microwave_id: str):
""" print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.")
Hardware driver stop — halts magnetron/turntable immediately. # TODO: Add LoRa STOP command here
"""
print(f"[{microwave_id}] 🛑 Emergency stop issued to hardware.")
# TODO: Add physical hardware stop command here
# e.g., gpio_controller.stop()
# --- ASYNC COOKING LOGIC ---
def read_local_sensors(microwave_id, initial_dish_height):
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
print(f"[{microwave_id}] Reading local physical sensors...")
sensor_data = {
"microwave_id": microwave_id,
"defrost_mode": button_state,
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
}
def _send_params_to_microwave(microwave_id: str, cook_time: int, power_level: int, target_temp: float, cancel_event: threading.Event): # Temp / Hum (handles DHT error safely)
"""
Triggers physical microwave execution.
"""
if cancel_event.is_set():
return
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."""
URL = "https://smartwave.matthiasg.dev/cooking-params"
try: try:
# Check cancellation before network call temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
if cancel_event.is_set(): if temp is not None:
print(f"[{microwave_id}] Job canceled before starting API call.") sensor_data["temperature"] = temp
return sensor_data["humidity"] = hum
log(f"[{microwave_id}] Sending sensor data to cloud API...")
# Ensure camera_image is encoded to Base64 string if it's currently raw bytes
if isinstance(sensors_data.get("camera_image"), bytes):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
# 1. HTTP Request (15-second timeout)
response = requests.post(URL, json=sensors_data, timeout=360) # timeout for long-running requests
# Check cancellation right after network call returns
if cancel_event.is_set():
print(f"[{microwave_id}] Job was canceled while waiting for cloud response. Discarding result.")
return
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()}")
response.raise_for_status()
# 2. Extract Response Parameters
response_json = response.json()
cook_plan = response_json.get("cook_plan", {})
cook_time = cook_plan.get("cook_time_seconds")
power_level = cook_plan.get("effective_power_watts")
target_temp = cook_plan.get("target_temp")
dish_name = response_json.get("dish_name", "Unknown Dish")
if cook_time is None or power_level is None or target_temp is None:
print(f"[{microwave_id}] Cloud returned incomplete plan: {response_json}")
return
# Check cancellation before starting physical microwave
if cancel_event.is_set():
print(f"[{microwave_id}] Job was canceled before starting hardware execution.")
return
print(f"[{microwave_id}] Received plan for '{dish_name}': {cook_time}s @ {power_level}W power, target temp {target_temp}°C.")
# 3. Start Hardware Execution
_send_params_to_microwave(microwave_id, cook_time, power_level, target_temp, cancel_event)
except requests.exceptions.Timeout:
print(f"[{microwave_id}] Request timed out waiting for cloud response.")
except requests.exceptions.RequestException as e:
print(f"[{microwave_id}] HTTP error reaching cloud API: {e}")
except Exception as e: except Exception as e:
print(f"[{microwave_id}] Unexpected error in worker thread: {e}") log(f"[{microwave_id}] DHT read warning: {e}")
traceback.print_exc()
finally:
# Clean up registry entry if this worker was the active one
with active_cooks_lock:
if active_cooks.get(microwave_id) == cancel_event:
del active_cooks[microwave_id]
def start_cooking_for_microwave(microwave_id: str, sensors_data: dict):
"""
Sends sensors data to the cloud and starts cooking in a separate thread.
If a worker is already running for the given microwave_id, it cancels
the previous process and stops the hardware before starting the new one.
"""
with active_cooks_lock:
# 1. If an active job exists for this microwave, cancel it
if microwave_id in active_cooks:
print(f"[{microwave_id}] Existing cooking job detected! Canceling old worker...")
active_cooks[microwave_id].set() # Signal existing thread to abort
_stop_hardware(microwave_id) # Stop hardware immediately
# 2. Register a new cancellation event for this microwave
cancel_event = threading.Event()
active_cooks[microwave_id] = cancel_event
# 3. Start the new background worker thread
thread = threading.Thread(
target=_cooking_worker,
args=(microwave_id, sensors_data, cancel_event),
daemon=True
)
thread.start()
# Sensor reading
def read_sensors():
"""Read all sensors and return a dictionary of their values."""
log("\nLecture des capteurs...")
sensor_data = {}
# Read Ultrasonic Ranger
distance = ultrasonicRanger.get_dish_height()
if distance is not None:
log(f"\nLecture du capteur Ultrason : {distance}")
sensor_data["ultrasonic_distance"] = distance
# Read Temperature and Humidity
temperature, humidity = temp_hum.get_temperature_and_humidity()
if temperature is not None and humidity is not None:
log(f"\nLecture du capteur Temp/Hum : {temperature}, {humidity}")
sensor_data["temperature"] = temperature
sensor_data["humidity"] = humidity
# Read GPS Data
gps_data = gps.get_gps_data()
if gps_data:
log(f"\nLecture du capteur GPS : {gps_data}")
sensor_data["gps"] = gps_data
# Camera # Camera
picture_bytes = None
try: try:
picture_bytes = camera.get_picture() sensor_data["camera_image"] = camera.get_picture()
log(f"\nLecture du capteur Caméra : {len(picture_bytes)} bytes")
sensor_data["camera_image"] = picture_bytes
except Exception as e: except Exception as e:
log(f"Error reading camera data: {e}") log(f"[{microwave_id}] Camera read failed: {e}")
# Read Button State (last because he can still change state while reading other sensors)
sensor_data["defrost_state"] = button_state
return sensor_data return sensor_data
# --- MAIN EXECUTION LOOP ---
while True: async def handle_new_dish(microwave_id, detected_height):
"""Triggered when a new dish is placed inside."""
microwave_states[microwave_id] = MicrowaveState.ANALYZING
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
# 1. Setup synchronization event and clear previous cache for this microwave
event = asyncio.Event()
ir_data_events[microwave_id] = event
ir_data_cache.pop(microwave_id, None)
# 2. Send IR request to ESP32 via MQTT immediately
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_init(microwave_id),
qos=config.MQTT_QOS
)
# 3. Start local sensor reading in parallel
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
# 4. Wait for local sensors to finish reading
sensors_data = await sensor_task
# Check if dish was removed while reading sensors
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
ir_data_events.pop(microwave_id, None)
return
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly)
try: try:
# === TREAT MESSAGE QUEUE === await asyncio.wait_for(event.wait(), timeout=10.0)
ir_payload = ir_data_cache.get(microwave_id, {})
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
except asyncio.TimeoutError:
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None
finally:
ir_data_events.pop(microwave_id, None)
# 6. Dispatch cloud request task
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."""
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params"
# Format image
if isinstance(sensors_data.get("camera_image"), bytes):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
try: try:
msg = data_queue.get(block=False) response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
# print(msg) # Abort if state changed (e.g. user removed dish while waiting for wifi)
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return
if msg["source"] == "LoRa": response.raise_for_status()
print(f"\n[Main Loop] LoRa : Données traitées : {msg['data']}") plan = response.json().get("cook_plan", {})
elif msg["source"] == "MQTT": c_time = plan.get("cook_time_seconds")
# MQTT HELLO c_power = plan.get("effective_power_watts")
if (msg["topic"] == config.MQTT_TOPIC_HELLO.decode('utf-8')): c_temp = plan.get("target_temp")
if ("id_orchestrator" in msg["data"] and msg["data"]["id_orchestrator"] == DEVICE_ID):
# Do not answer to messages coming from me
continue
microwave_id = msg["data"]["id_microwave"]
print(f"\n[Main Loop] MQTT : Hello reçu de {microwave_id}.")
# Responds
mqtt_client.publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello_ack(DEVICE_ID, microwave_id), qos=config.MQTT_QOS)
print(f"[Main Loop] MQTT : Réponse Hello envoyée à {microwave_id}.")
# TODO : Save in database
# MQTT SENSOR DATA
elif (msg["topic"] == config.MQTT_TOPIC_SENSOR.decode('utf-8')):
print(f"\n[Main Loop] MQTT : Données capteurs reçues du micro-ondes : {msg['data']}")
microwave_id = msg["data"].get("id_microwave")
if not microwave_id:
print("[Main Loop] MQTT : Données capteurs reçues sans ID micro-ondes. Ignoré.")
continue
# Get the already existing cooking data for this microwave
sensors_data = cooking_queue.get(microwave_id)
if sensors_data is None:
print(f"[Main Loop] MQTT : Données capteurs reçues pour {microwave_id} mais aucune donnée de cuisson en cours. Ignoré.")
continue
# Merge the received sensor data into the existing cooking data
sensors_data["ir_initial_temp"] = msg["data"].get("dish_temp")
sensors_data["ir_ambient_temp"] = msg["data"].get("ambient_temp")
start_cooking_for_microwave(microwave_id, sensors_data)
# Remove the cooking data from the queue since it's now being processed
del cooking_queue[microwave_id]
print(f"\n[Main Loop] MQTT : Données traitées : {msg['data']}") if c_time is None or c_power is None or c_temp is None:
except queue.Empty: print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
pass microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
return
# === CHECK FOR DISH INSERTED === print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
# Read the dish height from the ultrasonic sensor. If it's below a certain threshold, we assume a dish has been inserted. microwave_states[microwave_id] = MicrowaveState.COOKING
dish_height = ultrasonicRanger.get_dish_height() mqtt_client.publish(
if dish_height is not None and dish_height > 2.0: # Threshold in cm for detecting a dish config.MQTT_TOPIC_COOKING,
print(f"\n[Main Loop] Dish detected at height: {dish_height} cm. Initiating sensor read...") payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
# Read all sensors and store the data in the cooking queue for this microwave qos=config.MQTT_QOS
read_sensors_for_cooking("2") )
print(f"[Main Loop] Sensor data collected and queued for cooking.")
# DEBUG : Read sensors
# sensor_values = read_sensors()
# if sensor_values:
# sensor_values_print = sensor_values.copy()
# if "camera_image" in sensor_values_print:
# sensor_values_print["camera_image"] = f"<{len(sensor_values_print['camera_image'])} bytes>"
# print(f"\nCapteurs Données lues : {sensor_values_print}")
time.sleep(3)
except KeyboardInterrupt:
break
except Exception as e: except Exception as e:
traceback.print_exc() print(f"[{microwave_id}] Cloud API Error: {e}")
time.sleep(1) # Prevents rapid error logging in case of persistent issues microwave_states[microwave_id] = MicrowaveState.DONE
# Clean termination # --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "new_cooking_state" in data.get("data", {}):
mw_id = data["data"].get("id")
n_state = data["data"].get("new_cooking_state")
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
microwave_states[mw_id] = MicrowaveState.DONE
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
elif source == "MQTT":
topic = msg["topic"]
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
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.")
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
async def get_filtered_dish_height(samples=3, delay=0.04):
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
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:
valid_samples.append(h)
await asyncio.sleep(delay)
if valid_samples:
valid_samples.sort()
return valid_samples[len(valid_samples) // 2] # Median sample
return None # All reads failed or out of range
async def monitor_dish_height_task():
"""Monitors presence of dish with hysteresis and debouncing."""
mw_id = "2"
consecutive_present = 0
consecutive_absent = 0
REQUIRED_STABLE_READS = 3 # Must see 3 stable states in a row (~1 second)
while True:
dist = await get_filtered_dish_height()
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
if dist is not None:
# Hysteresis Thresholds:
# - Must be > 2.5 cm to detect dish insertion
# - Must be < 1.2 cm to detect dish removal
if dist > 2.5:
consecutive_present += 1
consecutive_absent = 0
elif dist < 1.2:
consecutive_absent += 1
consecutive_present = 0
else:
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
consecutive_present = 0
consecutive_absent = 0
# --- DISH INSERTED CONFIRMED ---
if consecutive_present >= REQUIRED_STABLE_READS and current_state == MicrowaveState.IDLE:
consecutive_present = 0
asyncio.create_task(handle_new_dish(mw_id, dist))
# --- DISH REMOVED CONFIRMED ---
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
consecutive_absent = 0
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
microwave_states[mw_id] = MicrowaveState.IDLE
if current_state == MicrowaveState.COOKING:
_stop_hardware(mw_id)
# Remove from IR cache and events
ir_data_cache.pop(mw_id, None)
ir_data_events.pop(mw_id, None)
await asyncio.sleep(0.3)
# --- BOOTSTRAP ---
async def main():
global async_event_queue
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
async_event_queue = asyncio.Queue()
await asyncio.gather(
lora_listener_task(),
mqtt_listener_task(),
process_messages_task(),
monitor_dish_height_task()
)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nArrêt manuel.")
finally:
if hasattr(mqtt_client._client, "loop_stop"): if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop() mqtt_client._client.loop_stop()
mqtt_client.close() mqtt_client.close()
+7 -3
View File
@@ -11,7 +11,8 @@ grovepi.pinMode(button, "INPUT")
button_callback = None button_callback = None
def read_button_state(): def read_button_state():
if not grove_lock.acquire(timeout=0.05): # Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
if not grove_lock.acquire(timeout=0.2):
return None return None
try: try:
return grovepi.digitalRead(button) return grovepi.digitalRead(button)
@@ -26,15 +27,18 @@ def monitor_button():
last_button_state = button_switch_state last_button_state = button_switch_state
while True: while True:
time.sleep(0.04)
current_state = read_button_state() current_state = read_button_state()
if current_state is not None: if current_state is not None:
# Rising edge detection (0 -> 1 transition)
if current_state == 1 and last_button_state == 0: if current_state == 1 and last_button_state == 0:
if button_callback: if button_callback:
button_callback() button_callback()
last_button_state = current_state last_button_state = current_state
time.sleep(0.02) # Fast 20ms poll when lock is clear
else:
# Lock was busy; retry quickly without updating last_button_state
time.sleep(0.01)
def start_button_monitoring_thread(): def start_button_monitoring_thread():
threading.Thread(target=monitor_button, daemon=True).start() threading.Thread(target=monitor_button, daemon=True).start()
+1 -1
View File
@@ -7,7 +7,7 @@ lora.configure()
print("Raspberry Pi : En attente active de JSON...") print("Raspberry Pi : En attente active de JSON...")
while True: while True:
paquet = lora.receive_packet(timeout_ms=5000) paquet = lora.receive_reliable(timeout_ms=5000)
if paquet: if paquet:
# Plus besoin de décoder du HEX ou de parser du JSON manuellement ! # Plus besoin de décoder du HEX ou de parser du JSON manuellement !
groupe = paquet['group'] groupe = paquet['group']
+5
View File
@@ -5,6 +5,11 @@ import shared.deviceTypes as deviceTypes
import shared.config as config import shared.config as config
import shared.payloads as payloads import shared.payloads as payloads
import shared.cookingState as cookingState import shared.cookingState as cookingState
import shared.safeQueue as safeQueue
try:
import shared.lora_device as lora_device
except ImportError:
pass # No need
try: try:
import shared.uart_comm as uart_comm import shared.uart_comm as uart_comm
except ImportError: except ImportError:
+1 -1
View File
@@ -1,7 +1,7 @@
DEBUG=True DEBUG=True
# LoRa # LoRa
HEARTBEAT_INTERVAL = 30 LORA_HEARTBEAT_INTERVAL = 30
# MQTT # MQTT
MQTT_BROKER_HOST = "192.168.50.1" MQTT_BROKER_HOST = "192.168.50.1"
+26 -8
View File
@@ -13,6 +13,7 @@ class CookingState:
self.temperature_provider = temperature_provider self.temperature_provider = temperature_provider
self.on_state_change = on_state_change self.on_state_change = on_state_change
self.on_refresh = on_refresh self.on_refresh = on_refresh
self.on_pause = None
self.state = CookingStates.COOKING self.state = CookingStates.COOKING
self.paused = False self.paused = False
@@ -24,6 +25,7 @@ class CookingState:
self.estimated_remaining_time = float(cook_time) self.estimated_remaining_time = float(cook_time)
self._last_temperature_sample = None self._last_temperature_sample = None
self._last_refresh_signature = None self._last_refresh_signature = None
self._stirred = False
def set_temperature_provider(self, temperature_provider): def set_temperature_provider(self, temperature_provider):
self.temperature_provider = temperature_provider self.temperature_provider = temperature_provider
@@ -34,14 +36,18 @@ class CookingState:
def set_refresh_callback(self, callback): def set_refresh_callback(self, callback):
self.on_refresh = callback self.on_refresh = callback
def set_pause_callback(self, callback):
self.on_pause = callback
def pause(self): def pause(self):
if self.paused: if self.paused:
return return
self.paused = True self.paused = True
self._pause_started_at = time.time() self._pause_started_at = time.time()
self._notify_refresh(force=True) # self._notify_refresh(force=True)
if self.on_pause:
self.on_pause(self)
def unpause(self): def unpause(self):
if not self.paused: if not self.paused:
return return
@@ -50,15 +56,16 @@ class CookingState:
if self._pause_started_at is not None: if self._pause_started_at is not None:
self._paused_duration += now - self._pause_started_at self._paused_duration += now - self._pause_started_at
self._pause_started_at = None # self._pause_started_at = None
self.paused = False self.paused = False
self._notify_refresh(force=True) # self._notify_refresh(force=True)
def toggle_pause(self): def toggle_pause(self):
if self.paused: if self.paused:
self.unpause() self.unpause()
else: else:
self.pause() self.pause()
self.on_pause(self)
def set_state(self, state): def set_state(self, state):
if self.state == state: if self.state == state:
@@ -154,6 +161,8 @@ class CookingState:
self.on_refresh(self) self.on_refresh(self)
def update_tick(self): def update_tick(self):
if self.state == CookingStates.IDLE:
return self.state
if self.paused: if self.paused:
self._notify_refresh() self._notify_refresh()
return self.state return self.state
@@ -170,17 +179,19 @@ class CookingState:
elapsed_time = self.get_elapsed_time() elapsed_time = self.get_elapsed_time()
self.estimated_remaining_time = self.get_remaining_time_estimation() 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 self.current_dish_temp is not None:
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp: 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 self.state = CookingStates.STIRRING_REQUIRED
self.pause() self.pause()
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
self.state = CookingStates.DONE self.state = CookingStates.DONE
elif self.state == CookingStates.DONE and self.current_dish_temp < (self.target_temp - self.TEMPERATURE_TOLERANCE): elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up
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.state = CookingStates.STIRRING_REQUIRED
self.pause() self.pause()
elif self._pause_started_at != None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): # If the dish had to be pause and it's been a long time, we stop the cooking
self.state = CookingStates.DONE
self._last_temperature_sample = (now, self.current_dish_temp) self._last_temperature_sample = (now, self.current_dish_temp)
@@ -197,3 +208,10 @@ class CookingStates:
DONE = 2 DONE = 2
ALERT = 3 # Microwave is too hot internally or other alerts ALERT = 3 # Microwave is too hot internally or other alerts
IDLE = 4 # Waiting for cooking parameters to be set, or after cooking is done IDLE = 4 # Waiting for cooking parameters to be set, or after cooking is done
@staticmethod
def get_state_name(state_val):
for key, value in CookingStates.__dict__.items():
if value == state_val and not key.startswith('__'):
return key
return "UNKNOWN"
+294 -43
View File
@@ -1,5 +1,6 @@
import sys import sys
import time import time
import random
IS_MICROPYTHON = sys.implementation.name == 'micropython' IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
from machine import Pin, SPI from machine import Pin, SPI
import ubinascii import ubinascii
import ujson as json import ujson as json
else:
import threading
import serial
import json
# --- BASE RELIABLE LORA DEVICE ---
class BaseLoraDevice:
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
def __init__(self):
self.processed_msg_ids = set()
self.received_acks = set()
self.pending_rx_queue = []
self.default_group = 2
def _generate_msg_id(self):
return random.getrandbits(16)
def _send_ack(self, ack_id):
"""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)
else:
time.sleep(0.01)
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload)
def _process_incoming_packet(self, packet):
"""Internal packet processor: handles ACKs and deduplication."""
if not packet or packet.get("raw"):
return packet
data = packet.get("data")
if isinstance(data, dict):
# 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}")
if ack_id is not None:
self.received_acks.add(ack_id)
if len(self.received_acks) > 100:
self.received_acks.clear()
return None # Drop internal protocol message from user queue
# 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.")
self._send_ack(msg_id)
if msg_id in self.processed_msg_ids:
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
return None # Discard duplicate retry
self.processed_msg_ids.add(msg_id)
if len(self.processed_msg_ids) > 100:
self.processed_msg_ids.clear()
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."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
print(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}")
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()
packet = self.receive_packet(timeout_ms=500)
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)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
finally:
if lock: lock.release()
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 ===")
return True
finally:
if lock: lock.release()
print(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
def receive_reliable(self, timeout_ms=1000):
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
if len(self.pending_rx_queue) > 0:
return self.pending_rx_queue.pop(0)
start_time = time.time()
timeout_s = timeout_ms / 1000.0
while True:
elapsed = time.time() - start_time
remaining_ms = int((timeout_s - elapsed) * 1000)
if remaining_ms <= 0:
break
poll_time = max(50, min(remaining_ms, 300))
packet = self.receive_packet(timeout_ms=poll_time)
if packet:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
return filtered_packet
return None
if IS_MICROPYTHON:
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) --- # --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
class LoraHardwareSPI: class LoraHardwareSPI(BaseLoraDevice):
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13): def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
super().__init__()
self._pins = {
"spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
"cs": cs, "irq": irq, "rst": rst, "gpio": gpio
}
self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
self.lock = _thread.allocate_lock()
self.lora = None
self.reset_hardware()
def reset_hardware(self):
"""Resets SX1262 hardware and recreates driver instance."""
with self.lock:
try:
irq_pin = Pin(self._pins["irq"], Pin.IN)
irq_pin.irq(handler=None)
except Exception:
pass
try:
rst_pin = Pin(self._pins["rst"], Pin.OUT)
rst_pin.value(0)
time.sleep_ms(30)
rst_pin.value(1)
time.sleep_ms(50)
except Exception:
pass
self.lora = None
time.sleep_ms(50)
try:
from sx1262 import SX1262 from sx1262 import SX1262
self.lora = SX1262( new_instance = SX1262(**self._pins)
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso, new_instance.begin(
cs=cs, irq=irq, rst=rst, gpio=gpio freq=self._cfg["freq"], bw=self._cfg["bw"], sf=self._cfg["sf"],
cr=self._cfg["cr"], power=self._cfg["power"],
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
) )
self.default_group = 2 # On définit le groupe par défaut ici # SyncWord 0x12 = Decimal 18
self.lock = _thread.allocate_lock() # Création du verrou new_instance.setSyncWord(0x12)
self.lora = new_instance
except Exception as e:
print(f"[LoRa SPI] Initialization error: {e}")
def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14): def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14):
self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
if self.lora is None:
self.reset_hardware()
else:
with self.lock:
try:
self.lora.begin( self.lora.begin(
freq=freq, bw=bw, sf=sf, cr=cr, power=power, freq=freq, bw=bw, sf=sf, cr=cr, power=power,
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
) )
self.lora.setSyncWord(0x14) self.lora.setSyncWord(0x12)
except Exception:
self.reset_hardware()
def send(self, payload, group=None): def send(self, payload, group=None):
"""Encode la payload en JSON si nécessaire, et injecte automatiquement l'octet de groupe.""" """Encodes payload into JSON and prepends group byte."""
with self.lock: with self.lock:
if self.lora is None:
return
if group is None: if group is None:
group = self.default_group group = self.default_group
# Si c'est un dictionnaire ou une liste, on le convertit en JSON textuel
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
# Insertion automatique de l'octet de groupe au tout début de la trame physique
paquet_physique = bytes([group]) + payload paquet_physique = bytes([group]) + payload
try:
self.lora.send(paquet_physique) self.lora.send(paquet_physique)
except Exception as e:
print(f"[LoRa SPI] Send error: {e}")
def receive_packet(self, timeout_ms=1000): def receive_packet(self, timeout_ms=500):
"""Écoute, nettoie, extrait le groupe, gère le HEX et parse le JSON.""" """Listens on SPI bus with auto-detection for JSON vs. Grouped headers."""
with self.lock: with self.lock:
if self.lora is None:
return None
try:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms) data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
if state == 0 and len(data) > 1: except Exception as e:
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] group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t') payload_brute = data[1:].strip(b'\x00 \r\n\t')
else:
return None
try: try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t') text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
return None return None
else: else:
import threading
import serial
import json
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) --- # --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
class LoraSerialAT: class LoraSerialAT(BaseLoraDevice):
def __init__(self, port): def __init__(self, port):
super().__init__()
self.port = port self.port = port
self.ser = serial.Serial( self.ser = serial.Serial(
port=self.port, port=self.port,
@@ -95,37 +297,60 @@ else:
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
self.ser.reset_output_buffer() self.ser.reset_output_buffer()
self.lock = threading.Lock() self.lock = threading.Lock()
def configure(self, **kwargs):
pass
def send(self, payload): # Initial configuration
"""Encode automatiquement la payload en HEX pour l'envoi via la clé.""" 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."""
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')
return resp
def configure(self, freq=868.1, sf=7, bw=125):
"""Configures LA66 frequency, SF, BW, SyncWord, CRC, and continuous RX mode."""
with self.lock: with self.lock:
freq_hz = int(freq * 1000000)
bw_code = 0 if bw == 125 else 1
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
# Fallback standalone commands
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
self.ser.reset_input_buffer()
def send(self, payload, group=None):
"""Encodes payload into HEX AT command and re-enables continuous RX."""
with self.lock:
if group is None:
group = self.default_group
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
hex_payload = payload.hex() paquet_physique = bytes([group]) + payload
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n" cmd = f"AT+PSEND={hex_payload}"
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3") resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
self.ser.write(cmd.encode('utf-8')) print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
time.sleep(0.2) # Re-enable continuous receive mode after transmission completes
response = "" self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
start_wait = time.time()
while (time.time() - start_wait) < 1.5:
if self.ser.in_waiting > 0:
response += self.ser.readline().decode('utf-8', errors='ignore')
time.sleep(0.05)
# print(f"[RPI LA66 TX STATUS] :\n{response.strip()}") def receive_packet(self, timeout_ms=500):
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
def receive_packet(self, timeout_ms=5000):
with self.lock: with self.lock:
start_time = time.time() start_time = time.time()
timeout_s = timeout_ms / 1000.0 timeout_s = timeout_ms / 1000.0
@@ -136,18 +361,38 @@ else:
if line: if line:
payload_bytes = None payload_bytes = None
if "(HEX:)" in line: # Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line:
parts = line.split("+RECV:")[1].strip().split(",")
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
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(" ", "") hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
try: try: payload_bytes = bytes.fromhex(hex_part)
payload_bytes = bytes.fromhex(hex_part) except ValueError: pass
except ValueError:
pass
elif "Data:" in line: elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8') payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
if payload_bytes and len(payload_bytes) > 1: 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] group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t') payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
else:
continue
try: try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t') text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
@@ -181,3 +426,9 @@ def get_lora_device(port_or_pins=None):
else: else:
port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
return LoraSerialAT(port) return LoraSerialAT(port)
class LoraCommands:
PING = "ping"
COOKING_STATE_UPDATE = "cooking_state_update"
TOGGLE_PAUSE = "toggle_pause"
+93 -21
View File
@@ -11,13 +11,10 @@ try:
except ImportError: except ImportError:
try: try:
from umqtt.simple import MQTTClient as _MQTTClient from umqtt.simple import MQTTClient as _MQTTClient
import _thread
import gc
BACKEND_NAME = "umqtt.simple" BACKEND_NAME = "umqtt.simple"
IS_MICROPYTHON = True IS_MICROPYTHON = True
# except ImportError:
# try:
# from umqtt.robust import MQTTClient as _MQTTClient
# BACKEND_NAME = "umqtt.robust"
# IS_MICROPYTHON = True
except ImportError as exc: except ImportError as exc:
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
@@ -79,6 +76,11 @@ class BrokerClient:
self._client = None self._client = None
self._callback = None self._callback = None
self._messages = [] self._messages = []
self._cadata = None # Cache cert bytes to prevent heap fragmentation
# Thread safety lock for MicroPython socket reads/writes
if IS_MICROPYTHON:
self._lock = _thread.allocate_lock()
def set_callback(self, callback): def set_callback(self, callback):
self._callback = callback self._callback = callback
@@ -104,17 +106,25 @@ class BrokerClient:
return self._client return self._client
if IS_MICROPYTHON: if IS_MICROPYTHON:
gc.collect() # Clean Python heap before importing/allocating SSL
import ssl import ssl
ssl_params = self.ssl_params ssl_params = self.ssl_params
if self.use_tls and ssl_params is None: if self.use_tls and ssl_params is None:
# MicroPython uses context-less structures. # OPTION A: If broker uses 'require_certificate false' and self-signed certs:
# If your CA is self-signed, validation can fail without a valid hostname match. # Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
ssl_params = { ssl_params = {
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit "cert_reqs": ssl.CERT_NONE,
"cadata": _read_file_bytes(self.cafile) "server_hostname": self.host
} }
# OPTION B: If strict CA validation IS required, load cadata ONLY with CERT_REQUIRED:
# ssl_params = {
# "cert_reqs": ssl.CERT_REQUIRED,
# "cadata": _read_file_bytes(self.cafile),
# "server_hostname": self.host
# }
client = _MQTTClient( client = _MQTTClient(
self.client_id or "smartWave-client", self.client_id or "smartWave-client",
self.host, self.host,
@@ -150,18 +160,34 @@ class BrokerClient:
return self._client return self._client
def connect(self): def connect(self):
client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
gc.collect() # Force C & Python memory cleanup right before TLS handshake
if self._client is not None:
self.close()
client = self.open()
try:
if IS_MICROPYTHON:
gc.collect() # Sweep memory right before umqtt calls ssl.wrap_socket()
with self._lock:
client.connect() client.connect()
return client return client
client.connect(self.host, self.port, self.keepalive) client.connect(self.host, self.port, self.keepalive)
return client return client
except Exception as e:
print("MQTT connection failed, closing client and releasing memory.")
print("Exception:", e)
self.close()
raise
def publish(self, topic, payload, qos=2, retain=False): def publish(self, topic, payload, qos=2, retain=False):
client = self.open() client = self.open()
payload_bytes = _ensure_bytes(payload) payload_bytes = _ensure_bytes(payload)
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
return client.publish(topic, payload_bytes, retain=retain, qos=qos) return client.publish(topic, payload_bytes, retain=retain, qos=qos)
if isinstance(topic, bytes): if isinstance(topic, bytes):
@@ -172,6 +198,7 @@ class BrokerClient:
def subscribe(self, topic, qos=2): def subscribe(self, topic, qos=2):
client = self.open() client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
client.set_callback(self._on_micropython_message) client.set_callback(self._on_micropython_message)
return client.subscribe(topic, qos=qos) return client.subscribe(topic, qos=qos)
@@ -184,27 +211,43 @@ class BrokerClient:
client = self.open() client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
import struct import struct
# Ensure the topic is bytes for writing to the socket import time
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8') topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
# 1. Build the MQTT unsubscribe packet header # 1. Increment and lock the PID for THIS specific request
client.pid = (client.pid % 65535) + 1
sent_pid = client.pid # <-- Store local copy
# 2. Construct UNSUBSCRIBE packet
rem_len = 2 + 2 + len(topic_bytes)
pkt = bytearray(b"\xa2\0\0\0") pkt = bytearray(b"\xa2\0\0\0")
client.pid += 1 struct.pack_into("!BH", pkt, 1, rem_len, sent_pid)
# Packet length is: 2 bytes (PID) + 2 bytes (topic length indicator) + topic string length # 3. Write packet to socket
struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid)
# 2. Write the packet to the socket
client.sock.write(pkt) client.sock.write(pkt)
client._send_str(topic_bytes) client._send_str(topic_bytes)
# 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker # 4. Wait for UNSUBACK (0xB0)
while True: start = time.time()
while time.time() - start < 3:
op = client.wait_msg() op = client.wait_msg()
if op == 0xB0: if op == 0xB0:
resp = client.sock.read(3) resp = bytearray(3)
assert resp[1] == pkt[2] and resp[2] == pkt[3] read_bytes = 0
while read_bytes < 3:
chunk = client.sock.read(3 - read_bytes)
if chunk:
resp[read_bytes:read_bytes + len(chunk)] = chunk
read_bytes += len(chunk)
else:
time.sleep_ms(10)
# Compare against sent_pid instead of client.pid
resp_pid = (resp[1] << 8) | resp[2]
if resp_pid != sent_pid:
print(f"[MQTT] UNSUBACK PID mismatch (expected {sent_pid}, got {resp_pid})")
return client return client
return client return client
if isinstance(topic, bytes): if isinstance(topic, bytes):
@@ -219,6 +262,7 @@ class BrokerClient:
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
return self._client.check_msg() return self._client.check_msg()
return self._client.loop(timeout=timeout) return self._client.loop(timeout=timeout)
@@ -226,6 +270,7 @@ class BrokerClient:
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
return self._client.wait_msg() return self._client.wait_msg()
return self._client.loop_forever() return self._client.loop_forever()
@@ -235,14 +280,41 @@ class BrokerClient:
return self._messages.pop(0) return self._messages.pop(0)
def close(self): def close(self):
"""Safely clean up socket context without causing ESP32 C panics."""
if self._client is None: if self._client is None:
return return
if IS_MICROPYTHON:
with self._lock:
try:
if hasattr(self._client, "sock") and self._client.sock:
self._client.sock.close()
except Exception:
pass
finally:
if hasattr(self._client, "sock"):
self._client.sock = None
self._client = None
gc.collect() # Immediately reclaim freed socket & mbedTLS RAM
else:
try: try:
self._client.disconnect() self._client.disconnect()
except Exception: except Exception:
pass pass
finally:
self._client = None self._client = None
def ping(self):
"""Thread-safe PINGREQ wrapper for MicroPython."""
if self._client is None:
return
if IS_MICROPYTHON:
with self._lock:
return self._client.ping()
else:
# Paho handles keepalives automatically via loop_start/loop
pass
def __enter__(self): def __enter__(self):
self.connect() self.connect()
return self return self
+37
View File
@@ -0,0 +1,37 @@
import _thread
class SafeQueue:
"""A lightweight, thread-safe FIFO queue for MicroPython."""
def __init__(self, maxsize=20):
self._queue = []
self._lock = _thread.allocate_lock()
self.maxsize = maxsize
def put(self, item) -> bool:
"""Push an item to the end of the queue. Returns False if queue is full."""
with self._lock:
if len(self._queue) < self.maxsize:
self._queue.append(item)
return True
else:
print("[Queue Warning] Buffer full, dropping oldest message.")
self._queue.pop(0) # Drop oldest to make room
self._queue.append(item)
return False
def get(self):
"""Pop and return the oldest item from the queue, or None if empty."""
with self._lock:
if self._queue:
return self._queue.pop(0)
return None
def empty(self) -> bool:
"""Check if the queue has no items."""
with self._lock:
return len(self._queue) == 0
def size(self) -> int:
"""Return current number of queued items."""
with self._lock:
return len(self._queue)
+18 -81
View File
@@ -5,98 +5,42 @@ import ujson
class SafeUART: class SafeUART:
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200): def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
# Initialize the hardware UART channel # Setting timeout allows readline() to be non-blocking
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024) 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() self.lock = _thread.allocate_lock()
self.rx_queue = [] self.rx_queue = []
self.buffer = b""
# Start the background data worker thread _thread.stack_size(4096)
_thread.stack_size(4096) # Cap the stack size for the UART listener
_thread.start_new_thread(self._listener_worker, ()) _thread.start_new_thread(self._listener_worker, ())
_thread.stack_size(0) _thread.stack_size(0)
print(f"[UART] Thread initialized on UART{uart_id} (TX:{tx_pin}, RX:{rx_pin})")
def _listener_worker(self): def _listener_worker(self):
"""Worker loop that handles nested JSON structures by tracking brace depth.""" """Simple worker that relies on newline framing instead of manual JSON parsing."""
while True: while True:
messages_found = []
with self.lock:
if self.uart.any(): if self.uart.any():
chunk = self.uart.read()
if chunk is not None and isinstance(chunk, bytes):
self.buffer += chunk
# 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: with self.lock:
self.rx_queue.append(decoded_str) line = self.uart.readline()
except Exception as e:
print(f"[UART Parse Error]: {e}") if line:
try:
decoded = line.decode('utf-8').strip()
if decoded: # Ignore empty lines
with self.lock:
self.rx_queue.append(decoded)
except UnicodeError:
pass # Drop corrupted bytes cleanly
time.sleep_ms(10) time.sleep_ms(10)
def send(self, message): def send(self, message):
"""Safely pushes strings across the serial wire from any thread context."""
if not message.endswith('\n'): if not message.endswith('\n'):
message += '\n' message += '\n'
data = message.encode('utf-8')
with self.lock: with self.lock:
self.uart.write(data) self.uart.write(message.encode('utf-8'))
def read(self):
with self.lock:
return self.rx_queue.pop(0) if self.rx_queue else None
def send_as_command(self, command: 'UARTCommand'): def send_as_command(self, command: 'UARTCommand'):
"""Safely sends a structured command over UART.""" """Safely sends a structured command over UART."""
@@ -108,13 +52,6 @@ class SafeUART:
with self.lock: with self.lock:
return len(self.rx_queue) > 0 return len(self.rx_queue) > 0
def read(self):
"""Pulls the oldest unread string from the queue. Returns None if empty."""
with self.lock:
if self.rx_queue:
return self.rx_queue.pop(0)
return None
def read_as_command(self) -> 'UARTCommand | 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.""" """Attempts to read the oldest unread string and parse it as a UARTCommand. Returns None if empty or invalid."""
raw_message = self.read() raw_message = self.read()
+3 -3
View File
@@ -4,7 +4,7 @@ Edit BROKER_HOST so it points to the broker machine IP address.
Do not use localhost from the ESP32. Do not use localhost from the ESP32.
""" """
from shared.mqtt import BrokerClient import shared
BROKER_HOST = "192.168.50.1" BROKER_HOST = "192.168.50.1"
@@ -17,7 +17,7 @@ def on_message(message):
def main(): def main():
client = BrokerClient( client = shared.get_mqtt_client(
host=BROKER_HOST, host=BROKER_HOST,
client_id="smartwave-esp32-demo", client_id="smartwave-esp32-demo",
use_tls=True, use_tls=True,
@@ -28,7 +28,7 @@ def main():
client.set_callback(on_message) client.set_callback(on_message)
client.connect() client.connect()
client.subscribe(TOPIC, qos=2) client.subscribe(TOPIC, qos=2)
client.publish(TOPIC, b"hello from MicroPython", qos=2, retain=False) client.publish(TOPIC, b"hello from MicroPython", qos=1, retain=False)
for _ in range(30): for _ in range(30):
client.poll() client.poll()