Compare commits
3 Commits
5c60017e8d
...
6fa74c6b93
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fa74c6b93 | |||
| af5ea45270 | |||
| 1626b392b3 |
@@ -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
|
||||||
|
|||||||
+55
-25
@@ -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,36 +199,60 @@ 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)
|
|
||||||
|
|
||||||
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
max_retries = 3
|
||||||
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
|
retry_delay_seconds = 30
|
||||||
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
response.raise_for_status()
|
try:
|
||||||
plan = response.json().get("cook_plan", {})
|
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
|
||||||
c_time = plan.get("cook_time_seconds")
|
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
|
||||||
c_power = plan.get("effective_power_watts")
|
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
||||||
c_temp = plan.get("target_temp")
|
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||||
|
return
|
||||||
|
|
||||||
if c_time is None or c_power is None or c_temp is None:
|
response.raise_for_status()
|
||||||
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
plan = response.json().get("cook_plan", {})
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
|
c_time = plan.get("cook_time_seconds")
|
||||||
|
c_power = plan.get("effective_power_watts")
|
||||||
|
c_temp = plan.get("target_temp")
|
||||||
|
|
||||||
|
if c_time is None or c_power is None or c_temp is None:
|
||||||
|
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_COOKING,
|
||||||
|
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
||||||
|
qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
except Exception as e:
|
||||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
|
||||||
mqtt_client.publish(
|
|
||||||
config.MQTT_TOPIC_COOKING,
|
|
||||||
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
|
||||||
qos=config.MQTT_QOS
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
if attempt < max_retries:
|
||||||
print(f"[{microwave_id}] Cloud API Error: {e}")
|
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
# 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
|
||||||
|
|
||||||
# --- MAIN LOGIC TASKS ---
|
# --- MAIN LOGIC TASKS ---
|
||||||
async def process_messages_task():
|
async def process_messages_task():
|
||||||
@@ -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
|
||||||
|
|||||||
+57
-64
@@ -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,37 +91,31 @@ 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
|
||||||
|
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
|
||||||
|
timeout_ms = int(ack_timeout * 1000)
|
||||||
|
packet = self.receive_packet(timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
# 2. Process incoming packet if received
|
||||||
|
if packet:
|
||||||
if lock: lock.acquire()
|
if lock: lock.acquire()
|
||||||
try:
|
try:
|
||||||
if msg_id in self.received_acks:
|
filtered_packet = self._process_incoming_packet(packet)
|
||||||
self.received_acks.remove(msg_id)
|
if filtered_packet:
|
||||||
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
self.pending_rx_queue.append(filtered_packet)
|
||||||
return True
|
|
||||||
finally:
|
finally:
|
||||||
if lock: lock.release()
|
if lock: lock.release()
|
||||||
|
|
||||||
packet = self.receive_packet(timeout_ms=500)
|
# 3. Check if matching ACK was received
|
||||||
if packet:
|
if lock: lock.acquire()
|
||||||
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
|
try:
|
||||||
if lock: lock.acquire()
|
if msg_id in self.received_acks:
|
||||||
try:
|
self.received_acks.remove(msg_id)
|
||||||
filtered_packet = self._process_incoming_packet(packet)
|
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||||
if filtered_packet:
|
return True
|
||||||
self.pending_rx_queue.append(filtered_packet)
|
finally:
|
||||||
finally:
|
if lock: lock.release()
|
||||||
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] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||||
|
|
||||||
@@ -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:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if text.startswith('{') or text.startswith('['):
|
# Find where the actual JSON payload starts ({ or [)
|
||||||
decoded_text = text
|
idx_brace = raw_text.find('{')
|
||||||
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
|
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:
|
||||||
decoded_text = ubinascii.unhexlify(text).decode('utf-8').strip('\x00 \r\n\t')
|
parsed_json = json.loads(json_str)
|
||||||
|
return {"group": self.default_group, "data": parsed_json, "raw": False}
|
||||||
except Exception:
|
except Exception:
|
||||||
decoded_text = text
|
pass
|
||||||
else:
|
|
||||||
decoded_text = text
|
|
||||||
|
|
||||||
try:
|
return {"group": self.default_group, "data": raw_text, "raw": True}
|
||||||
parsed_json = json.loads(decoded_text)
|
|
||||||
return {"group": group, "data": parsed_json, "raw": False}
|
|
||||||
except ValueError:
|
|
||||||
return {"group": group, "data": decoded_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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user