Compare commits

...

3 Commits

Author SHA1 Message Date
Ninluc 6fa74c6b93 Retries with cloud
Build, push image, and notify Watchtower / build-image (push) Successful in 2m27s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
Still need to to be able to reset the cooking_state so hat the user can reinsert a dish
2026-08-05 21:34:15 +02:00
Ninluc af5ea45270 LoRa Fix 2026-08-05 21:33:41 +02:00
Ninluc 1626b392b3 Fix mqtt 2026-08-05 20:48:39 +02:00
3 changed files with 117 additions and 100 deletions
+1 -7
View File
@@ -30,13 +30,7 @@ from shared import get_mqtt_client, config, payloads
MQTT_CA_FILE = "/certs/ca.crt" MQTT_CA_FILE = "/certs/ca.crt"
mqtt_client = get_mqtt_client( mqtt_client = None # Will be initialized in connect_mqtt_async()
host="192.168.50.1",
client_id="smartwave-esp32-demo",
use_tls=True,
cafile=MQTT_CA_FILE,
keepalive=30,
)
# --- HARDWARE & MODULE DEFERRED IMPORTS --- # --- HARDWARE & MODULE DEFERRED IMPORTS ---
status_led = None status_led = None
+37 -7
View File
@@ -35,7 +35,9 @@ class MicrowaveState:
# Global state trackers # Global state trackers
microwave_states = {"2": MicrowaveState.IDLE} microwave_states = {"2": MicrowaveState.IDLE}
button_state = False button_state = False
cloud_alert = False # Global status flag for screen / UI display
async_event_queue = None async_event_queue = None
# Async synchronization trackers for MQTT IR sensors responses # Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event() ir_data_events = {} # mw_id -> asyncio.Event()
@@ -109,7 +111,7 @@ button.start_button_monitoring_thread()
# --- HARDWARE CONTROLLERS --- # --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str): def _stop_hardware(microwave_id: str):
print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.") print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
# TODO: Add LoRa STOP command here # TODO: Add LoRa STOP command here
# --- ASYNC COOKING LOGIC --- # --- ASYNC COOKING LOGIC ---
@@ -187,7 +189,8 @@ async def handle_new_dish(microwave_id, detected_height):
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data)) asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
async def request_cloud_cooking_plan(microwave_id, sensors_data): async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud and starts the microwave if successful.""" """Sends all data to the cloud with up to 3 retries (30s interval)."""
global cloud_alert
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params" URL = "https://smartwave.matthiasg.dev/cooking-params"
@@ -196,11 +199,20 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8") sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
print(f"[{microwave_id}] Requesting cooking plan from cloud app...") print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
try:
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
max_retries = 3
retry_delay_seconds = 30
for attempt in range(1, max_retries + 1):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
return
try:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi) # Abort if state changed (e.g. user removed dish while waiting for wifi)
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD: if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.") print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return return
@@ -212,19 +224,34 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
if c_time is None or c_power is None or c_temp is None: if c_time is None or c_power is None or c_temp is None:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}") print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe microwave_states[microwave_id] = MicrowaveState.DONE
return return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W") print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
cloud_alert = False # Reset alert flag on success
microwave_states[microwave_id] = MicrowaveState.COOKING microwave_states[microwave_id] = MicrowaveState.COOKING
mqtt_client.publish( mqtt_client.publish(
config.MQTT_TOPIC_COOKING, config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp), payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS qos=config.MQTT_QOS
) )
return
except Exception as e: except Exception as e:
print(f"[{microwave_id}] Cloud API Error: {e}") print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
if attempt < max_retries:
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
# Interruptible wait loop in case user removes the dish mid-wait
for _ in range(retry_delay_seconds):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
return
await asyncio.sleep(1)
# Executed only if all 3 retries failed
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.")
cloud_alert = True
microwave_states[microwave_id] = MicrowaveState.DONE microwave_states[microwave_id] = MicrowaveState.DONE
# --- MAIN LOGIC TASKS --- # --- MAIN LOGIC TASKS ---
@@ -292,6 +319,7 @@ async def get_filtered_dish_height(samples=3, delay=0.04):
async def monitor_dish_height_task(): async def monitor_dish_height_task():
"""Monitors presence of dish with hysteresis and debouncing.""" """Monitors presence of dish with hysteresis and debouncing."""
global cloud_alert
mw_id = "2" mw_id = "2"
consecutive_present = 0 consecutive_present = 0
consecutive_absent = 0 consecutive_absent = 0
@@ -326,6 +354,8 @@ async def monitor_dish_height_task():
consecutive_absent = 0 consecutive_absent = 0
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.") print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
microwave_states[mw_id] = MicrowaveState.IDLE microwave_states[mw_id] = MicrowaveState.IDLE
cloud_alert = False # Reset error alert on dish removal
if current_state == MicrowaveState.COOKING: if current_state == MicrowaveState.COOKING:
_stop_hardware(mw_id) _stop_hardware(mw_id)
# Remove from IR cache and events # Remove from IR cache and events
+47 -54
View File
@@ -1,6 +1,7 @@
import sys import sys
import time import time
import random import random
from shared.logging import log
IS_MICROPYTHON = sys.implementation.name == 'micropython' IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -32,9 +33,10 @@ class BaseLoraDevice:
"""Sends an immediate acknowledgement packet back to the sender.""" """Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}") print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON: if IS_MICROPYTHON:
time.sleep_ms(10) time.sleep_ms(100)
else: else:
time.sleep(0.01) # Give LA66 chip time to finish logging RSSI and reset RF frontend
time.sleep(0.25)
ack_payload = {"_type": "_ack", "_ack_id": ack_id} ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload) self.send(ack_payload)
@@ -72,8 +74,8 @@ class BaseLoraDevice:
return packet return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5): def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
"""Sends a payload and retries until an ACK is received or max retries are reached.""" """Sends a payload and listens in a single continuous RX window for the ACK."""
lock = getattr(self, 'lock', None) lock = getattr(self, 'lock', None)
if isinstance(payload, dict): if isinstance(payload, dict):
@@ -89,21 +91,14 @@ class BaseLoraDevice:
for attempt in range(max_retries): for attempt in range(max_retries):
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}") print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload) self.send(payload)
start_time = time.time()
while (time.time() - start_time) < ack_timeout: # 1. Open a single continuous RX window for the full timeout duration
if lock: lock.acquire() # (Defaulted to 3.0s to account for LA66 UART + transmission time)
try: timeout_ms = int(ack_timeout * 1000)
if msg_id in self.received_acks: packet = self.receive_packet(timeout_ms=timeout_ms)
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) # 2. Process incoming packet if received
if packet: if packet:
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
if lock: lock.acquire() if lock: lock.acquire()
try: try:
filtered_packet = self._process_incoming_packet(packet) filtered_packet = self._process_incoming_packet(packet)
@@ -112,11 +107,12 @@ 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:
self.received_acks.remove(msg_id) self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===") print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True return True
finally: finally:
if lock: lock.release() if lock: lock.release()
@@ -247,36 +243,30 @@ if IS_MICROPYTHON:
print(f"[LoRa SPI] Recv error caught: {e}") print(f"[LoRa SPI] Recv error caught: {e}")
return None return None
if state == 0 and data is not None and len(data) > 0: if state == 0 and data is not None and len(data) > 0:
if data[0] in (0x7B, 0x5B): # Starts with '{' or '[' # Decode to string, ignoring unprintable characters
group = self.default_group
payload_brute = data.strip(b'\x00 \r\n\t')
elif len(data) > 1:
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t')
else:
return None
try: try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t') raw_text = data.decode('utf-8', 'ignore').strip()
except UnicodeError:
return None
if text.startswith('{') or text.startswith('['):
decoded_text = text
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
try:
decoded_text = ubinascii.unhexlify(text).decode('utf-8').strip('\x00 \r\n\t')
except Exception: except Exception:
decoded_text = text return None
else:
decoded_text = text
# Find where the actual JSON payload starts ({ or [)
idx_brace = raw_text.find('{')
idx_bracket = raw_text.find('[')
valid_indices = [i for i in (idx_brace, idx_bracket) if i != -1]
if valid_indices:
# Slice off all leading group bytes/control characters (\x02)
json_str = raw_text[min(valid_indices):]
try: try:
parsed_json = json.loads(decoded_text) parsed_json = json.loads(json_str)
return {"group": group, "data": parsed_json, "raw": False} return {"group": self.default_group, "data": parsed_json, "raw": False}
except ValueError: except Exception:
return {"group": group, "data": decoded_text, "raw": True} pass
return {"group": self.default_group, "data": raw_text, "raw": True}
return None return None
@@ -301,10 +291,15 @@ else:
# Initial configuration # Initial configuration
self.configure(freq=868.1, sf=7, bw=125) self.configure(freq=868.1, sf=7, bw=125)
def _send_at_cmd(self, cmd, wait_time=0.15): def _send_at_cmd(self, cmd, wait_time=0.3):
"""Helper to send AT command and purge response buffer.""" """Sends AT command, draining unread serial noise first."""
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
if self.ser.in_waiting > 0:
self.ser.read_all()
self.ser.write(f"{cmd}\r\n".encode('utf-8')) self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time) time.sleep(wait_time)
resp = "" resp = ""
while self.ser.in_waiting > 0: while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore') resp += self.ser.readline().decode('utf-8', errors='ignore')
@@ -326,7 +321,7 @@ else:
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
def send(self, payload, group=None): def send(self, payload, group=None):
"""Encodes payload into HEX AT command and re-enables continuous RX.""" """Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
with self.lock: with self.lock:
if group is None: if group is None:
group = self.default_group group = self.default_group
@@ -337,17 +332,15 @@ else:
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
paquet_physique = bytes([group]) + payload hex_payload = payload.hex()
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer()
print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}") print(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
cmd = f"AT+PSEND={hex_payload}"
resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
# Re-enable continuous receive mode after transmission completes # Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
self._send_at_cmd("AT+PRECV=65535", wait_time=0.05) cmd = f"AT+SEND={group},{hex_payload},0,3"
resp = self._send_at_cmd(cmd, wait_time=0.3)
print(f"[RPi LoRa Serial] AT+SEND response: {resp}")
def receive_packet(self, timeout_ms=500): def receive_packet(self, timeout_ms=500):
"""Reads incoming serial lines from LA66 stick with robust format parsing.""" """Reads incoming serial lines from LA66 stick with robust format parsing."""