Better LoRa

This commit is contained in:
2026-08-16 16:28:58 +02:00
parent 9b0ecf944a
commit 01eccf47c6
3 changed files with 141 additions and 123 deletions
+30 -32
View File
@@ -1,8 +1,6 @@
import gc import gc
import sys import sys
import time import time
import _thread
from lib.microwaveScreen import MicrowaveScreen
import uasyncio as asyncio import uasyncio as asyncio
from machine import Pin, SoftI2C from machine import Pin, SoftI2C
import ssd1306 import ssd1306
@@ -19,6 +17,7 @@ from shared.logging import log
from shared.lora_device import LoraCommands from shared.lora_device import LoraCommands
from shared.alerts import Alert, AlertType, AlertManager from shared.alerts import Alert, AlertType, AlertManager
from shared.microwave_state import MicrowaveState from shared.microwave_state import MicrowaveState
from lib.microwaveScreen import MicrowaveScreen
# --- READ DEVICE ID --- # --- READ DEVICE ID ---
try: try:
@@ -112,10 +111,9 @@ def init_hardware():
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})") print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
# --- ASYNC LORA TASK (REPLACES _THREAD) ---
# --- DEDICATED LORA HARDWARE THREAD --- async def lora_rx_and_heartbeat_task():
def lora_hardware_thread(): """Replaces the hardware OS thread with a non-blocking async task to avoid SPI collisions."""
"""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:
@@ -124,19 +122,22 @@ def lora_hardware_thread():
# 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
log("\n[LoRa Thread] Sending Heartbeat...") log("\n[LoRa Task] Sending Heartbeat...")
if lora: if lora:
lora.send(PING_PAYLOAD) lora.send(PING_PAYLOAD)
# CRITICAL: Force the radio chip out of Standby mode and back into RX mode
if hasattr(lora, 'receive'):
lora.receive()
# 2. Blocking 300ms RX listen window (keeps radio actively listening) # 2. Listen for incoming packets (Must be longer than LoRa Time-on-Air)
if lora: if lora:
paquet = lora.receive_reliable(timeout_ms=300) paquet = lora.receive_reliable(timeout_ms=350)
if paquet is not None: if paquet is not None:
log(f"[LoRa Thread] New Packet Received: {paquet}") log(f"[LoRa Task] New Packet Received: {paquet}")
data_queue.put(paquet) data_queue.put(paquet)
time.sleep_ms(10) # Give control back to event loop
await asyncio.sleep_ms(10)
# --- COOKING STATE CALLBACKS --- # --- COOKING STATE CALLBACKS ---
def cooking_state_temperature_provider(): def cooking_state_temperature_provider():
@@ -228,7 +229,7 @@ def update_screen():
async def uart_polling_task(): async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board.""" """Polls UART for incoming messages from the WiFi board."""
global cooking_state, cooking_start_time, temperature_asked global cooking_state, cooking_start_time, temperature_asked, microwave_state
while True: while True:
if uart_device and uart_device.any(): if uart_device and uart_device.any():
@@ -256,6 +257,7 @@ async def uart_polling_task():
await asyncio.sleep_ms(200) await asyncio.sleep_ms(200)
cooking_state_on_state_change(cooking_state) cooking_state_on_state_change(cooking_state)
microwave_state = MicrowaveState.COOKING
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE: elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
try: try:
# Check if payload is already a dict or needs JSON decoding # Check if payload is already a dict or needs JSON decoding
@@ -282,40 +284,43 @@ async def lora_process_task():
if paquet and not paquet.get("raw"): if paquet and not paquet.get("raw"):
data = paquet.get("data", {}) data = paquet.get("data", {})
# RECIPIENT FILTERING: Drop packets not addressed to this ESP32 or ALL
target = data.get("target_id") or data.get("recipient")
if target and target not in [DEVICE_ID, "ALL", "BROADCAST"]:
log(f"[LoRa Process] Ignoring packet intended for {target}")
continue
if "action" in data: if "action" in data:
if data["action"] == LoraCommands.TOGGLE_PAUSE: if data["action"] == LoraCommands.TOGGLE_PAUSE:
if cooking_state is not None: if cooking_state is not None:
if cooking_state.state == cookingState.CookingStates.DONE: if cooking_state.state == cookingState.CookingStates.DONE:
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.") print("[LoRa Process] Cooking is done. Resetting microwave for next session.")
cooking_state.set_state(cookingState.CookingStates.IDLE) cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20) await asyncio.sleep_ms(20)
cooking_state = None cooking_state = None
else: else:
cooking_state.toggle_pause() cooking_state.toggle_pause()
if cooking_state.paused: print(f"[LoRa Process] Cooking paused state toggled to {cooking_state.paused}")
print("[LoRa Process] Cooking paused via orchestrator command.")
else:
print("[LoRa Process] Cooking resumed via orchestrator command.")
else: else:
log("[LoRa Process] No active cooking state to toggle pause/resume.") log("[LoRa Process] No active cooking state to toggle pause/resume.")
elif data["action"] == LoraCommands.TOGGLE_DEFROST: elif data["action"] == LoraCommands.TOGGLE_DEFROST:
print("[LoRa Process] Toggling defrost mode via orchestrator command.") print("[LoRa Process] Toggling defrost mode via orchestrator command.")
defrost_mode = data["defrost_state"] defrost_mode = data.get("defrost_state", False)
update_screen() update_screen()
elif data["action"] == LoraCommands.NEW_ALERT: elif data["action"] == LoraCommands.NEW_ALERT:
alert = Alert(data.get("alert_type"), data.get("message", "Unsafe area")) alert = Alert(data.get("alert_type"), data.get("message", "Unsafe area"))
alert.timestamp = data.get("timestamp", time.time()) alert.timestamp = data.get("timestamp", time.time())
print(f"[LoRa Process] New alert received via orchestrator: {alert.to_dict()}") print(f"[LoRa Process] New alert received via orchestrator: {alert.to_dict()}")
alert_manager.add_alert(alert) alert_manager.add_alert(alert)
elif data["action"] == LoraCommands.MICROVAVE_STATE_UPDATE: elif data["action"] == LoraCommands.MICROVAVE_STATE_UPDATE:
new_state = data.get("new_microwave_state") new_state = data.get("new_microwave_state")
if new_state: if new_state:
print(f"[LoRa Process] Microwave state update received: {new_state}") print(f"[LoRa Process] Microwave state update received: {new_state}")
microwave_state = new_state microwave_state = new_state
update_screen() update_screen()
else:
print("[LoRa Process] Received microwave state update with no state specified.")
await asyncio.sleep_ms(100) await asyncio.sleep_ms(100)
@@ -351,7 +356,7 @@ async def memory_cleanup_task():
# --- BOOTSTRAP --- # --- BOOTSTRAP ---
async def main(): async def main():
global cooking_state, defrost_mode, microwave_screen, alert_manager global alert_manager
print("[Main] Starting application...") print("[Main] Starting application...")
init_hardware() init_hardware()
@@ -359,15 +364,8 @@ async def main():
alert_manager = AlertManager() alert_manager = AlertManager()
alert_manager.set_on_alert_callback(on_new_alert) alert_manager.set_on_alert_callback(on_new_alert)
# Launch dedicated hardware thread for LoRa RX # Launch all tasks within the same single-threaded uasyncio event loop
try: asyncio.create_task(lora_rx_and_heartbeat_task())
_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(uart_polling_task())
asyncio.create_task(lora_process_task()) asyncio.create_task(lora_process_task())
asyncio.create_task(cooking_loop_task()) asyncio.create_task(cooking_loop_task())
+2 -2
View File
@@ -268,11 +268,11 @@ def button_callback():
global button_state global button_state
if microwave_states.get("2").cooking_state == CookingStates.COOKING or microwave_states.get("2").cooking_state == CookingStates.DONE or microwave_states.get("2").cooking_state == CookingStates.STIRRING_REQUIRED: if microwave_states.get("2").cooking_state == CookingStates.COOKING or microwave_states.get("2").cooking_state == CookingStates.DONE or microwave_states.get("2").cooking_state == CookingStates.STIRRING_REQUIRED:
print("[Button] Toggling pause/resume for microwave '2'.") print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}) lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}, max_retries=6)
else: else:
button_state = not button_state button_state = not button_state
print(f"[Button] Defrost state toggled to: {button_state}") print(f"[Button] Defrost state toggled to: {button_state}")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state}) lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state}, max_retries=5)
button.set_callback(button_callback) button.set_callback(button_callback)
button.start_button_monitoring_thread() button.start_button_monitoring_thread()
+29 -9
View File
@@ -28,6 +28,12 @@ class BaseLoraDevice:
self.pending_rx_queue = [] self.pending_rx_queue = []
self.default_group = 2 self.default_group = 2
# NEW: Global TX lock to prevent concurrent overlapping transmissions
if IS_MICROPYTHON:
self.tx_lock = _thread.allocate_lock()
else:
self.tx_lock = threading.Lock()
def _generate_msg_id(self): def _generate_msg_id(self):
return random.getrandbits(16) return random.getrandbits(16)
@@ -77,6 +83,8 @@ class BaseLoraDevice:
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0): def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
"""Sends a payload and listens in a single continuous RX window for the ACK.""" """Sends a payload and listens in a single continuous RX window for the ACK."""
with self.tx_lock:
lock = getattr(self, 'lock', None) lock = getattr(self, 'lock', None)
if isinstance(payload, dict): if isinstance(payload, dict):
@@ -93,12 +101,9 @@ class BaseLoraDevice:
log(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)
# 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) timeout_ms = int(ack_timeout * 1000)
packet = self.receive_packet(timeout_ms=timeout_ms) packet = self.receive_packet(timeout_ms=timeout_ms)
# 2. Process incoming packet if received
if packet: if packet:
if lock: lock.acquire() if lock: lock.acquire()
try: try:
@@ -108,7 +113,6 @@ class BaseLoraDevice:
finally: finally:
if lock: lock.release() if lock: lock.release()
# 3. Check if matching ACK was received
if lock: lock.acquire() if lock: lock.acquire()
try: try:
if msg_id in self.received_acks: if msg_id in self.received_acks:
@@ -296,19 +300,28 @@ else:
self.ser.reset_output_buffer() self.ser.reset_output_buffer()
self.lock = threading.Lock() self.lock = threading.Lock()
# Initial configuration self._emergency_rx_buffer = []
self.configure(freq=868.1, sf=7, bw=125) self.configure(freq=868.1, sf=7, bw=125)
def _send_at_cmd(self, cmd, timeout=1.5): def _send_at_cmd(self, cmd, timeout=1.5):
"""Sends AT command and reads response until line received or timeout.""" """Sends AT command and safely handles async incoming packets."""
self.ser.write(f"{cmd}\r\n".encode('utf-8')) self.ser.write(f"{cmd}\r\n".encode('utf-8'))
start = time.time() start = time.time()
resp = "" resp = ""
while (time.time() - start) < timeout: while (time.time() - start) < timeout:
if self.ser.in_waiting > 0: if self.ser.in_waiting > 0:
line = self.ser.readline().decode('utf-8', errors='ignore') line = self.ser.readline().decode('utf-8', errors='ignore').strip()
resp += line
if not line:
continue
if any(marker in line for marker in ["+RECV:", "+RCV=", "+DRX:", "(HEX:)", "Data:"]):
self._emergency_rx_buffer.append(line)
continue
resp += line + "\n"
if "OK" in line or "ERROR" in line or "AT_BUSY" in line: if "OK" in line or "ERROR" in line or "AT_BUSY" in line:
break break
time.sleep(0.02) time.sleep(0.02)
@@ -364,8 +377,14 @@ else:
timeout_s = timeout_ms / 1000.0 timeout_s = timeout_ms / 1000.0
while (time.time() - start_time) < timeout_s: while (time.time() - start_time) < timeout_s:
if self.ser.in_waiting > 0: line = ""
# NEW: Process emergency buffer first before checking serial
if self._emergency_rx_buffer:
line = self._emergency_rx_buffer.pop(0)
elif self.ser.in_waiting > 0:
line = self.ser.readline().decode('utf-8', errors='ignore').strip() line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if line: if line:
payload_bytes = None payload_bytes = None
@@ -438,6 +457,7 @@ def get_lora_device(port_or_pins=None):
class LoraCommands: class LoraCommands:
PING = "ping" PING = "ping"
MICROVAVE_STATE_UPDATE = "microwave_state_update"
COOKING_STATE_UPDATE = "cooking_state_update" COOKING_STATE_UPDATE = "cooking_state_update"
COOKING_UPDATE = "cooking_update" COOKING_UPDATE = "cooking_update"
TOGGLE_PAUSE = "toggle_pause" TOGGLE_PAUSE = "toggle_pause"