Compare commits

..

19 Commits

Author SHA1 Message Date
Ninluc 25e635e207 MQTT hello is saved in database
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-06 16:55:24 +02:00
Ninluc fb41a2d07a Database logging and error handling 2026-08-06 16:55:03 +02:00
Ninluc fb6ee0099a Removed debug log
Build, push image, and notify Watchtower / build-image (push) Successful in 59s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-06 15:44:10 +02:00
Ninluc 038171d68d ESP-LORA refactor 2026-08-06 15:44:03 +02:00
Ninluc 5381631876 Reduced logging in non debug
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-06 15:20:12 +02:00
Ninluc 2df05fd684 Fix reset dish state and debug values 2026-08-06 15:19:54 +02:00
Ninluc 11357b9a0e DEBUG led somtimes don't update 2026-08-06 15:19:30 +02:00
Ninluc e2beef511c Send status updae after doing changes 2026-08-06 15:19:14 +02:00
Ninluc e748d53c94 Fix changing cooking state 2026-08-06 14:56:35 +02:00
Ninluc 6fa74c6b93 Retries with cloud
Build, push image, and notify Watchtower / build-image (push) Successful in 2m27s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
Still need to to be able to reset the cooking_state so hat the user can reinsert a dish
2026-08-05 21:34:15 +02:00
Ninluc af5ea45270 LoRa Fix 2026-08-05 21:33:41 +02:00
Ninluc 1626b392b3 Fix mqtt 2026-08-05 20:48:39 +02:00
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
Ninluc 7299a50198 Beginning of cooking cycle
Build, push image, and notify Watchtower / build-image (push) Successful in 1m21s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-07-31 21:50:42 +02:00
Ninluc 8ac5db22c1 Moved rgb led to shared 2026-07-31 16:24:41 +02:00
Ninluc b12296bf0e Added RGB led control class 2026-07-30 17:39:29 +02:00
20 changed files with 2011 additions and 780 deletions
+229 -50
View File
@@ -1,69 +1,248 @@
import _thread import gc
from machine import Pin import sys
from shared import get_lora, get_uart, deviceTypes, config
import time import time
import _thread
import uasyncio as asyncio
from machine import Pin, SoftI2C
import framebuf
import ssd1306
# --- Configuration Matérielle --- # Clean memory immediately
vext = Pin(19, Pin.OUT) gc.collect()
vext.value(0)
time.sleep_ms(100)
# --- Lecture de l'ID unique de l'ESP --- from shared.safeQueue import SafeQueue
from shared import get_lora, get_uart, deviceTypes, config, cookingState
from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED
from shared.logging import log
from shared.lora_device import LoraCommands
# --- READ DEVICE ID ---
try: try:
with open("device_id.txt", "r") as f: with open("device_id.txt", "r") as f:
DEVICE_ID = f.read().strip() DEVICE_ID = f.read().strip()
except Exception: except Exception:
DEVICE_ID = "ESP32_Inconnu" DEVICE_ID = "ESP32_Inconnu"
# --- Initialisation LoRa --- # --- GLOBAL VARIABLES ---
lora = get_lora() cooking_state = None
lora.configure(freq=868.1, sf=7) data_queue = SafeQueue()
lora = None
uart_device = None
magnetron_led = None
display = None
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})") PING_PAYLOAD = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
def heartbeat_loop(): def init_hardware():
"""Initializes all hardware components."""
global lora, uart_device, magnetron_led, display
print("[Main] Initializing hardware peripherals...")
# Power up VEXT (for LoRa/Display)
vext = Pin(19, Pin.OUT)
vext.value(0)
time.sleep_ms(100)
# Init LoRa
lora = get_lora()
lora.configure(freq=868.1, sf=7)
# Init RGB LEDs
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off()
# Init OLED Display
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
display.text("Booting...", 1, 2, 1)
display.show()
# Init UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
# --- DEDICATED LORA HARDWARE THREAD ---
def lora_hardware_thread():
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
last_heartbeat_time = 0
while True: while True:
print(f"\nESP32 : Envoi du Heartbeat...") now = time.time()
# Envoi périodique
ping_payload = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
lora.send(ping_payload)
# Le receive_packet est maintenant protégé par le lock dans lora_device # 1. Send periodic heartbeat
# Si le main thread utilise la radio, ce thread attendra son tour if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
paquet = lora.receive_packet(timeout_ms=2000) last_heartbeat_time = now
print("\n[LoRa Thread] Sending Heartbeat...")
if lora:
lora.send(PING_PAYLOAD)
if paquet and not paquet["raw"]: # 2. Blocking 300ms RX listen window (keeps radio actively listening)
donnees = paquet["data"] if lora:
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur paquet = lora.receive_reliable(timeout_ms=300)
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]: if paquet is not None:
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]") log(f"[LoRa Thread] New Packet Received: {paquet}")
else: data_queue.put(paquet)
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) time.sleep_ms(10)
# UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
# Lancer la boucle de heartbeat dans un thread séparé
_thread.start_new_thread(heartbeat_loop, ())
# --- MAIN APPLICATION THREAD --- # --- COOKING STATE CALLBACKS ---
print("[Main] Main execution path active.") def cooking_state_temperature_provider():
while True: return 22.0, 29.0 # TODO: Replace with real temperature reading
# 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}")
# 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 def cooking_state_on_state_change(state):
# uart_device.send("Data Pack: LoRa Link RSSI -72dBm") print(f"[CookingState] State changed to: {state.state}")
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
magnetron_led.off()
else:
magnetron_led.on()
# Send state updates to WiFi board and Orchestrator
if uart_device:
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
if lora:
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
# Update OLED display
if display:
display.fill(0)
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
display.show()
time.sleep_ms(200) def cooking_state_on_refresh(state):
# TODO: Show screen information
pass
def cooking_state_on_pause(state):
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
state.set_state(cookingState.CookingStates.COOKING)
# TODO: send_reliable lora message to orchestrator about pause/resume state
# --- ASYNC TASKS ---
async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board."""
global cooking_state
while True:
if uart_device and uart_device.any():
command = uart_device.read_as_command()
if command:
print(f"[UART Task] Received command from WiFi Board: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_PARAMS:
params = command.payload
print(f"[UART Task] Cooking parameters received: {params}")
cooking_state = cookingState.CookingState(
cook_time=params["cook_time"],
power_level=params["power_level"],
target_temp=params["target_temp"]
)
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
cooking_state.set_state_change_callback(cooking_state_on_state_change)
cooking_state.set_refresh_callback(cooking_state_on_refresh)
cooking_state.set_pause_callback(cooking_state_on_pause)
await asyncio.sleep_ms(20)
cooking_state_on_state_change(cooking_state)
else:
print(f"[UART Task] Unknown command type received: {command.command_type}")
await asyncio.sleep_ms(50)
async def lora_process_task():
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
global cooking_state
while True:
while not data_queue.empty():
paquet = data_queue.get()
if paquet and not paquet.get("raw"):
data = paquet.get("data", {})
if "action" in data:
if data["action"] == LoraCommands.TOGGLE_PAUSE:
if cooking_state is not None:
if cooking_state.state == cookingState.CookingStates.DONE:
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.")
cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20)
cooking_state = None
else:
cooking_state.toggle_pause()
if cooking_state.paused:
print("[LoRa Process] Cooking paused via orchestrator command.")
else:
print("[LoRa Process] Cooking resumed via orchestrator command.")
else:
log("[LoRa Process] No active cooking state to toggle pause/resume.")
await asyncio.sleep_ms(50)
async def cooking_loop_task():
"""Ticks the cooking state and logs information periodically."""
while True:
if cooking_state is not None:
cooking_state.update_tick()
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s, "
f"Est. Remaining: {cooking_state.get_remaining_time_estimation():.2f}s")
await asyncio.sleep_ms(500)
async def memory_cleanup_task():
"""Periodically cleans up memory to prevent heap fragmentation."""
while True:
gc.collect()
await asyncio.sleep(10)
# --- BOOTSTRAP ---
async def main():
print("[Main] Starting application...")
init_hardware()
# Launch dedicated hardware thread for LoRa RX
try:
_thread.stack_size(16 * 1024)
except Exception:
pass
_thread.start_new_thread(lora_hardware_thread, ())
print("[Main] LoRa hardware background thread started.")
# Launch background async tasks
asyncio.create_task(uart_polling_task())
asyncio.create_task(lora_process_task())
asyncio.create_task(cooking_loop_task())
asyncio.create_task(memory_cleanup_task())
print("[Main] All async tasks running concurrently!")
# Keep main task alive indefinitely
while True:
await asyncio.sleep(3600)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("[Main] Program stopped by user.")
except Exception as e:
sys.print_exception(e)
+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']}")
+49 -10
View File
@@ -1,19 +1,58 @@
# This file is executed on every boot (including wake-boot from deepsleep) # This file is executed on every boot (including wake-boot from deepsleep)
import esp import esp
from machine import Pin
esp.osdebug(True) esp.osdebug(True)
#import webrepl #import webrepl
#webrepl.start() #webrepl.start()
def do_connect(ssid, pwd): # def do_connect(ssid, pwd):
import network # import network
sta_if = network.WLAN(network.STA_IF) # sta_if = network.WLAN(network.STA_IF)
if not sta_if.isconnected(): # sta_if.config(pm=sta_if.PM_NONE)
print('connecting to network...') # if not sta_if.isconnected():
sta_if.active(True) # print('connecting to network...')
sta_if.connect(ssid, pwd) # sta_if.active(True)
while not sta_if.isconnected(): # sta_if.connect(ssid, pwd)
pass # while not sta_if.isconnected():
print('network config:', sta_if.ifconfig()) # pass
# print('network config:', sta_if.ifconfig())
import network
import time
def do_connect(ssid, password):
wlan = network.WLAN(network.STA_IF)
# 1. ALWAYS activate the interface FIRST
if not wlan.active():
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')
# Set PIN 27 as GND for the temperature sensor (MLX90614)
sensor_gnd = Pin(27, Pin.OUT)
sensor_gnd.value(0)
+302 -154
View File
@@ -1,20 +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
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:
@@ -22,172 +14,328 @@ try:
DEVICE_ID = f.read().strip() DEVICE_ID = f.read().strip()
except Exception: except Exception:
DEVICE_ID = "ESP32_Inconnu" DEVICE_ID = "ESP32_Inconnu"
# --- GLOBAL APP STATE ---
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 = None # Will be initialized in connect_mqtt_async()
host=config.MQTT_BROKER_HOST,
client_id="smartwave-esp32-" + DEVICE_ID, # --- HARDWARE & MODULE DEFERRED IMPORTS ---
use_tls=config.USE_TLS, status_led = None
cafile=MQTT_CA_FILE, uart_device = None
keepalive=config.MQTT_KEEPALIVE, 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_cooking_state_change(state):
"""Callback executed whenever local cooking state transitions."""
BLINK_INTERVAL_MS = 500
log(f"[CookingState] State changed to: {state.state}")
if status_led and cookingState:
if state.state == cookingState.CookingStates.IDLE:
log(f"[CookingState] State changed to IDLE. Turning LED off and stopping blink.")
status_led.color = status_led.OFF
status_led.blink_off()
elif state.state == cookingState.CookingStates.COOKING:
log(f"[CookingState] State changed to COOKING. Turning LED yellow and stopping blink.")
status_led.color = status_led.YELLOW
status_led.blink_off()
elif state.state == cookingState.CookingStates.STIRRING_REQUIRED:
log(f"[CookingState] State changed to STIRRING_REQUIRED. Turning LED orange and starting blink.")
status_led.color = status_led.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS)
elif state.state == cookingState.CookingStates.ALERT:
log(f"[CookingState] State changed to ALERT. Turning LED red and starting blink.")
status_led.color = status_led.RED
status_led.blink_on(BLINK_INTERVAL_MS)
elif state.state == cookingState.CookingStates.DONE:
log(f"[CookingState] State changed to DONE. Turning LED green and stopping blink.")
status_led.color = status_led.GREEN
status_led.blink_off()
global orchestrator_id
orchestrator_id = None
def on_mqtt_message(message): def on_mqtt_message(message):
print("[MQTT Thread] Received message:", message) """Sync callback: Lightweight! Only updates variables or triggers async signals."""
global orchestrator_id, cooking_state, should_unsubscribe_hello
# Try and parse the payload as json, but if it fails, just print the raw payload print("[MQTT] Received message on topic:", message.get("topic"))
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 topic = message.get("topic")
if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID: # 1. Orchestrator Hello Response
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"]) if (
global orchestrator_id topic == config.MQTT_TOPIC_HELLO
orchestrator_id = payload_data["id_orchestrator"] and payload_data
# Unsubscribe from the hello topic since we got a response and payload_data.get("id_microwave") == DEVICE_ID
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO) ):
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO) orchestrator_id = payload_data.get("id_orchestrator")
print("[MQTT] Hello response received from orchestrator:", orchestrator_id)
# Handle cooking messages should_unsubscribe_hello = True
elif message['topic'] == config.MQTT_TOPIC_COOKING and payload_data and payload_data["id_microwave"] == DEVICE_ID:
# Cooking sensors init request # 2. Cooking Parameters / Sensor Request
if not "cook_time_seconds" in payload_data: elif (
print("[MQTT Thread] Cooking sensors init received from the orchestrator") topic == config.MQTT_TOPIC_COOKING
obj_temp = temperature_sensor.read_object_temp() and payload_data
amb_temp = temperature_sensor.read_ambient_temp() and payload_data.get("id_microwave") == DEVICE_ID
queue_publish(config.MQTT_TOPIC_SENSOR, payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)) ):
# Received cooking parameters from the orchestrator 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)
# Here you would handle the cooking parameters, e.g., start a cooking process if cookingState:
# For now, we just print them cooking_state = cookingState.CookingState(
cook_time=payload_data["cook_time"],
print("[MQTT Thread] Message processing complete.") 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)
mqtt_client.set_callback(on_mqtt_message) 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.")
def mqtt_background_thread(): # --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
"""Background MQTT worker handling ALL socket operations safely.""" async def sensor_publisher_task():
print("[Thread] Background MQTT worker started.") """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."""
global cooking_state
while True:
if uart_device:
try:
cmd = uart_device.read_as_command()
if cmd:
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
if (
hasattr(cmd, "command_type")
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
):
if cooking_state:
cooking_state.set_state(cmd.payload.get("state"))
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: while True:
try: try:
print("[Thread] Attempting connection to MQTT broker...") 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.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)
# Force heap cleanup before sleeping
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)
async def mqtt_poll_task():
global mqtt_connected
last_ping = time.time()
# --- 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: while True:
try: if mqtt_connected:
# 1. Check for incoming messages from the Heltec board try:
while uart_device.any(): mqtt_client.poll()
incoming_msg = uart_device.read() now = time.time()
print(f"[Thread] Received from esp-lora over UART: {incoming_msg}") if now - last_ping >= 15:
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()
await asyncio.sleep_ms(30)
async def orchestrator_hello_task():
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
# 2. Example: Send data to the Heltec board every 5 seconds if mqtt_connected:
# uart_device.send("Status Check: WiFi Active") print("[Hello Task] Sending initial hello to orchestrator...")
try:
time.sleep(5) # Fast responsive polling loop for local UART if mqtt_client is None:
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
except Exception as e: await connect_mqtt_async()
print("[Thread] UART error encountered:", e)
time.sleep(5) 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
# --- Launch background worker --- await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
_thread.start_new_thread(mqtt_background_thread, ())
_thread.start_new_thread(uart_background_thread, ())
# --- MAIN APPLICATION THREAD (Core 0) --- async def memory_cleanup_task():
print("[Main] Main execution path active.") while True:
time.sleep(2) # Give the thread a moment to initial connect gc.collect()
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL await asyncio.sleep(10)
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)
# 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.")
temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
while True: # --- MAIN ENTRY POINT ---
# MQTT HELLO sent every x seconds until we get a response from the orchestrator async def main():
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL): global sensor_request_event
print("[Main] Attempting to send initial hello to orchestrator...") print("[Main] Starting application...")
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID))
mqtt_hello_sent_timestamp = time.time() # Initialize loop-bound events
pass sensor_request_event = asyncio.Event()
# 2. Example: Send data to the Heltec board every 5 seconds await connect_mqtt_async()
# uart_device.send("Status Check: WiFi Active") init_hardware()
time.sleep(1)
# 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.")
+377 -344
View File
@@ -1,396 +1,429 @@
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, db
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
# --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite"
def init_db():
"""Ensures the connected_components table exists on startup."""
sql = """
CREATE TABLE IF NOT EXISTS connected_components (
id TEXT PRIMARY KEY,
type TEXT,
timestamp INTEGER
);
"""
db.execute(DB_PATH, sql)
print(f"[DB] Initialized database table at {DB_PATH}")
def save_connected_component(component_id: str, component_type: str):
"""Upserts component information into the database (blocking sync worker)."""
current_time = int(time.time())
sql = """
INSERT INTO connected_components (id, type, timestamp)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
timestamp = excluded.timestamp;
"""
db.execute(DB_PATH, sql, (str(component_id), str(component_type), current_time))
print(f"[DB] Component saved/updated -> ID: {component_id}, Type: {component_type}, Timestamp: {current_time}")
# --- Read Unique Device ID --- # --- Read Unique Device ID ---
try: def get_device_id():
with open("device_id.txt", "r") as f: for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
DEVICE_ID = f.read().strip() try:
except Exception: with open(path, "r") as f:
try: return f.read().strip()
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f: except Exception:
DEVICE_ID = f.read().strip() pass
except Exception: return "RPI_Orchestrateur_Default"
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
cloud_alert = False # Global status flag for screen / UI display
async_event_queue = None
# Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event()
# --- 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",
keepalive=config.MQTT_KEEPALIVE, keepalive=config.MQTT_KEEPALIVE,
) )
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):
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog. topic = topic.decode('utf-8')
time.sleep(0.2)
await async_event_queue.put({
"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
button_state = not button_state if microwave_states.get("2") == MicrowaveState.COOKING:
print(f"\n[Thread Button] Button state changed to: {button_state}") print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
button.set_callback(button_callback) else:
button_state = not button_state
print(f"[Button] Defrost state toggled to: {button_state}")
# Launch background monitoring workers button.set_callback(button_callback)
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()
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
"""
log(f"[{microwave_id}] 🛑 Emergency stop issued to hardware.")
# TODO: Add physical hardware stop command here
# e.g., gpio_controller.stop()
# --- ASYNC COOKING LOGIC ---
def _send_params_to_microwave(microwave_id: str, cook_time_seconds: int, power_level_pct: int, target_temp: float, cancel_event: threading.Event): def read_local_sensors(microwave_id, initial_dish_height):
""" """Blocking function to read local I2C/SPI sensors. Runs in a thread."""
Triggers physical microwave execution. print(f"[{microwave_id}] Reading local physical sensors...")
""" sensor_data = {
if cancel_event.is_set(): "microwave_id": microwave_id,
return "defrost_mode": button_state,
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
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)
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"
# Temp / Hum (handles DHT error safely)
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
if (response.status_code != 200):
print(f"[{microwave_id}] Cloud API returned error {response.status_code}: {response.json()}")
return
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:
try:
# === TREAT MESSAGE QUEUE ===
try:
msg = data_queue.get(block=False)
# print(msg)
if msg["source"] == "LoRa":
print(f"\n[Main Loop] LoRa : Données traitées : {msg['data']}")
elif msg["source"] == "MQTT":
# MQTT HELLO
if (msg["topic"] == config.MQTT_TOPIC_HELLO.decode('utf-8')):
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']}")
except queue.Empty:
pass
# === CHECK FOR DISH INSERTED ===
# Read the dish height from the ultrasonic sensor. If it's below a certain threshold, we assume a dish has been inserted.
dish_height = ultrasonicRanger.get_dish_height()
if dish_height is not None and dish_height > 2.0: # Threshold in cm for detecting a dish
print(f"\n[Main Loop] Dish detected at height: {dish_height} cm. Initiating sensor read...")
# Read all sensors and store the data in the cooking queue for this microwave
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:
traceback.print_exc()
time.sleep(1) # Prevents rapid error logging in case of persistent issues
# Clean termination async def handle_new_dish(microwave_id, detected_height):
if hasattr(mqtt_client._client, "loop_stop"): """Triggered when a new dish is placed inside."""
mqtt_client._client.loop_stop() microwave_states[microwave_id] = MicrowaveState.ANALYZING
mqtt_client.close() 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:
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 with up to 3 retries (30s interval)."""
global cloud_alert
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params"
# 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...")
max_retries = 3
retry_delay_seconds = 30
for attempt in range(1, max_retries + 1):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
return
try:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi)
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return
response.raise_for_status()
plan = response.json().get("cook_plan", {})
c_time = plan.get("cook_time_seconds")
c_power = plan.get("effective_power_watts")
c_temp = plan.get("target_temp")
if c_time is None or c_power is None or c_temp is None:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
microwave_states[microwave_id] = MicrowaveState.DONE
return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
cloud_alert = False # Reset alert flag on success
microwave_states[microwave_id] = MicrowaveState.COOKING
if config.DEBUG:
c_time = min(c_time, 10) # Limit to 10s for debug
c_temp = min(c_temp, 50) # Limit to 50°C for debug
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS
)
return
except Exception as e:
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
if attempt < max_retries:
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
# Interruptible wait loop in case user removes the dish mid-wait
for _ in range(retry_delay_seconds):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
return
await asyncio.sleep(1)
# Executed only if all 3 retries failed
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.")
cloud_alert = True
microwave_states[microwave_id] = MicrowaveState.DONE
# --- MAIN LOGIC TASKS ---
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:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_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 negative glitches
if h is not None and h >= 0.0:
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."""
global cloud_alert
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
cloud_alert = False # Reset error alert on dish removal
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...")
# Initialize SQLite database table
init_db()
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"):
mqtt_client._client.loop_stop()
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()
+9
View File
@@ -1,5 +1,6 @@
import grovepi import grovepi
import math import math
import time
from sensors.lock import grove_lock from sensors.lock import grove_lock
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3 # Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
@@ -20,3 +21,11 @@ def get_temperature_and_humidity():
else: else:
print("Error reading from DHT sensor") print("Error reading from DHT sensor")
return None, None 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
+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']
+11
View File
@@ -4,6 +4,17 @@
import shared.deviceTypes as deviceTypes 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.safeQueue as safeQueue
try:
import shared.lora_device as lora_device
except ImportError:
pass # No need
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): def get_lora(*args, **kwargs):
from .lora_device import get_lora_device from .lora_device import get_lora_device
+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"
+215
View File
@@ -0,0 +1,215 @@
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.on_pause = None
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
self._stirred = False
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 set_pause_callback(self, callback):
self.on_pause = callback
def pause(self):
if self.paused:
return
self.paused = True
self._pause_started_at = time.time()
# self._notify_refresh(force=True)
if self.on_pause:
self.on_pause(self)
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()
self.on_pause(self)
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.state == CookingStates.IDLE:
return self.state
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 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.pause()
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
self.state = CookingStates.DONE
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.STIRRING_REQUIRED
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)
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
@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"
+96 -63
View File
@@ -5,98 +5,131 @@ same code can run on CPython (`sqlite3`) and MicroPython (`sqlite3` or
`usqlite`, depending on the port). `usqlite`, depending on the port).
""" """
from shared.logging import log
try: try:
import sqlite3 as _sqlite import sqlite3 as _sqlite
DRIVER_NAME = "sqlite3" DRIVER_NAME = "sqlite3"
except ImportError: except ImportError:
try: try:
import usqlite as _sqlite import usqlite as _sqlite
DRIVER_NAME = "usqlite" DRIVER_NAME = "usqlite"
except ImportError as exc: except ImportError as exc:
raise ImportError("No sqlite driver found. Expected sqlite3 or usqlite.") from exc log("[DB Error] No sqlite driver found. Expected sqlite3 or usqlite.")
raise ImportError("No sqlite driver found. Expected sqlite3 or usqlite.") from exc
def _connect(database_path, **connect_kwargs): def _connect(database_path, **connect_kwargs):
if connect_kwargs: try:
try: if connect_kwargs:
return _sqlite.connect(database_path, **connect_kwargs) try:
except TypeError: return _sqlite.connect(database_path, **connect_kwargs)
pass except TypeError:
return _sqlite.connect(database_path) pass
return _sqlite.connect(database_path)
except Exception as e:
log(f"[DB Error] Driver connect failed for '{database_path}': {e}")
raise
class Database: class Database:
"""Lightweight connection wrapper with a consistent API.""" """Lightweight connection wrapper with logging and consistent API."""
def __init__(self, database_path, **connect_kwargs): def __init__(self, database_path, **connect_kwargs):
self._database_path = database_path self._database_path = database_path
self._connect_kwargs = connect_kwargs self._connect_kwargs = connect_kwargs
self._connection = None self._connection = None
def open(self): def open(self):
if self._connection is None: if self._connection is None:
self._connection = _connect(self._database_path, **self._connect_kwargs) self._connection = _connect(self._database_path, **self._connect_kwargs)
return self._connection return self._connection
def close(self): def close(self):
if self._connection is not None: if self._connection is not None:
self._connection.close() try:
self._connection = None self._connection.close()
except Exception as e:
log(f"[DB Error] Failed to close database '{self._database_path}': {e}")
finally:
self._connection = None
def commit(self): def commit(self):
connection = self.open() connection = self.open()
if hasattr(connection, "commit"): if hasattr(connection, "commit"):
connection.commit() try:
connection.commit()
except Exception as e:
log(f"[DB Error] Commit failed on '{self._database_path}': {e}")
raise
def cursor(self): def cursor(self):
return self.open().cursor() return self.open().cursor()
def execute(self, sql, params=None): def execute(self, sql, params=None):
cursor = self.cursor() cursor = self.cursor()
if params is None: try:
cursor.execute(sql) if params is None:
else: cursor.execute(sql)
cursor.execute(sql, params) else:
return cursor cursor.execute(sql, params)
return cursor
except Exception as e:
log(f"[DB Error] Query failed on '{self._database_path}' | SQL: {sql} | Params: {params} | Error: {e}")
raise
def executemany(self, sql, params_list): def executemany(self, sql, params_list):
cursor = self.cursor() cursor = self.cursor()
cursor.executemany(sql, params_list) try:
return cursor cursor.executemany(sql, params_list)
return cursor
except Exception as e:
log(f"[DB Error] Executemany failed on '{self._database_path}' | SQL: {sql} | Error: {e}")
raise
def fetchone(self, sql, params=None): def fetchone(self, sql, params=None):
return self.execute(sql, params).fetchone() return self.execute(sql, params).fetchone()
def fetchall(self, sql, params=None): def fetchall(self, sql, params=None):
return self.execute(sql, params).fetchall() return self.execute(sql, params).fetchall()
def executescript(self, script): def executescript(self, script):
connection = self.open() connection = self.open()
if hasattr(connection, "executescript"): if hasattr(connection, "executescript"):
return connection.executescript(script) try:
raise NotImplementedError("executescript is not available on this sqlite backend") return connection.executescript(script)
except Exception as e:
log(f"[DB Error] Executescript failed on '{self._database_path}': {e}")
raise
log("[DB Error] executescript is not available on this sqlite backend")
raise NotImplementedError("executescript is not available on this sqlite backend")
def __enter__(self): def __enter__(self):
self.open() self.open()
return self return self
def __exit__(self, exc_type, exc, traceback): def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None: if exc_type is None:
self.commit() self.commit()
self.close() else:
log(f"[DB Error] Context exited with exception on '{self._database_path}': {exc_val}")
self.close()
def connect(database_path, **connect_kwargs): def connect(database_path, **connect_kwargs):
return Database(database_path, **connect_kwargs) return Database(database_path, **connect_kwargs)
def execute(database_path, sql, params=None, **connect_kwargs): def execute(database_path, sql, params=None, **connect_kwargs):
return connect(database_path, **connect_kwargs).execute(sql, params) with connect(database_path, **connect_kwargs) as db_inst:
return db_inst.execute(sql, params)
def fetchone(database_path, sql, params=None, **connect_kwargs): def fetchone(database_path, sql, params=None, **connect_kwargs):
return connect(database_path, **connect_kwargs).fetchone(sql, params) with connect(database_path, **connect_kwargs) as db_inst:
return db_inst.fetchone(sql, params)
def fetchall(database_path, sql, params=None, **connect_kwargs): def fetchall(database_path, sql, params=None, **connect_kwargs):
return connect(database_path, **connect_kwargs).fetchall(sql, params) with connect(database_path, **connect_kwargs) as db_inst:
return db_inst.fetchall(sql, params)
+318 -74
View File
@@ -1,5 +1,7 @@
import sys import sys
import time import time
import random
from shared.logging import log
IS_MICROPYTHON = sys.implementation.name == 'micropython' IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -8,81 +10,271 @@ 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(100)
else:
# Give LA66 chip time to finish logging RSSI and reset RF frontend
time.sleep(0.25)
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload)
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")
log(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
if ack_id is not None:
self.received_acks.add(ack_id)
if len(self.received_acks) > 100:
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:
log(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
self._send_ack(msg_id)
if msg_id in self.processed_msg_ids:
log(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=3.0):
"""Sends a payload and listens in a single continuous RX window for the ACK."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
for attempt in range(max_retries):
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
# 1. Open a single continuous RX window for the full timeout duration
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
timeout_ms = int(ack_timeout * 1000)
packet = self.receive_packet(timeout_ms=timeout_ms)
# 2. Process incoming packet if received
if packet:
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
finally:
if lock: lock.release()
# 3. Check if matching ACK was received
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
return False
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):
from sx1262 import SX1262 super().__init__()
self.lora = SX1262( self._pins = {
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso, "spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
cs=cs, irq=irq, rst=rst, gpio=gpio "cs": cs, "irq": irq, "rst": rst, "gpio": gpio
) }
self.default_group = 2 # On définit le groupe par défaut ici self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
self.lock = _thread.allocate_lock() # Création du verrou 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
new_instance = SX1262(**self._pins)
new_instance.begin(
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
)
# SyncWord 0x12 = Decimal 18
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.lora.begin( self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
freq=freq, bw=bw, sf=sf, cr=cr, power=power, if self.lora is None:
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False self.reset_hardware()
) else:
self.lora.setSyncWord(0x14) with self.lock:
try:
self.lora.begin(
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
)
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
self.lora.send(paquet_physique) try:
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:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms) if self.lora is None:
if state == 0 and len(data) > 1: return None
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t') try:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
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:
# Decode to string, ignoring unprintable characters
try: try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t') raw_text = data.decode('utf-8', 'ignore').strip()
except UnicodeError: except Exception:
return None return None
if text.startswith('{') or text.startswith('['): # Find where the actual JSON payload starts ({ or [)
decoded_text = text idx_brace = raw_text.find('{')
elif text.lower().startswith('7b') or text.lower().startswith('5b'): idx_bracket = raw_text.find('[')
valid_indices = [i for i in (idx_brace, idx_bracket) if i != -1]
if valid_indices:
# Slice off all leading group bytes/control characters (\x02)
json_str = raw_text[min(valid_indices):]
try: try:
decoded_text = ubinascii.unhexlify(text).decode('utf-8').strip('\x00 \r\n\t') parsed_json = json.loads(json_str)
return {"group": self.default_group, "data": parsed_json, "raw": False}
except Exception: except Exception:
decoded_text = text pass
else:
decoded_text = text
try: return {"group": self.default_group, "data": raw_text, "raw": True}
parsed_json = json.loads(decoded_text)
return {"group": group, "data": parsed_json, "raw": False}
except ValueError:
return {"group": group, "data": decoded_text, "raw": True}
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,12 +287,45 @@ 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 # Initial configuration
self.configure(freq=868.1, sf=7, bw=125)
def send(self, payload): def _send_at_cmd(self, cmd, wait_time=0.3):
"""Encode automatiquement la payload en HEX pour l'envoi via la clé.""" """Sends AT command, draining unread serial noise first."""
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
if self.ser.in_waiting > 0:
self.ser.read_all()
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
resp = ""
while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore')
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 a HEX string and transmits via 4-parameter AT+SEND."""
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)
@@ -108,24 +333,17 @@ else:
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
hex_payload = payload.hex() hex_payload = payload.hex()
self.ser.reset_input_buffer()
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n"
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3")
self.ser.write(cmd.encode('utf-8'))
time.sleep(0.2)
response = ""
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=5000): log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
cmd = f"AT+SEND={group},{hex_payload},0,3"
resp = self._send_at_cmd(cmd, wait_time=0.3)
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
def receive_packet(self, timeout_ms=500):
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
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 +354,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:
group = payload_bytes[0] if payload_bytes[0] in (0x7B, 0x5B):
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t') group = self.default_group
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
elif len(payload_bytes) > 1:
group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
else:
continue
try: try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t') text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
@@ -180,4 +418,10 @@ def get_lora_device(port_or_pins=None):
return LoraHardwareSPI(**pins) return LoraHardwareSPI(**pins)
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"
+106 -34
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,16 +106,24 @@ 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",
@@ -150,19 +160,35 @@ class BrokerClient:
return self._client return self._client
def connect(self): def connect(self):
client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
client.connect() gc.collect() # Force C & Python memory cleanup right before TLS handshake
return client
client.connect(self.host, self.port, self.keepalive) if self._client is not None:
return client 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()
return client
client.connect(self.host, self.port, self.keepalive)
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:
return client.publish(topic, payload_bytes, retain=retain, qos=qos) with self._lock:
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
if isinstance(topic, bytes): if isinstance(topic, bytes):
topic = topic.decode('utf-8') topic = topic.decode('utf-8')
@@ -172,8 +198,9 @@ 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:
client.set_callback(self._on_micropython_message) with self._lock:
return client.subscribe(topic, qos=qos) client.set_callback(self._on_micropython_message)
return client.subscribe(topic, qos=qos)
if isinstance(topic, bytes): if isinstance(topic, bytes):
topic = topic.decode('utf-8') topic = topic.decode('utf-8')
@@ -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,14 +262,16 @@ class BrokerClient:
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
return self._client.check_msg() with self._lock:
return self._client.check_msg()
return self._client.loop(timeout=timeout) return self._client.loop(timeout=timeout)
def wait(self): def wait(self):
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
return self._client.wait_msg() with self._lock:
return self._client.wait_msg()
return self._client.loop_forever() return self._client.loop_forever()
def get_message(self): def get_message(self):
@@ -235,13 +280,40 @@ 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
try:
self._client.disconnect() if IS_MICROPYTHON:
except Exception: 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:
self._client.disconnect()
except Exception:
pass
finally:
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 pass
self._client = None
def __enter__(self): def __enter__(self):
self.connect() self.connect()
+11 -1
View File
@@ -1,4 +1,5 @@
from time import time from time import time
from shared.deviceTypes import DEVICE_TYPES
try: try:
@@ -16,7 +17,8 @@ def as_json(data):
def mqtt_hello(id_microwave): def mqtt_hello(id_microwave):
return as_json({ return as_json({
"id_microwave": id_microwave "id_microwave": id_microwave,
"type": DEVICE_TYPES["MICROWAVE"]
}) })
def mqtt_hello_ack(id_orchestrator, id_microwave): def mqtt_hello_ack(id_orchestrator, id_microwave):
@@ -35,4 +37,12 @@ def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
"id_microwave": id_microwave, "id_microwave": id_microwave,
"dish_temp": dish_temp, "dish_temp": dish_temp,
"ambient_temp": ambient_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
}) })
+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)
+4
View File
@@ -0,0 +1,4 @@
try:
from shared.sensors.rgb_led import RGBLED
except ImportError:
pass # No need as we are on the RPI
+159
View File
@@ -0,0 +1,159 @@
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,
state toggling, and non-blocking blinking via machine.Timer.
"""
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
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, 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)
self._b_pwm = PWM(Pin(blue_pin, Pin.OUT), freq=freq)
self._common_anode = common_anode
# State tracking variables
self._color = (0, 0, 0) # Current (R, G, B) tuple [0-255]
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):
"""Recalculates and applies PWM duty cycles based on state."""
if not self._is_on:
r, g, b = 0, 0, 0
else:
r = int(self._color[0] * self._brightness)
g = int(self._color[1] * self._brightness)
b = int(self._color[2] * self._brightness)
for pwm, val in ((self._r_pwm, r), (self._g_pwm, g), (self._b_pwm, b)):
# Clamp value between 0 and 255
val = max(0, min(255, val))
# Convert 8-bit (0-255) to MicroPython's 16-bit PWM duty (0-65535)
duty = int((val / 255.0) * 65535)
if self._common_anode:
duty = 65535 - duty
pwm.duty_u16(duty)
# --- Properties and Setters ---
@property
def color(self):
"""Returns the active RGB tuple (R, G, B)."""
return self._color
@color.setter
def color(self, rgb_tuple):
"""Sets the RGB color tuple (e.g., (255, 128, 0))."""
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
self._color = tuple(rgb_tuple)
self._apply()
else:
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
@property
def brightness(self):
"""Returns the current brightness level (0.0 to 1.0)."""
return self._brightness
@brightness.setter
def brightness(self, level):
"""Sets brightness level from 0.0 (0%) to 1.0 (100%)."""
self._brightness = max(0.0, min(1.0, float(level)))
self._apply()
@property
def is_on(self):
"""Returns True if the LED is currently powered on."""
return self._is_on
@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."""
self.color = (r, g, b)
def on(self):
"""Turns the LED on using its stored color and brightness."""
self._is_on = True
self._apply()
def off(self):
"""Turns the LED off without resetting the active color state."""
self._is_on = False
self._apply()
def toggle(self):
"""Toggles between ON and OFF states."""
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 and timer when finished."""
self._r_pwm.deinit()
self._g_pwm.deinit()
self._b_pwm.deinit()
+74 -40
View File
@@ -1,66 +1,100 @@
# shared/uart_comm.py
import _thread import _thread
from machine import UART from machine import UART
import time import time
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) 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):
"""Asynchronous internal loop parsing incoming stream lines into the queue.""" """Simple worker that relies on newline framing instead of manual JSON parsing."""
while True: while True:
try: if self.uart.any():
if self.uart.any(): with self.lock:
with self.lock: line = self.uart.readline()
# 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)
time.sleep_ms(20) # Give other background threads breathing room 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)
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'
with self.lock: with self.lock:
self.uart.write(message.encode('utf-8')) 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'):
"""Safely sends a structured command over UART."""
json_message = command.to_json()
self.send(json_message)
def any(self): def any(self):
"""Checks if any complete messages are waiting to be read.""" """Checks if any complete messages are waiting to be read."""
with self.lock: with self.lock:
return len(self.rx_queue) > 0 return len(self.rx_queue) > 0
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
def read(self):
"""Pulls the oldest unread string from the queue. Returns None if empty.""" class UARTCommand:
with self.lock: """A simple wrapper for commands sent over UART, allowing for structured data."""
if self.rx_queue: def __init__(self, command_type: str, payload):
return self.rx_queue.pop(0) self.command_type = command_type
return None 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"
+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()