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')
+250 -185
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
# Try and parse the payload as json, but if it fails, just print the raw payload
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: def init_hardware():
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"]) """Initializes hardware peripherals AFTER MQTT TLS has reserved its memory."""
global orchestrator_id global status_led, uart_device, mlx_temperature_sensor
orchestrator_id = payload_data["id_orchestrator"] global cookingState, log, UARTCommand, UARTCommandType
# Unsubscribe from the hello topic since we got a response
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
# Handle cooking messages print("[Main] Initializing hardware peripherals...")
elif message['topic'] == config.MQTT_TOPIC_COOKING and payload_data and payload_data["id_microwave"] == DEVICE_ID:
# Cooking sensors init request # Deferred module imports
if not "cook_time" in payload_data: from shared import get_uart, cookingState as cs, logging
print("[MQTT Thread] Cooking sensors init received from the orchestrator") from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
obj_temp = mlx_temperature_sensor.read_object_temp() from shared.sensors import RGBLED
amb_temp = mlx_temperature_sensor.read_ambient_temp() from sensors import temperature_sensor
queue_publish(config.MQTT_TOPIC_SENSOR, payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp))
# Received cooking parameters from the orchestrator cookingState = cs
else: log = logging.log
print("[MQTT Thread] Cooking parameters received from the orchestrator:", payload_data) UARTCommand = UC
global cooking_state UARTCommandType = UCT
cooking_state = cookingState.CookingState(
cook_time=payload_data["cook_time"], # Status LED
power_level=payload_data["power_level"], status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
target_temp=payload_data["target_temp"]
# 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,
) )
cooking_state.set_state_change_callback(on_cooking_state_change) devices = temperature_sensor_i2c.scan()
cooking_state.set_state(cookingState.CookingStates.IDLE) # Set initial state to IDLE if 0x5A in devices:
# Send to the LoRa board the cooking parameters print("[Main] MLX90614 found at address 0x5A!")
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)) else:
print("[MQTT Thread] Cooking parameters sent to LoRa board.") print("[Main] MLX90614 not found on I2C bus.")
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
print("[MQTT Thread] Message processing complete.")
mqtt_client.set_callback(on_mqtt_message)
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:
status_led.color = RGBLED.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS)
elif state.state == cookingState.CookingStates.DONE:
status_led.color = RGBLED.GREEN
status_led.blink_off()
elif state.state == cookingState.CookingStates.ALERT:
status_led.color = RGBLED.RED
status_led.blink_on(BLINK_INTERVAL_MS)
poller = select.poll()
poller.register(mqtt_client._client.sock, select.POLLIN)
last_check = time.time()
while True:
# 1. Process outbound messages queued by the main thread
while len(msg_queue) > 0:
with queue_lock:
topic, payload = msg_queue.pop(0)
print(f"[Thread] Safely publishing queued message to {topic}...")
mqtt_client.publish(topic, payload, qos=config.MQTT_QOS)
# 2. Check for incoming messages (non-blocking poll)
events = poller.poll(200)
if events:
mqtt_client.wait()
# 3. Handle Keepalive tracking manually
if time.time() - last_check >= 15:
# print("[Thread] Sending keepalive ping...")
mqtt_client._client.ping()
last_check = time.time()
# Small breathe room for the CPU core
time.sleep_ms(50)
except Exception as e:
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 === def on_mqtt_message(message):
BLINK_INTERVAL_MS = 500 # Blink every 500ms global orchestrator_id, cooking_state
print("[MQTT] Received message on topic:", message.get("topic"))
if state.state == cookingState.CookingStates.IDLE: payload_data = None
status_led.color = RGBLED.OFF try:
status_led.blink_off() payload_data = json.loads(message["payload"])
if state.state == cookingState.CookingStates.COOKING: except Exception as e:
status_led.color = RGBLED.YELLOW print("[MQTT] Payload parsing warning:", e)
status_led.blink_off()
if state.state == cookingState.CookingStates.STIRRING_REQUIRED: topic = message.get("topic")
status_led.color = RGBLED.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS) # 1. Orchestrator Hello Response
if state.state == cookingState.CookingStates.DONE: if (
status_led.color = RGBLED.GREEN topic == config.MQTT_TOPIC_HELLO
status_led.blink_off() and payload_data
if state.state == cookingState.CookingStates.ALERT: and payload_data.get("id_microwave") == DEVICE_ID
status_led.color = RGBLED.RED ):
status_led.blink_on(BLINK_INTERVAL_MS) 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:
print("[MQTT] Cooking parameters received:", payload_data)
if cookingState:
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)
if uart_device and UARTCommand:
uart_device.send_as_command(
UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)
)
print("[MQTT] Cooking parameters sent to LoRa board.")
# --- MAIN APPLICATION THREAD (Core 0) --- mqtt_client.set_callback(on_mqtt_message)
print("[Main] Main execution path active.")
# Temperature sensor setup
temperature_sensor_i2c = I2C(0, scl=Pin(25, Pin.IN, Pin.PULL_UP), sda=Pin(26, Pin.IN, Pin.PULL_UP), freq=100000)
# Scan to verify the sensor is connected and detected
print("Scanning I2C bus...")
devices = temperature_sensor_i2c.scan()
if 0x5A in devices:
print("MLX90614 found at address 0x5A!")
else:
print("MLX90614 not found. Please check your wiring.")
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
# --- Launch background worker --- async def connect_mqtt_async():
_thread.start_new_thread(mqtt_background_thread, ()) """Connects to MQTT safely while memory is clean."""
time.sleep(2) # Give the thread a moment to initial connect global mqtt_connected
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL mqtt_connected = False
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
while True: while True:
# MQTT HELLO sent every x seconds until we get a response from the orchestrator try:
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL): print("[MQTT] Connecting to broker with TLS...")
print("[Main] Attempting to send initial hello to orchestrator...") gc.collect()
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID)) mqtt_client.connect()
mqtt_hello_sent_timestamp = time.time() 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 pass
print("[MQTT] Retrying connection in 5 seconds...")
await asyncio.sleep(5)
# 1. Listen for incoming UART serial packets from the WROOM board
# --- 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(): while uart_device.any():
command = uart_device.read_as_command() command = uart_device.read_as_command()
if command: if command:
print(f"[Main] Received command from LoRa Board: {command.command_type}") print(f"[UART Task] Received command: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_STATE_UPDATE: if command.command_type == UARTCommandType.COOKING_STATE_UPDATE:
# Handle cooking state update command
new_state = command.payload.get("state", None) new_state = command.payload.get("state", None)
print(f"[Main] Cooking state update received: {new_state}") print(f"[UART Task] Cooking state update: {new_state}")
on_received_cooking_state_update(new_state) on_received_cooking_state_update(new_state)
else: else:
print(f"[Main] Unknown command type received: {command.command_type}") print(f"[UART Task] Unknown command type: {command.command_type}")
else: else:
# Fallback to reading as a raw string if parsing fails
raw_command = uart_device.read() raw_command = uart_device.read()
print(f"[Main] Received raw command from WiFi Board: {raw_command}") print(f"[UART Task] Received raw command: {raw_command}")
# 2. Example: Send data to the Heltec board every 5 seconds await asyncio.sleep_ms(20)
# uart_device.send("Status Check: WiFi Active")
time.sleep(1)
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.")
+294 -43
View File
@@ -1,5 +1,6 @@
import sys import sys
import time import time
import random
IS_MICROPYTHON = sys.implementation.name == 'micropython' IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
from machine import Pin, SPI from machine import Pin, SPI
import ubinascii import ubinascii
import ujson as json import ujson as json
else:
import threading
import serial
import json
# --- BASE RELIABLE LORA DEVICE ---
class BaseLoraDevice:
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
def __init__(self):
self.processed_msg_ids = set()
self.received_acks = set()
self.pending_rx_queue = []
self.default_group = 2
def _generate_msg_id(self):
return random.getrandbits(16)
def _send_ack(self, ack_id):
"""Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON:
time.sleep_ms(10)
else:
time.sleep(0.01)
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload)
def _process_incoming_packet(self, packet):
"""Internal packet processor: handles ACKs and deduplication."""
if not packet or packet.get("raw"):
return packet
data = packet.get("data")
if isinstance(data, dict):
# 1. Handle incoming ACK response
if data.get("_type") == "_ack":
ack_id = data.get("_ack_id")
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
if ack_id is not None:
self.received_acks.add(ack_id)
if len(self.received_acks) > 100:
self.received_acks.clear()
return None # Drop internal protocol message from user queue
# 2. Handle incoming command expecting an ACK
msg_id = data.get("_msg_id")
if msg_id is not None:
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
self._send_ack(msg_id)
if msg_id in self.processed_msg_ids:
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
return None # Discard duplicate retry
self.processed_msg_ids.add(msg_id)
if len(self.processed_msg_ids) > 100:
self.processed_msg_ids.clear()
return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
"""Sends a payload and retries until an ACK is received or max retries are reached."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
print(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
for attempt in range(max_retries):
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
start_time = time.time()
while (time.time() - start_time) < ack_timeout:
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
packet = self.receive_packet(timeout_ms=500)
if packet:
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
finally:
if lock: lock.release()
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
return True
finally:
if lock: lock.release()
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
return False
def receive_reliable(self, timeout_ms=1000):
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
if len(self.pending_rx_queue) > 0:
return self.pending_rx_queue.pop(0)
start_time = time.time()
timeout_s = timeout_ms / 1000.0
while True:
elapsed = time.time() - start_time
remaining_ms = int((timeout_s - elapsed) * 1000)
if remaining_ms <= 0:
break
poll_time = max(50, min(remaining_ms, 300))
packet = self.receive_packet(timeout_ms=poll_time)
if packet:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
return filtered_packet
return None
if IS_MICROPYTHON:
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) --- # --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
class LoraHardwareSPI: class LoraHardwareSPI(BaseLoraDevice):
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13): def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
super().__init__()
self._pins = {
"spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
"cs": cs, "irq": irq, "rst": rst, "gpio": gpio
}
self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
self.lock = _thread.allocate_lock()
self.lora = None
self.reset_hardware()
def reset_hardware(self):
"""Resets SX1262 hardware and recreates driver instance."""
with self.lock:
try:
irq_pin = Pin(self._pins["irq"], Pin.IN)
irq_pin.irq(handler=None)
except Exception:
pass
try:
rst_pin = Pin(self._pins["rst"], Pin.OUT)
rst_pin.value(0)
time.sleep_ms(30)
rst_pin.value(1)
time.sleep_ms(50)
except Exception:
pass
self.lora = None
time.sleep_ms(50)
try:
from sx1262 import SX1262 from sx1262 import SX1262
self.lora = SX1262( new_instance = SX1262(**self._pins)
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso, new_instance.begin(
cs=cs, irq=irq, rst=rst, gpio=gpio freq=self._cfg["freq"], bw=self._cfg["bw"], sf=self._cfg["sf"],
cr=self._cfg["cr"], power=self._cfg["power"],
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
) )
self.default_group = 2 # On définit le groupe par défaut ici # SyncWord 0x12 = Decimal 18
self.lock = _thread.allocate_lock() # Création du verrou new_instance.setSyncWord(0x12)
self.lora = new_instance
except Exception as e:
print(f"[LoRa SPI] Initialization error: {e}")
def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14): def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14):
self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
if self.lora is None:
self.reset_hardware()
else:
with self.lock:
try:
self.lora.begin( self.lora.begin(
freq=freq, bw=bw, sf=sf, cr=cr, power=power, freq=freq, bw=bw, sf=sf, cr=cr, power=power,
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
) )
self.lora.setSyncWord(0x14) self.lora.setSyncWord(0x12)
except Exception:
self.reset_hardware()
def send(self, payload, group=None): def send(self, payload, group=None):
"""Encode la payload en JSON si nécessaire, et injecte automatiquement l'octet de groupe.""" """Encodes payload into JSON and prepends group byte."""
with self.lock: with self.lock:
if self.lora is None:
return
if group is None: if group is None:
group = self.default_group group = self.default_group
# Si c'est un dictionnaire ou une liste, on le convertit en JSON textuel
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
# Insertion automatique de l'octet de groupe au tout début de la trame physique
paquet_physique = bytes([group]) + payload paquet_physique = bytes([group]) + payload
try:
self.lora.send(paquet_physique) self.lora.send(paquet_physique)
except Exception as e:
print(f"[LoRa SPI] Send error: {e}")
def receive_packet(self, timeout_ms=1000): def receive_packet(self, timeout_ms=500):
"""Écoute, nettoie, extrait le groupe, gère le HEX et parse le JSON.""" """Listens on SPI bus with auto-detection for JSON vs. Grouped headers."""
with self.lock: with self.lock:
if self.lora is None:
return None
try:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms) data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
if state == 0 and len(data) > 1: except Exception as e:
print(f"[LoRa SPI] Recv error caught: {e}")
return None
if state == 0 and data is not None and len(data) > 0:
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
group = self.default_group
payload_brute = data.strip(b'\x00 \r\n\t')
elif len(data) > 1:
group = data[0] group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t') payload_brute = data[1:].strip(b'\x00 \r\n\t')
else:
return None
try: try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t') text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
return None return None
else: else:
import threading
import serial
import json
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) --- # --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
class LoraSerialAT: class LoraSerialAT(BaseLoraDevice):
def __init__(self, port): def __init__(self, port):
super().__init__()
self.port = port self.port = port
self.ser = serial.Serial( self.ser = serial.Serial(
port=self.port, port=self.port,
@@ -95,37 +297,60 @@ else:
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
self.ser.reset_output_buffer() self.ser.reset_output_buffer()
self.lock = threading.Lock() self.lock = threading.Lock()
def configure(self, **kwargs):
pass
def send(self, payload): # Initial configuration
"""Encode automatiquement la payload en HEX pour l'envoi via la clé.""" self.configure(freq=868.1, sf=7, bw=125)
def _send_at_cmd(self, cmd, wait_time=0.15):
"""Helper to send AT command and purge response buffer."""
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
resp = ""
while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore')
return resp
def configure(self, freq=868.1, sf=7, bw=125):
"""Configures LA66 frequency, SF, BW, SyncWord, CRC, and continuous RX mode."""
with self.lock: with self.lock:
freq_hz = int(freq * 1000000)
bw_code = 0 if bw == 125 else 1
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
# Fallback standalone commands
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
self.ser.reset_input_buffer()
def send(self, payload, group=None):
"""Encodes payload into HEX AT command and re-enables continuous RX."""
with self.lock:
if group is None:
group = self.default_group
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
hex_payload = payload.hex() paquet_physique = bytes([group]) + payload
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n" cmd = f"AT+PSEND={hex_payload}"
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3") resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
self.ser.write(cmd.encode('utf-8')) print(f"[RPi LoRa Serial] AT+PSEND response: {resp.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=500):
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
def receive_packet(self, timeout_ms=5000):
with self.lock: with self.lock:
start_time = time.time() start_time = time.time()
timeout_s = timeout_ms / 1000.0 timeout_s = timeout_ms / 1000.0
@@ -136,18 +361,38 @@ else:
if line: if line:
payload_bytes = None payload_bytes = None
if "(HEX:)" in line: # Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line:
parts = line.split("+RECV:")[1].strip().split(",")
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
try: payload_bytes = bytes.fromhex(hex_str)
except ValueError: pass
elif "+RCV=" in line:
parts = line.split("+RCV=")[1].strip().split(",")
if len(parts) >= 4:
try: payload_bytes = bytes.fromhex(parts[3].strip())
except ValueError: pass
elif "+DRX:" in line:
parts = line.split("+DRX:")[1].strip().split(",")
if len(parts) >= 2:
try: payload_bytes = bytes.fromhex(parts[1].strip())
except ValueError: pass
elif "(HEX:)" in line:
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "") hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
try: try: payload_bytes = bytes.fromhex(hex_part)
payload_bytes = bytes.fromhex(hex_part) except ValueError: pass
except ValueError:
pass
elif "Data:" in line: elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8') payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
if payload_bytes and len(payload_bytes) > 1: if payload_bytes and len(payload_bytes) > 0:
if payload_bytes[0] in (0x7B, 0x5B):
group = self.default_group
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
elif len(payload_bytes) > 1:
group = payload_bytes[0] group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t') payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
else:
continue
try: try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t') text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
@@ -181,3 +426,9 @@ def get_lora_device(port_or_pins=None):
else: else:
port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
return LoraSerialAT(port) return LoraSerialAT(port)
class LoraCommands:
PING = "ping"
COOKING_STATE_UPDATE = "cooking_state_update"
TOGGLE_PAUSE = "toggle_pause"
+82 -21
View File
@@ -11,13 +11,10 @@ try:
except ImportError: except ImportError:
try: try:
from umqtt.simple import MQTTClient as _MQTTClient from umqtt.simple import MQTTClient as _MQTTClient
import _thread
import gc
BACKEND_NAME = "umqtt.simple" BACKEND_NAME = "umqtt.simple"
IS_MICROPYTHON = True IS_MICROPYTHON = True
# except ImportError:
# try:
# from umqtt.robust import MQTTClient as _MQTTClient
# BACKEND_NAME = "umqtt.robust"
# IS_MICROPYTHON = True
except ImportError as exc: except ImportError as exc:
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
@@ -79,6 +76,11 @@ class BrokerClient:
self._client = None self._client = None
self._callback = None self._callback = None
self._messages = [] self._messages = []
self._cadata = None # Cache cert bytes to prevent heap fragmentation
# Thread safety lock for MicroPython socket reads/writes
if IS_MICROPYTHON:
self._lock = _thread.allocate_lock()
def set_callback(self, callback): def set_callback(self, callback):
self._callback = callback self._callback = callback
@@ -104,17 +106,25 @@ class BrokerClient:
return self._client return self._client
if IS_MICROPYTHON: if IS_MICROPYTHON:
gc.collect() # Clean Python heap before importing/allocating SSL
import ssl import ssl
ssl_params = self.ssl_params ssl_params = self.ssl_params
if self.use_tls and ssl_params is None: if self.use_tls and ssl_params is None:
# MicroPython uses context-less structures. # OPTION A: If broker uses 'require_certificate false' and self-signed certs:
# If your CA is self-signed, validation can fail without a valid hostname match. # Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
ssl_params = { ssl_params = {
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit "cert_reqs": ssl.CERT_NONE,
"cadata": _read_file_bytes(self.cafile) "server_hostname": self.host
} }
# OPTION B: If strict CA validation IS required, load cadata ONLY with CERT_REQUIRED:
# ssl_params = {
# "cert_reqs": ssl.CERT_REQUIRED,
# "cadata": _read_file_bytes(self.cafile),
# "server_hostname": self.host
# }
client = _MQTTClient( client = _MQTTClient(
self.client_id or "smartWave-client", self.client_id or "smartWave-client",
self.host, self.host,
@@ -150,18 +160,34 @@ class BrokerClient:
return self._client return self._client
def connect(self): def connect(self):
client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
gc.collect() # Force C & Python memory cleanup right before TLS handshake
if self._client is not None:
self.close()
client = self.open()
try:
if IS_MICROPYTHON:
gc.collect() # Sweep memory right before umqtt calls ssl.wrap_socket()
with self._lock:
client.connect() client.connect()
return client return client
client.connect(self.host, self.port, self.keepalive) client.connect(self.host, self.port, self.keepalive)
return client return client
except Exception as e:
print("MQTT connection failed, closing client and releasing memory.")
print("Exception:", e)
self.close()
raise
def publish(self, topic, payload, qos=2, retain=False): def publish(self, topic, payload, qos=2, retain=False):
client = self.open() client = self.open()
payload_bytes = _ensure_bytes(payload) payload_bytes = _ensure_bytes(payload)
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
return client.publish(topic, payload_bytes, retain=retain, qos=qos) return client.publish(topic, payload_bytes, retain=retain, qos=qos)
if isinstance(topic, bytes): if isinstance(topic, bytes):
@@ -172,6 +198,7 @@ class BrokerClient:
def subscribe(self, topic, qos=2): def subscribe(self, topic, qos=2):
client = self.open() client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
client.set_callback(self._on_micropython_message) client.set_callback(self._on_micropython_message)
return client.subscribe(topic, qos=qos) return client.subscribe(topic, qos=qos)
@@ -184,27 +211,43 @@ class BrokerClient:
client = self.open() client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
import struct import struct
# Ensure the topic is bytes for writing to the socket import time
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8') topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
# 1. Build the MQTT unsubscribe packet header # 1. Increment and lock the PID for THIS specific request
client.pid = (client.pid % 65535) + 1
sent_pid = client.pid # <-- Store local copy
# 2. Construct UNSUBSCRIBE packet
rem_len = 2 + 2 + len(topic_bytes)
pkt = bytearray(b"\xa2\0\0\0") pkt = bytearray(b"\xa2\0\0\0")
client.pid += 1 struct.pack_into("!BH", pkt, 1, rem_len, sent_pid)
# Packet length is: 2 bytes (PID) + 2 bytes (topic length indicator) + topic string length # 3. Write packet to socket
struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid)
# 2. Write the packet to the socket
client.sock.write(pkt) client.sock.write(pkt)
client._send_str(topic_bytes) client._send_str(topic_bytes)
# 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker # 4. Wait for UNSUBACK (0xB0)
while True: start = time.time()
while time.time() - start < 3:
op = client.wait_msg() op = client.wait_msg()
if op == 0xB0: if op == 0xB0:
resp = client.sock.read(3) resp = bytearray(3)
assert resp[1] == pkt[2] and resp[2] == pkt[3] read_bytes = 0
while read_bytes < 3:
chunk = client.sock.read(3 - read_bytes)
if chunk:
resp[read_bytes:read_bytes + len(chunk)] = chunk
read_bytes += len(chunk)
else:
time.sleep_ms(10)
# Compare against sent_pid instead of client.pid
resp_pid = (resp[1] << 8) | resp[2]
if resp_pid != sent_pid:
print(f"[MQTT] UNSUBACK PID mismatch (expected {sent_pid}, got {resp_pid})")
return client return client
return client return client
if isinstance(topic, bytes): if isinstance(topic, bytes):
@@ -219,6 +262,7 @@ class BrokerClient:
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
return self._client.check_msg() return self._client.check_msg()
return self._client.loop(timeout=timeout) return self._client.loop(timeout=timeout)
@@ -226,6 +270,7 @@ class BrokerClient:
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
with self._lock:
return self._client.wait_msg() return self._client.wait_msg()
return self._client.loop_forever() return self._client.loop_forever()
@@ -235,12 +280,28 @@ class BrokerClient:
return self._messages.pop(0) return self._messages.pop(0)
def close(self): def close(self):
"""Safely clean up socket context without causing ESP32 C panics."""
if self._client is None: if self._client is None:
return return
if IS_MICROPYTHON:
with self._lock:
try:
if hasattr(self._client, "sock") and self._client.sock:
self._client.sock.close()
except Exception:
pass
finally:
if hasattr(self._client, "sock"):
self._client.sock = None
self._client = None
gc.collect() # Immediately reclaim freed socket & mbedTLS RAM
else:
try: try:
self._client.disconnect() self._client.disconnect()
except Exception: except Exception:
pass pass
finally:
self._client = None self._client = None
def __enter__(self): def __enter__(self):