Working MQTT back !

This commit is contained in:
2026-08-03 16:41:48 +02:00
parent 9eac93c409
commit 43a1822547
4 changed files with 716 additions and 305 deletions
+44 -10
View File
@@ -5,16 +5,50 @@ esp.osdebug(True)
#import webrepl #import webrepl
#webrepl.start() #webrepl.start()
def do_connect(ssid, pwd): # def do_connect(ssid, pwd):
import network # 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')
+268 -203
View File
@@ -1,23 +1,12 @@
import _thread import gc
import select import sys
from machine import Pin, I2C
from sensors import temperature_sensor
from shared import get_mqtt_client, get_uart, config, payloads, cookingState
from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED
from shared.logging import log
import time import time
import ujson as json import ujson as json
import sys import uasyncio as asyncio
from machine import Pin, I2C
# Simple thread-safe queue list # 1. Clean memory immediately
msg_queue = [] gc.collect()
queue_lock = _thread.allocate_lock()
def queue_publish(topic, payload):
"""Safely queues a message from the main thread."""
with queue_lock:
msg_queue.append((topic, payload))
# --- READ DEVICE ID --- # --- READ DEVICE ID ---
try: try:
@@ -26,214 +15,290 @@ try:
except Exception: except Exception:
DEVICE_ID = "ESP32_Inconnu" DEVICE_ID = "ESP32_Inconnu"
# --- Cooking State --- # --- GLOBAL APP STATE ---
cooking_state = None # This will hold the current cooking state if any orchestrator_id = None
cooking_state = None
mqtt_connected = False
# --- MQTT SETUP (Initialized First!) ---
from shared import get_mqtt_client, config, payloads
# --- MQTT SETUP ---
MQTT_CA_FILE = "/certs/ca.crt" MQTT_CA_FILE = "/certs/ca.crt"
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host=config.MQTT_BROKER_HOST, host="192.168.50.1",
client_id="smartwave-esp32-" + DEVICE_ID, client_id="smartwave-esp32-demo",
use_tls=config.USE_TLS, use_tls=True,
cafile=MQTT_CA_FILE, cafile=MQTT_CA_FILE,
keepalive=config.MQTT_KEEPALIVE, keepalive=30,
) )
global orchestrator_id # --- HARDWARE & MODULE DEFERRED IMPORTS ---
orchestrator_id = None # We declare variables here, but initialize them AFTER MQTT connects
def on_mqtt_message(message): status_led = None
print("[MQTT Thread] Received message:", message) uart_device = None
mlx_temperature_sensor = None
cookingState = None
log = None
UARTCommand = None
UARTCommandType = None
def init_hardware():
"""Initializes hardware peripherals AFTER MQTT TLS has reserved its memory."""
global status_led, uart_device, mlx_temperature_sensor
global cookingState, log, UARTCommand, UARTCommandType
# Try and parse the payload as json, but if it fails, just print the raw payload print("[Main] Initializing hardware peripherals...")
payload_data=None
try:
payload_data = json.loads(message['payload'])
except Exception as e:
print("[MQTT Thread] Error parsing JSON:", e)
sys.print_exception(e)
pass # Maybe it's not JSON
if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID: # Deferred module imports
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"]) from shared import get_uart, cookingState as cs, logging
global orchestrator_id from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
orchestrator_id = payload_data["id_orchestrator"] from shared.sensors import RGBLED
# Unsubscribe from the hello topic since we got a response from sensors import temperature_sensor
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
# Handle cooking messages
elif message['topic'] == config.MQTT_TOPIC_COOKING and payload_data and payload_data["id_microwave"] == DEVICE_ID:
# Cooking sensors init request
if not "cook_time" in payload_data:
print("[MQTT Thread] Cooking sensors init received from the orchestrator")
obj_temp = mlx_temperature_sensor.read_object_temp()
amb_temp = mlx_temperature_sensor.read_ambient_temp()
queue_publish(config.MQTT_TOPIC_SENSOR, payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp))
# Received cooking parameters from the orchestrator
else:
print("[MQTT Thread] Cooking parameters received from the orchestrator:", payload_data)
global cooking_state
cooking_state = cookingState.CookingState(
cook_time=payload_data["cook_time"],
power_level=payload_data["power_level"],
target_temp=payload_data["target_temp"]
)
cooking_state.set_state_change_callback(on_cooking_state_change)
cooking_state.set_state(cookingState.CookingStates.IDLE) # Set initial state to IDLE
# Send to the LoRa board the cooking parameters
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data))
print("[MQTT Thread] Cooking parameters sent to LoRa board.")
print("[MQTT Thread] Message processing complete.")
mqtt_client.set_callback(on_mqtt_message) cookingState = cs
log = logging.log
UARTCommand = UC
UARTCommandType = UCT
# Status LED
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
# Hardware UART 2
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
# I2C Temperature Sensor
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 mqtt_background_thread(): # --- CALLBACKS ---
"""Background MQTT worker handling ALL socket operations safely.""" def on_cooking_state_change(state):
print("[Thread] Background MQTT worker started.") if status_led is None:
return
print(f"[Main] Cooking state changed to: {state.state}")
BLINK_INTERVAL_MS = 500
while True: from shared.sensors import RGBLED
try: if state.state == cookingState.CookingStates.IDLE:
print("[Thread] Attempting connection to MQTT broker...") status_led.color = RGBLED.OFF
mqtt_client.connect() status_led.blink_off()
print("[Thread] Connected! Subscribing to topic...") elif state.state == cookingState.CookingStates.COOKING:
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS) status_led.color = RGBLED.YELLOW
print("[Thread] Successfully subscribed. Setting up poller...") status_led.blink_off()
elif state.state == cookingState.CookingStates.STIRRING_REQUIRED:
poller = select.poll() status_led.color = RGBLED.ORANGE
poller.register(mqtt_client._client.sock, select.POLLIN) status_led.blink_on(BLINK_INTERVAL_MS)
elif state.state == cookingState.CookingStates.DONE:
last_check = time.time() status_led.color = RGBLED.GREEN
status_led.blink_off()
while True: elif state.state == cookingState.CookingStates.ALERT:
# 1. Process outbound messages queued by the main thread status_led.color = RGBLED.RED
while len(msg_queue) > 0: status_led.blink_on(BLINK_INTERVAL_MS)
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:
print("[Thread] Connection dropped or error encountered:", 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:
mqtt_client.close()
except Exception:
pass
time.sleep(5)
# UART
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
# Cooking Cycle
cooking_state = None
def on_received_cooking_state_update(new_state): def on_received_cooking_state_update(new_state):
global cooking_state global cooking_state
if cooking_state is None: if cooking_state is None:
print("[Main] No active cooking state to update.") print("[Main] No active cooking state to update.")
return return
if log:
log(f"[Main] Updating cooking state to: {new_state}") log(f"[Main] Updating cooking state to: {new_state}")
cooking_state.set_state(new_state) cooking_state.set_state(new_state)
# Status LED
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
def on_cooking_state_change(state):
print(f"[Main] Cooking state changed to: {state.state}")
# === STATUS LED UPDATE ===
BLINK_INTERVAL_MS = 500 # Blink every 500ms
if state.state == cookingState.CookingStates.IDLE:
status_led.color = RGBLED.OFF
status_led.blink_off()
if state.state == cookingState.CookingStates.COOKING:
status_led.color = RGBLED.YELLOW
status_led.blink_off()
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
status_led.color = RGBLED.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS)
if state.state == cookingState.CookingStates.DONE:
status_led.color = RGBLED.GREEN
status_led.blink_off()
if state.state == cookingState.CookingStates.ALERT:
status_led.color = RGBLED.RED
status_led.blink_on(BLINK_INTERVAL_MS)
# --- MAIN APPLICATION THREAD (Core 0) ---
print("[Main] Main execution path active.")
# Temperature sensor setup def on_mqtt_message(message):
temperature_sensor_i2c = I2C(0, scl=Pin(25, Pin.IN, Pin.PULL_UP), sda=Pin(26, Pin.IN, Pin.PULL_UP), freq=100000) global orchestrator_id, cooking_state
# Scan to verify the sensor is connected and detected print("[MQTT] Received message on topic:", message.get("topic"))
print("Scanning I2C bus...")
devices = temperature_sensor_i2c.scan()
if 0x5A in devices:
print("MLX90614 found at address 0x5A!")
else:
print("MLX90614 not found. Please check your wiring.")
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
# --- Launch background worker ---
_thread.start_new_thread(mqtt_background_thread, ())
time.sleep(2) # Give the thread a moment to initial connect
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
while True:
# MQTT HELLO sent every x seconds until we get a response from the orchestrator
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL):
print("[Main] Attempting to send initial hello to orchestrator...")
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID))
mqtt_hello_sent_timestamp = time.time()
pass
# 1. Listen for incoming UART serial packets from the WROOM board payload_data = None
while uart_device.any(): try:
command = uart_device.read_as_command() payload_data = json.loads(message["payload"])
if command: except Exception as e:
print(f"[Main] Received command from LoRa Board: {command.command_type}") print("[MQTT] Payload parsing warning:", e)
if command.command_type == UARTCommandType.COOKING_STATE_UPDATE:
# Handle cooking state update command topic = message.get("topic")
new_state = command.payload.get("state", None)
print(f"[Main] Cooking state update received: {new_state}") # 1. Orchestrator Hello Response
on_received_cooking_state_update(new_state) if (
else: topic == config.MQTT_TOPIC_HELLO
print(f"[Main] Unknown command type received: {command.command_type}") and payload_data
and payload_data.get("id_microwave") == DEVICE_ID
):
orchestrator_id = payload_data.get("id_orchestrator")
print("[MQTT] Hello response received from orchestrator:", orchestrator_id)
try:
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
print("[MQTT] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
except Exception as e:
print("[MQTT] Unsubscribe error:", e)
sys.print_exception(e)
# 2. Cooking Parameters / Sensor Request
elif (
topic == config.MQTT_TOPIC_COOKING
and payload_data
and payload_data.get("id_microwave") == DEVICE_ID
):
if "cook_time" not in payload_data:
print("[MQTT] Sensor data requested by orchestrator.")
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)
mqtt_client.publish(
config.MQTT_TOPIC_SENSOR, sensor_payload, qos=config.MQTT_QOS
)
else: else:
# Fallback to reading as a raw string if parsing fails print("[MQTT] Cooking parameters received:", payload_data)
raw_command = uart_device.read() if cookingState:
print(f"[Main] Received raw command from WiFi Board: {raw_command}") cooking_state = cookingState.CookingState(
cook_time=payload_data["cook_time"],
# 2. Example: Send data to the Heltec board every 5 seconds power_level=payload_data["power_level"],
# uart_device.send("Status Check: WiFi Active") target_temp=payload_data["target_temp"],
time.sleep(1) )
cooking_state.set_state_change_callback(on_cooking_state_change)
cooking_state.set_state(cookingState.CookingStates.IDLE)
if uart_device and UARTCommand:
uart_device.send_as_command(
UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)
)
print("[MQTT] Cooking parameters sent to LoRa board.")
mqtt_client.set_callback(on_mqtt_message)
async def connect_mqtt_async():
"""Connects to MQTT safely while memory is clean."""
global mqtt_connected
mqtt_connected = False
while True:
try:
print("[MQTT] Connecting to broker with TLS...")
gc.collect()
mqtt_client.connect()
print("[MQTT] Connected! Subscribing to topics...")
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
print("[MQTT] Subscribed successfully!")
mqtt_connected = True
return
except Exception as e:
print("[MQTT] Connection failed:", e)
sys.print_exception(e)
try:
mqtt_client.close()
except Exception:
pass
print("[MQTT] Retrying connection in 5 seconds...")
await asyncio.sleep(5)
# --- CONCURRENT ASYNC TASKS ---
async def mqtt_poll_task():
global mqtt_connected
last_ping = time.time()
while True:
if mqtt_connected:
try:
mqtt_client.poll()
now = time.time()
if now - last_ping >= 15:
if mqtt_client._client:
mqtt_client._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
while True:
if mqtt_connected and orchestrator_id is None:
print("[Hello Task] Sending initial hello to orchestrator...")
try:
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello(DEVICE_ID),
qos=config.MQTT_QOS,
)
except OSError as e:
print("[Hello Task] Hello publish failed:", e)
mqtt_connected = False
await connect_mqtt_async()
await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
async def uart_task():
while True:
if uart_device is not None:
while uart_device.any():
command = uart_device.read_as_command()
if command:
print(f"[UART Task] Received command: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_STATE_UPDATE:
new_state = command.payload.get("state", None)
print(f"[UART Task] Cooking state update: {new_state}")
on_received_cooking_state_update(new_state)
else:
print(f"[UART Task] Unknown command type: {command.command_type}")
else:
raw_command = uart_device.read()
print(f"[UART Task] Received raw command: {raw_command}")
await asyncio.sleep_ms(20)
async def memory_cleanup_task():
while True:
gc.collect()
await asyncio.sleep(10)
# --- MAIN ENTRY POINT ---
async def main():
print("[Main] Starting application...")
# STEP 1: Connect MQTT FIRST (while RAM is unfragmented)
await connect_mqtt_async()
# STEP 2: Initialize Hardware & Secondary Modules AFTER connection
init_hardware()
# STEP 3: Launch tasks
asyncio.create_task(mqtt_poll_task())
asyncio.create_task(orchestrator_hello_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.")
+308 -57
View File
@@ -1,5 +1,6 @@
import sys import sys
import time import time
import random
IS_MICROPYTHON = sys.implementation.name == 'micropython' IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
from machine import Pin, SPI from machine import Pin, SPI
import ubinascii import ubinascii
import ujson as json import ujson as json
else:
import threading
import serial
import json
# --- BASE RELIABLE LORA DEVICE ---
class BaseLoraDevice:
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
def __init__(self):
self.processed_msg_ids = set()
self.received_acks = set()
self.pending_rx_queue = []
self.default_group = 2
def _generate_msg_id(self):
return random.getrandbits(16)
def _send_ack(self, ack_id):
"""Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON:
time.sleep_ms(10)
else:
time.sleep(0.01)
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload)
def _process_incoming_packet(self, packet):
"""Internal packet processor: handles ACKs and deduplication."""
if not packet or packet.get("raw"):
return packet
data = packet.get("data")
if isinstance(data, dict):
# 1. Handle incoming ACK response
if data.get("_type") == "_ack":
ack_id = data.get("_ack_id")
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
if ack_id is not None:
self.received_acks.add(ack_id)
if len(self.received_acks) > 100:
self.received_acks.clear()
return None # Drop internal protocol message from user queue
# 2. Handle incoming command expecting an ACK
msg_id = data.get("_msg_id")
if msg_id is not None:
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
self._send_ack(msg_id)
if msg_id in self.processed_msg_ids:
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
return None # Discard duplicate retry
self.processed_msg_ids.add(msg_id)
if len(self.processed_msg_ids) > 100:
self.processed_msg_ids.clear()
return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
"""Sends a payload and retries until an ACK is received or max retries are reached."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
print(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
for attempt in range(max_retries):
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
start_time = time.time()
while (time.time() - start_time) < ack_timeout:
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
packet = self.receive_packet(timeout_ms=500)
if packet:
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
finally:
if lock: lock.release()
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
return True
finally:
if lock: lock.release()
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
return False
def receive_reliable(self, timeout_ms=1000):
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
if len(self.pending_rx_queue) > 0:
return self.pending_rx_queue.pop(0)
start_time = time.time()
timeout_s = timeout_ms / 1000.0
while True:
elapsed = time.time() - start_time
remaining_ms = int((timeout_s - elapsed) * 1000)
if remaining_ms <= 0:
break
poll_time = max(50, min(remaining_ms, 300))
packet = self.receive_packet(timeout_ms=poll_time)
if packet:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
return filtered_packet
return None
if IS_MICROPYTHON:
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) --- # --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
class LoraHardwareSPI: class LoraHardwareSPI(BaseLoraDevice):
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13): def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
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:
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
group = self.default_group
payload_brute = data.strip(b'\x00 \r\n\t')
elif len(data) > 1:
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t')
else:
return None
try: try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t') text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
return None return None
else: else:
import threading
import serial
import json
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) --- # --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
class LoraSerialAT: class LoraSerialAT(BaseLoraDevice):
def __init__(self, port): def __init__(self, port):
super().__init__()
self.port = port self.port = port
self.ser = serial.Serial( self.ser = serial.Serial(
port=self.port, port=self.port,
@@ -95,37 +297,60 @@ else:
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
self.ser.reset_output_buffer() self.ser.reset_output_buffer()
self.lock = threading.Lock() self.lock = threading.Lock()
def configure(self, **kwargs):
pass # Initial configuration
self.configure(freq=868.1, sf=7, bw=125)
def send(self, payload): def _send_at_cmd(self, cmd, wait_time=0.15):
"""Encode automatiquement la payload en HEX pour l'envoi via la clé.""" """Helper to send AT command and purge response buffer."""
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
resp = ""
while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore')
return resp
def configure(self, freq=868.1, sf=7, bw=125):
"""Configures LA66 frequency, SF, BW, SyncWord, CRC, and continuous RX mode."""
with self.lock: with self.lock:
freq_hz = int(freq * 1000000)
bw_code = 0 if bw == 125 else 1
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
# Fallback standalone commands
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
self.ser.reset_input_buffer()
def send(self, payload, group=None):
"""Encodes payload into HEX AT command and re-enables continuous RX."""
with self.lock:
if group is None:
group = self.default_group
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
hex_payload = payload.hex() paquet_physique = bytes([group]) + payload
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n" cmd = f"AT+PSEND={hex_payload}"
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3") resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
self.ser.write(cmd.encode('utf-8')) print(f"[RPi LoRa Serial] AT+PSEND response: {resp.strip().replace(chr(10), ' | ')}")
time.sleep(0.2) # Re-enable continuous receive mode after transmission completes
response = "" self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
start_wait = time.time()
while (time.time() - start_wait) < 1.5:
if self.ser.in_waiting > 0:
response += self.ser.readline().decode('utf-8', errors='ignore')
time.sleep(0.05)
# print(f"[RPI LA66 TX STATUS] :\n{response.strip()}")
def receive_packet(self, timeout_ms=5000): 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 +361,38 @@ else:
if line: if line:
payload_bytes = None payload_bytes = None
if "(HEX:)" in line: # Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line:
parts = line.split("+RECV:")[1].strip().split(",")
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
try: payload_bytes = bytes.fromhex(hex_str)
except ValueError: pass
elif "+RCV=" in line:
parts = line.split("+RCV=")[1].strip().split(",")
if len(parts) >= 4:
try: payload_bytes = bytes.fromhex(parts[3].strip())
except ValueError: pass
elif "+DRX:" in line:
parts = line.split("+DRX:")[1].strip().split(",")
if len(parts) >= 2:
try: payload_bytes = bytes.fromhex(parts[1].strip())
except ValueError: pass
elif "(HEX:)" in line:
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "") hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
try: try: payload_bytes = bytes.fromhex(hex_part)
payload_bytes = bytes.fromhex(hex_part) except ValueError: pass
except ValueError:
pass
elif "Data:" in line: elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8') payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
if payload_bytes and len(payload_bytes) > 1: if payload_bytes and len(payload_bytes) > 0:
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 +425,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"
+96 -35
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,29 @@ 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:
pass try:
self._client = None 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 __enter__(self): def __enter__(self):
self.connect() self.connect()