Better LoRa
This commit is contained in:
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user