Compare commits
12 Commits
5c60017e8d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 25e635e207 | |||
| fb41a2d07a | |||
| fb6ee0099a | |||
| 038171d68d | |||
| 5381631876 | |||
| 2df05fd684 | |||
| 11357b9a0e | |||
| e2beef511c | |||
| e748d53c94 | |||
| 6fa74c6b93 | |||
| af5ea45270 | |||
| 1626b392b3 |
+189
-120
@@ -1,179 +1,248 @@
|
|||||||
|
import gc
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
import _thread
|
import _thread
|
||||||
|
import uasyncio as asyncio
|
||||||
from machine import Pin, SoftI2C
|
from machine import Pin, SoftI2C
|
||||||
|
import framebuf
|
||||||
|
import ssd1306
|
||||||
|
|
||||||
|
# Clean memory immediately
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
from shared.safeQueue import SafeQueue
|
from shared.safeQueue import SafeQueue
|
||||||
from shared import get_lora, get_uart, deviceTypes, config, cookingState
|
from shared import get_lora, get_uart, deviceTypes, config, cookingState
|
||||||
from shared.uart_comm import UARTCommand, UARTCommandType
|
from shared.uart_comm import UARTCommand, UARTCommandType
|
||||||
from shared.sensors import RGBLED
|
from shared.sensors import RGBLED
|
||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
from shared.lora_device import LoraCommands
|
from shared.lora_device import LoraCommands
|
||||||
import framebuf
|
|
||||||
import ssd1306
|
|
||||||
import time
|
|
||||||
|
|
||||||
# --- Configuration Matérielle ---
|
# --- READ DEVICE ID ---
|
||||||
vext = Pin(19, Pin.OUT)
|
|
||||||
vext.value(0)
|
|
||||||
time.sleep_ms(100)
|
|
||||||
|
|
||||||
# --- Lecture de l'ID unique de l'ESP ---
|
|
||||||
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()
|
data_queue = SafeQueue()
|
||||||
|
lora = None
|
||||||
# --- Création des lEDs RGB ---
|
uart_device = None
|
||||||
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
|
magnetron_led = None
|
||||||
magnetron_led.color = RGBLED.WHITE_YELLOW
|
display = None
|
||||||
magnetron_led.off()
|
|
||||||
|
|
||||||
# --- Création de l'écran OLED ---
|
|
||||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
|
||||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
|
||||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
|
||||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
|
||||||
display.text("Booting...", 1, 2, 1)
|
|
||||||
display.show()
|
|
||||||
|
|
||||||
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
|
||||||
|
|
||||||
PING_PAYLOAD = {
|
PING_PAYLOAD = {
|
||||||
"id": DEVICE_ID,
|
"id": DEVICE_ID,
|
||||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
"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
|
last_heartbeat_time = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# 1. Send periodic heartbeat
|
# 1. Send periodic heartbeat
|
||||||
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||||
last_heartbeat_time = now
|
last_heartbeat_time = now
|
||||||
print("\nESP32 : Envoi du Heartbeat...")
|
print("\n[LoRa Thread] Sending Heartbeat...")
|
||||||
lora.send(PING_PAYLOAD)
|
if lora:
|
||||||
|
lora.send(PING_PAYLOAD)
|
||||||
|
|
||||||
# 2. Increase listen window to 300ms so radio stays active in RX mode
|
# 2. Blocking 300ms RX listen window (keeps radio actively listening)
|
||||||
paquet = lora.receive_reliable(timeout_ms=300)
|
if lora:
|
||||||
|
paquet = lora.receive_reliable(timeout_ms=300)
|
||||||
if paquet is not None:
|
if paquet is not None:
|
||||||
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
||||||
data_queue.put(paquet)
|
data_queue.put(paquet)
|
||||||
|
|
||||||
time.sleep_ms(10)
|
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é
|
# --- COOKING STATE CALLBACKS ---
|
||||||
try:
|
|
||||||
_thread.stack_size(16 * 1024)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_thread.start_new_thread(heartbeat_loop, ())
|
|
||||||
|
|
||||||
# Cooking parameters
|
|
||||||
cooking_state = None
|
|
||||||
def cooking_state_temperature_provider():
|
def cooking_state_temperature_provider():
|
||||||
return 22.0, 29.0 # TODO Remplacer par la lecture réelle de la température du plat et de l'air ambiant
|
return 22.0, 29.0 # TODO: Replace with real temperature reading
|
||||||
|
|
||||||
def cooking_state_on_state_change(state):
|
def cooking_state_on_state_change(state):
|
||||||
print(f"[Main] Cooking state changed to: {state.state}")
|
print(f"[CookingState] State changed to: {state.state}")
|
||||||
|
|
||||||
# Send to the Wifi board the current state
|
|
||||||
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
|
||||||
# Send to the orchestrator the current state
|
|
||||||
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
|
||||||
|
|
||||||
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
|
||||||
display.show()
|
|
||||||
|
|
||||||
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
||||||
magnetron_led.off()
|
magnetron_led.off()
|
||||||
else:
|
else:
|
||||||
magnetron_led.on()
|
magnetron_led.on()
|
||||||
|
|
||||||
|
# Send state updates to WiFi board and Orchestrator
|
||||||
|
if uart_device:
|
||||||
|
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
||||||
|
if lora:
|
||||||
|
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||||
|
|
||||||
if state.state == cookingState.CookingStates.COOKING:
|
# Update OLED display
|
||||||
pass
|
if display:
|
||||||
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
|
display.fill(0)
|
||||||
pass
|
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
||||||
if state.state == cookingState.CookingStates.DONE:
|
display.show()
|
||||||
pass
|
|
||||||
if state.state == cookingState.CookingStates.ALERT:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cooking_state_on_refresh(state):
|
def cooking_state_on_refresh(state):
|
||||||
# TODO Show screen information
|
# TODO: Show screen information
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def cooking_state_on_pause(state):
|
def cooking_state_on_pause(state):
|
||||||
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
||||||
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
|
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
|
||||||
state.set_state(cookingState.CookingStates.COOKING)
|
state.set_state(cookingState.CookingStates.COOKING)
|
||||||
|
# TODO: send_reliable lora message to orchestrator about pause/resume state
|
||||||
# TODO send_reliable lora message to orchestrator about pause/resume state
|
|
||||||
|
|
||||||
|
|
||||||
# --- MAIN APPLICATION THREAD ---
|
# --- ASYNC TASKS ---
|
||||||
print("[Main] Main execution path active.")
|
|
||||||
while True:
|
|
||||||
# 1. Listen for incoming UART serial packets from the WROOM board
|
|
||||||
while uart_device.any():
|
|
||||||
command = uart_device.read_as_command()
|
|
||||||
if command:
|
|
||||||
print(f"[Main] Received command from WiFi Board: {command.command_type}")
|
|
||||||
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
|
||||||
# Handle cooking parameters command
|
|
||||||
params = command.payload
|
|
||||||
print(f"[Main] Cooking parameters received: {params}")
|
|
||||||
cooking_state = cookingState.CookingState(
|
|
||||||
cook_time=params["cook_time"],
|
|
||||||
power_level=params["power_level"],
|
|
||||||
target_temp=params["target_temp"]
|
|
||||||
)
|
|
||||||
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
|
|
||||||
cooking_state.set_state_change_callback(cooking_state_on_state_change)
|
|
||||||
cooking_state.set_refresh_callback(cooking_state_on_refresh)
|
|
||||||
cooking_state.set_pause_callback(cooking_state_on_pause)
|
|
||||||
time.sleep_ms(20) # Before sending back right away
|
|
||||||
cooking_state_on_state_change(cooking_state)
|
|
||||||
|
|
||||||
else:
|
async def uart_polling_task():
|
||||||
print(f"[Main] Unknown command type received: {command.command_type}")
|
"""Polls UART for incoming messages from the WiFi board."""
|
||||||
# 2. Listen for incoming LoRa packets from the orchestrator
|
global cooking_state
|
||||||
while not data_queue.empty():
|
|
||||||
paquet = data_queue.get()
|
while True:
|
||||||
if paquet and not paquet["raw"]:
|
if uart_device and uart_device.any():
|
||||||
data = paquet["data"]
|
command = uart_device.read_as_command()
|
||||||
# Commands
|
if command:
|
||||||
if "action" in data:
|
print(f"[UART Task] Received command from WiFi Board: {command.command_type}")
|
||||||
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||||
if cooking_state != None:
|
params = command.payload
|
||||||
if (cooking_state.state == cookingState.CookingStates.DONE):
|
print(f"[UART Task] Cooking parameters received: {params}")
|
||||||
print("[Main] Cooking is done. We reset the microwave for the next cooking session.")
|
|
||||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
cooking_state = cookingState.CookingState(
|
||||||
time.sleep_ms(20) # Before sending back right away
|
cook_time=params["cook_time"],
|
||||||
cooking_state = None
|
power_level=params["power_level"],
|
||||||
else:
|
target_temp=params["target_temp"]
|
||||||
cooking_state.toggle_pause()
|
)
|
||||||
if cooking_state.paused:
|
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
|
||||||
print("[Main] Cooking paused via orchestrator command.")
|
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:
|
else:
|
||||||
print("[Main] Cooking resumed via orchestrator command.")
|
cooking_state.toggle_pause()
|
||||||
else:
|
if cooking_state.paused:
|
||||||
log("[Main] No active cooking state to toggle pause/resume.")
|
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.")
|
||||||
|
|
||||||
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
|
await asyncio.sleep_ms(50)
|
||||||
|
|
||||||
# Cooking State Update
|
|
||||||
if cooking_state != None:
|
|
||||||
cooking_state.update_tick()
|
|
||||||
print(f"[Main] Cooking state : State : {cooking_state.state}, Temperature: {cooking_state.current_dish_temp}, Paused: {cooking_state.paused}, Remaining Time: {cooking_state.get_remaining_time():.2f}s, Estimated Remaining Time: {cooking_state.get_remaining_time_estimation():.2f}s")
|
|
||||||
|
|
||||||
time.sleep_ms(500)
|
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)
|
||||||
@@ -30,13 +30,7 @@ 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="192.168.50.1",
|
|
||||||
client_id="smartwave-esp32-demo",
|
|
||||||
use_tls=True,
|
|
||||||
cafile=MQTT_CA_FILE,
|
|
||||||
keepalive=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
|
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
|
||||||
status_led = None
|
status_led = None
|
||||||
@@ -82,35 +76,31 @@ def init_hardware():
|
|||||||
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
|
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
|
||||||
|
|
||||||
|
|
||||||
def on_received_cooking_state_update(state, is_error=False, is_terminated=False):
|
|
||||||
"""Callback executed when state changes are received from the LoRa board over UART."""
|
|
||||||
if cooking_state:
|
|
||||||
if is_error:
|
|
||||||
cooking_state.set_state(cookingState.CookingStates.ERROR)
|
|
||||||
elif is_terminated:
|
|
||||||
cooking_state.set_state(cookingState.CookingStates.ABORTED)
|
|
||||||
else:
|
|
||||||
cooking_state.set_state(state)
|
|
||||||
|
|
||||||
|
|
||||||
def on_cooking_state_change(state):
|
def on_cooking_state_change(state):
|
||||||
"""Callback executed whenever local cooking state transitions."""
|
"""Callback executed whenever local cooking state transitions."""
|
||||||
BLINK_INTERVAL_MS = 500
|
BLINK_INTERVAL_MS = 500
|
||||||
|
|
||||||
|
log(f"[CookingState] State changed to: {state.state}")
|
||||||
|
|
||||||
if status_led and cookingState:
|
if status_led and cookingState:
|
||||||
if state == cookingState.CookingStates.IDLE:
|
if state.state == cookingState.CookingStates.IDLE:
|
||||||
|
log(f"[CookingState] State changed to IDLE. Turning LED off and stopping blink.")
|
||||||
status_led.color = status_led.OFF
|
status_led.color = status_led.OFF
|
||||||
status_led.blink_off()
|
status_led.blink_off()
|
||||||
elif state == cookingState.CookingStates.COOKING:
|
elif state.state == cookingState.CookingStates.COOKING:
|
||||||
|
log(f"[CookingState] State changed to COOKING. Turning LED yellow and stopping blink.")
|
||||||
status_led.color = status_led.YELLOW
|
status_led.color = status_led.YELLOW
|
||||||
status_led.blink_off()
|
status_led.blink_off()
|
||||||
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
|
elif state.state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||||
|
log(f"[CookingState] State changed to STIRRING_REQUIRED. Turning LED orange and starting blink.")
|
||||||
status_led.color = status_led.ORANGE
|
status_led.color = status_led.ORANGE
|
||||||
status_led.blink_on(BLINK_INTERVAL_MS)
|
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||||
elif state == cookingState.CookingStates.ALERT:
|
elif state.state == cookingState.CookingStates.ALERT:
|
||||||
|
log(f"[CookingState] State changed to ALERT. Turning LED red and starting blink.")
|
||||||
status_led.color = status_led.RED
|
status_led.color = status_led.RED
|
||||||
status_led.blink_on(BLINK_INTERVAL_MS)
|
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||||
elif state == cookingState.CookingStates.DONE:
|
elif state.state == cookingState.CookingStates.DONE:
|
||||||
|
log(f"[CookingState] State changed to DONE. Turning LED green and stopping blink.")
|
||||||
status_led.color = status_led.GREEN
|
status_led.color = status_led.GREEN
|
||||||
status_led.blink_off()
|
status_led.blink_off()
|
||||||
|
|
||||||
@@ -199,23 +189,20 @@ async def sensor_publisher_task():
|
|||||||
|
|
||||||
async def uart_task():
|
async def uart_task():
|
||||||
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
|
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
|
||||||
|
global cooking_state
|
||||||
while True:
|
while True:
|
||||||
if uart_device:
|
if uart_device:
|
||||||
try:
|
try:
|
||||||
cmd = uart_device.read_as_command()
|
cmd = uart_device.read_as_command()
|
||||||
|
|
||||||
if cmd:
|
if cmd:
|
||||||
print("[UART] Command received from LoRa board:", cmd)
|
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
|
||||||
if (
|
if (
|
||||||
hasattr(cmd, "command_type")
|
hasattr(cmd, "command_type")
|
||||||
and cmd.command_type == UARTCommandType.STATE_UPDATE
|
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
|
||||||
and on_received_cooking_state_update
|
|
||||||
):
|
):
|
||||||
on_received_cooking_state_update(
|
if cooking_state:
|
||||||
cmd.payload.get("state"),
|
cooking_state.set_state(cmd.payload.get("state"))
|
||||||
cmd.payload.get("is_error", False),
|
|
||||||
cmd.payload.get("is_terminated", False),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[UART Task] Error reading command:", e)
|
print("[UART Task] Error reading command:", e)
|
||||||
|
|
||||||
|
|||||||
+105
-35
@@ -6,12 +6,40 @@ 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.cookingState import CookingStates
|
||||||
from shared.lora_device import LoraCommands
|
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 ---
|
||||||
def get_device_id():
|
def get_device_id():
|
||||||
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
|
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
|
||||||
@@ -35,7 +63,9 @@ class MicrowaveState:
|
|||||||
# Global state trackers
|
# Global state trackers
|
||||||
microwave_states = {"2": MicrowaveState.IDLE}
|
microwave_states = {"2": MicrowaveState.IDLE}
|
||||||
button_state = False
|
button_state = False
|
||||||
|
cloud_alert = False # Global status flag for screen / UI display
|
||||||
async_event_queue = None
|
async_event_queue = None
|
||||||
|
|
||||||
# Async synchronization trackers for MQTT IR sensors responses
|
# Async synchronization trackers for MQTT IR sensors responses
|
||||||
ir_data_cache = {} # mw_id -> dict of IR readings
|
ir_data_cache = {} # mw_id -> dict of IR readings
|
||||||
ir_data_events = {} # mw_id -> asyncio.Event()
|
ir_data_events = {} # mw_id -> asyncio.Event()
|
||||||
@@ -109,7 +139,7 @@ button.start_button_monitoring_thread()
|
|||||||
|
|
||||||
# --- HARDWARE CONTROLLERS ---
|
# --- HARDWARE CONTROLLERS ---
|
||||||
def _stop_hardware(microwave_id: str):
|
def _stop_hardware(microwave_id: str):
|
||||||
print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.")
|
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
|
||||||
# TODO: Add LoRa STOP command here
|
# TODO: Add LoRa STOP command here
|
||||||
|
|
||||||
# --- ASYNC COOKING LOGIC ---
|
# --- ASYNC COOKING LOGIC ---
|
||||||
@@ -187,7 +217,8 @@ async def handle_new_dish(microwave_id, detected_height):
|
|||||||
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||||
|
|
||||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||||
"""Sends all data to the cloud and starts the microwave if successful."""
|
"""Sends all data to the cloud with up to 3 retries (30s interval)."""
|
||||||
|
global cloud_alert
|
||||||
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
||||||
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||||
|
|
||||||
@@ -196,36 +227,63 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
|
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
|
||||||
|
|
||||||
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
|
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
|
||||||
try:
|
|
||||||
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)
|
max_retries = 3
|
||||||
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
|
retry_delay_seconds = 30
|
||||||
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
response.raise_for_status()
|
try:
|
||||||
plan = response.json().get("cook_plan", {})
|
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
|
||||||
c_time = plan.get("cook_time_seconds")
|
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
|
||||||
c_power = plan.get("effective_power_watts")
|
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
||||||
c_temp = plan.get("target_temp")
|
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||||
|
return
|
||||||
|
|
||||||
if c_time is None or c_power is None or c_temp is None:
|
response.raise_for_status()
|
||||||
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
plan = response.json().get("cook_plan", {})
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
|
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
|
return
|
||||||
|
|
||||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
except Exception as e:
|
||||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
|
||||||
mqtt_client.publish(
|
|
||||||
config.MQTT_TOPIC_COOKING,
|
|
||||||
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
|
||||||
qos=config.MQTT_QOS
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
if attempt < max_retries:
|
||||||
print(f"[{microwave_id}] Cloud API Error: {e}")
|
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
# 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 ---
|
# --- MAIN LOGIC TASKS ---
|
||||||
async def process_messages_task():
|
async def process_messages_task():
|
||||||
@@ -257,13 +315,19 @@ async def process_messages_task():
|
|||||||
|
|
||||||
if topic == hello_topic:
|
if topic == hello_topic:
|
||||||
if data.get("id_orchestrator") != DEVICE_ID:
|
if data.get("id_orchestrator") != DEVICE_ID:
|
||||||
mw_id = data.get("id_microwave")
|
component_id = data.get("id_microwave")
|
||||||
print(f"[MQTT] Hello from {mw_id}. Sending ACK.")
|
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
|
||||||
mqtt_client.publish(
|
|
||||||
config.MQTT_TOPIC_HELLO,
|
if component_id:
|
||||||
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
|
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
|
||||||
qos=config.MQTT_QOS
|
# 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:
|
elif topic == sensor_topic:
|
||||||
mw_id = str(data.get("id_microwave"))
|
mw_id = str(data.get("id_microwave"))
|
||||||
@@ -279,8 +343,8 @@ async def get_filtered_dish_height(samples=3, delay=0.04):
|
|||||||
valid_samples = []
|
valid_samples = []
|
||||||
for _ in range(samples):
|
for _ in range(samples):
|
||||||
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
|
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
|
||||||
# Discard 0.0 or near-zero timeout glitches
|
# Discard negative glitches
|
||||||
if h is not None and h > 0.5:
|
if h is not None and h >= 0.0:
|
||||||
valid_samples.append(h)
|
valid_samples.append(h)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
@@ -292,6 +356,7 @@ async def get_filtered_dish_height(samples=3, delay=0.04):
|
|||||||
|
|
||||||
async def monitor_dish_height_task():
|
async def monitor_dish_height_task():
|
||||||
"""Monitors presence of dish with hysteresis and debouncing."""
|
"""Monitors presence of dish with hysteresis and debouncing."""
|
||||||
|
global cloud_alert
|
||||||
mw_id = "2"
|
mw_id = "2"
|
||||||
consecutive_present = 0
|
consecutive_present = 0
|
||||||
consecutive_absent = 0
|
consecutive_absent = 0
|
||||||
@@ -326,6 +391,8 @@ async def monitor_dish_height_task():
|
|||||||
consecutive_absent = 0
|
consecutive_absent = 0
|
||||||
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
||||||
microwave_states[mw_id] = MicrowaveState.IDLE
|
microwave_states[mw_id] = MicrowaveState.IDLE
|
||||||
|
cloud_alert = False # Reset error alert on dish removal
|
||||||
|
|
||||||
if current_state == MicrowaveState.COOKING:
|
if current_state == MicrowaveState.COOKING:
|
||||||
_stop_hardware(mw_id)
|
_stop_hardware(mw_id)
|
||||||
# Remove from IR cache and events
|
# Remove from IR cache and events
|
||||||
@@ -339,6 +406,9 @@ async def main():
|
|||||||
global async_event_queue
|
global async_event_queue
|
||||||
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
|
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
|
||||||
|
|
||||||
|
# Initialize SQLite database table
|
||||||
|
init_db()
|
||||||
|
|
||||||
async_event_queue = asyncio.Queue()
|
async_event_queue = asyncio.Queue()
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
|
|||||||
@@ -179,8 +179,6 @@ class CookingState:
|
|||||||
elapsed_time = self.get_elapsed_time()
|
elapsed_time = self.get_elapsed_time()
|
||||||
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||||
|
|
||||||
print(elapsed_time, self.cook_time, self.current_dish_temp, self.target_temp, self._paused_duration, self._pause_started_at, now)
|
|
||||||
|
|
||||||
if self.current_dish_temp is not None:
|
if self.current_dish_temp is not None:
|
||||||
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring
|
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring
|
||||||
self.state = CookingStates.STIRRING_REQUIRED
|
self.state = CookingStates.STIRRING_REQUIRED
|
||||||
|
|||||||
+96
-63
@@ -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)
|
||||||
+63
-70
@@ -1,6 +1,7 @@
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
from shared.logging import log
|
||||||
|
|
||||||
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
||||||
|
|
||||||
@@ -32,9 +33,10 @@ class BaseLoraDevice:
|
|||||||
"""Sends an immediate acknowledgement packet back to the sender."""
|
"""Sends an immediate acknowledgement packet back to the sender."""
|
||||||
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
|
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
time.sleep_ms(10)
|
time.sleep_ms(100)
|
||||||
else:
|
else:
|
||||||
time.sleep(0.01)
|
# Give LA66 chip time to finish logging RSSI and reset RF frontend
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
|
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
|
||||||
self.send(ack_payload)
|
self.send(ack_payload)
|
||||||
@@ -49,7 +51,7 @@ class BaseLoraDevice:
|
|||||||
# 1. Handle incoming ACK response
|
# 1. Handle incoming ACK response
|
||||||
if data.get("_type") == "_ack":
|
if data.get("_type") == "_ack":
|
||||||
ack_id = data.get("_ack_id")
|
ack_id = data.get("_ack_id")
|
||||||
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
|
log(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
|
||||||
if ack_id is not None:
|
if ack_id is not None:
|
||||||
self.received_acks.add(ack_id)
|
self.received_acks.add(ack_id)
|
||||||
if len(self.received_acks) > 100:
|
if len(self.received_acks) > 100:
|
||||||
@@ -59,11 +61,11 @@ class BaseLoraDevice:
|
|||||||
# 2. Handle incoming command expecting an ACK
|
# 2. Handle incoming command expecting an ACK
|
||||||
msg_id = data.get("_msg_id")
|
msg_id = data.get("_msg_id")
|
||||||
if msg_id is not None:
|
if msg_id is not None:
|
||||||
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
|
log(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
|
||||||
self._send_ack(msg_id)
|
self._send_ack(msg_id)
|
||||||
|
|
||||||
if msg_id in self.processed_msg_ids:
|
if msg_id in self.processed_msg_ids:
|
||||||
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
|
log(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
|
||||||
return None # Discard duplicate retry
|
return None # Discard duplicate retry
|
||||||
|
|
||||||
self.processed_msg_ids.add(msg_id)
|
self.processed_msg_ids.add(msg_id)
|
||||||
@@ -72,8 +74,8 @@ class BaseLoraDevice:
|
|||||||
|
|
||||||
return packet
|
return packet
|
||||||
|
|
||||||
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
|
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
|
||||||
"""Sends a payload and retries until an ACK is received or max retries are reached."""
|
"""Sends a payload and listens in a single continuous RX window for the ACK."""
|
||||||
lock = getattr(self, 'lock', None)
|
lock = getattr(self, 'lock', None)
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
@@ -84,44 +86,38 @@ class BaseLoraDevice:
|
|||||||
msg_id = self._generate_msg_id()
|
msg_id = self._generate_msg_id()
|
||||||
payload["_msg_id"] = msg_id
|
payload["_msg_id"] = msg_id
|
||||||
|
|
||||||
print(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||||
self.send(payload)
|
self.send(payload)
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
while (time.time() - start_time) < ack_timeout:
|
# 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()
|
if lock: lock.acquire()
|
||||||
try:
|
try:
|
||||||
if msg_id in self.received_acks:
|
filtered_packet = self._process_incoming_packet(packet)
|
||||||
self.received_acks.remove(msg_id)
|
if filtered_packet:
|
||||||
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
self.pending_rx_queue.append(filtered_packet)
|
||||||
return True
|
|
||||||
finally:
|
finally:
|
||||||
if lock: lock.release()
|
if lock: lock.release()
|
||||||
|
|
||||||
packet = self.receive_packet(timeout_ms=500)
|
# 3. Check if matching ACK was received
|
||||||
if packet:
|
if lock: lock.acquire()
|
||||||
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
|
try:
|
||||||
if lock: lock.acquire()
|
if msg_id in self.received_acks:
|
||||||
try:
|
self.received_acks.remove(msg_id)
|
||||||
filtered_packet = self._process_incoming_packet(packet)
|
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||||
if filtered_packet:
|
return True
|
||||||
self.pending_rx_queue.append(filtered_packet)
|
finally:
|
||||||
finally:
|
if lock: lock.release()
|
||||||
if lock: lock.release()
|
|
||||||
|
|
||||||
if lock: lock.acquire()
|
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||||
try:
|
|
||||||
if msg_id in self.received_acks:
|
|
||||||
self.received_acks.remove(msg_id)
|
|
||||||
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
if lock: lock.release()
|
|
||||||
|
|
||||||
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
|
||||||
|
|
||||||
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
||||||
return False
|
return False
|
||||||
@@ -247,36 +243,30 @@ if IS_MICROPYTHON:
|
|||||||
print(f"[LoRa SPI] Recv error caught: {e}")
|
print(f"[LoRa SPI] Recv error caught: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
if state == 0 and data is not None and len(data) > 0:
|
if state == 0 and data is not None and len(data) > 0:
|
||||||
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
|
# Decode to string, ignoring unprintable characters
|
||||||
group = self.default_group
|
|
||||||
payload_brute = data.strip(b'\x00 \r\n\t')
|
|
||||||
elif len(data) > 1:
|
|
||||||
group = data[0]
|
|
||||||
payload_brute = data[1:].strip(b'\x00 \r\n\t')
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -301,10 +291,15 @@ else:
|
|||||||
# Initial configuration
|
# Initial configuration
|
||||||
self.configure(freq=868.1, sf=7, bw=125)
|
self.configure(freq=868.1, sf=7, bw=125)
|
||||||
|
|
||||||
def _send_at_cmd(self, cmd, wait_time=0.15):
|
def _send_at_cmd(self, cmd, wait_time=0.3):
|
||||||
"""Helper to send AT command and purge response buffer."""
|
"""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'))
|
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
|
|
||||||
resp = ""
|
resp = ""
|
||||||
while self.ser.in_waiting > 0:
|
while self.ser.in_waiting > 0:
|
||||||
resp += self.ser.readline().decode('utf-8', errors='ignore')
|
resp += self.ser.readline().decode('utf-8', errors='ignore')
|
||||||
@@ -326,7 +321,7 @@ else:
|
|||||||
self.ser.reset_input_buffer()
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
def send(self, payload, group=None):
|
def send(self, payload, group=None):
|
||||||
"""Encodes payload into HEX AT command and re-enables continuous RX."""
|
"""Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if group is None:
|
if group is None:
|
||||||
group = self.default_group
|
group = self.default_group
|
||||||
@@ -337,17 +332,15 @@ else:
|
|||||||
if isinstance(payload, str):
|
if isinstance(payload, str):
|
||||||
payload = payload.encode('utf-8')
|
payload = payload.encode('utf-8')
|
||||||
|
|
||||||
paquet_physique = bytes([group]) + payload
|
hex_payload = payload.hex()
|
||||||
hex_payload = paquet_physique.hex()
|
|
||||||
self.ser.reset_input_buffer()
|
|
||||||
|
|
||||||
print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
|
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
|
||||||
cmd = f"AT+PSEND={hex_payload}"
|
|
||||||
resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
|
|
||||||
print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
|
|
||||||
|
|
||||||
# Re-enable continuous receive mode after transmission completes
|
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
|
||||||
self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
|
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):
|
def receive_packet(self, timeout_ms=500):
|
||||||
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
|
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
|
||||||
|
|||||||
+3
-1
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user