Sending updates to external screen + RGB LCD

This commit is contained in:
2026-08-16 14:23:18 +02:00
parent 2276c1742a
commit fd00c9a89d
11 changed files with 367 additions and 24 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ struct MicrowaveData {
// Global states parsed from the API
static std::vector<MicrowaveData> g_microwaves;
static bool g_cloud_alert = false;
static bool g_cloud_alert = true;
static std::string g_update_time = "--:--";
static bool g_received_update = false;
+54 -8
View File
@@ -28,6 +28,7 @@ except Exception:
# --- GLOBAL VARIABLES ---
cooking_state = None
cooking_start_time = None # Track start timestamp
data_queue = SafeQueue()
lora = None
uart_device = None
@@ -46,6 +47,31 @@ PING_PAYLOAD = {
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
def send_cooking_update(state=None, start_time=None, estimated_remaining_time=None, paused=None):
"""Sends a flexible COOKING_UPDATE payload containing only populated fields."""
if not lora:
return
payload = {
"id": DEVICE_ID,
"action": LoraCommands.COOKING_UPDATE
}
if state is not None:
payload["cooking_state"] = state
if estimated_remaining_time is not None:
payload["estimated_remaining_time"] = estimated_remaining_time
if paused is not None:
payload["paused"] = paused
# Only transmit if at least one field beyond id and action was provided
if len(payload) > 2:
log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}")
lora.send(payload)
def on_new_alert(alert: Alert):
global microwave_screen
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
@@ -138,9 +164,7 @@ def cooking_state_temperature_provider():
# 3. Handle fresh incoming reading
if not temp_is_invalid(current_temp):
temps = [float(current_temp[0]), float(current_temp[1])]
last_temp = [temps[0], temps[1]] # Keep a safe reference copy
# Reset current_temp buffer to consume the value
last_temp = [temps[0], temps[1]]
current_temp = [None, None]
return temps
@@ -152,6 +176,7 @@ def cooking_state_temperature_provider():
return (0.0, 0.0)
def cooking_state_on_state_change(state):
global cooking_start_time
print(f"[CookingState] State changed to: {state.state}")
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
@@ -162,8 +187,14 @@ def cooking_state_on_state_change(state):
# Send state updates to WiFi board and Orchestrator
if uart_device:
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
if lora:
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
# Send LoRa update with start time and remaining time if beginning cooking process
send_cooking_update(
state=state.state,
start_time=cooking_start_time if state.state == cookingState.CookingStates.COOKING else None,
estimated_remaining_time=state.get_remaining_time() if hasattr(state, "get_remaining_time") else None,
paused=state.paused
)
microwave_screen.update(cooking_state, defrost_mode)
@@ -176,14 +207,20 @@ def cooking_state_on_pause(state):
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
state.set_state(cookingState.CookingStates.COOKING)
# TODO: send_reliable lora message to orchestrator about pause/resume state
# Send update on pause/resume toggle
send_cooking_update(
state=state.state,
paused=state.paused,
estimated_remaining_time=state.get_remaining_time()
)
# --- ASYNC TASKS ---
async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board."""
global cooking_state, temperature_asked
global cooking_state, cooking_start_time, temperature_asked
while True:
if uart_device and uart_device.any():
@@ -196,6 +233,9 @@ async def uart_polling_task():
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
# Track start time timestamp
cooking_start_time = time.time()
cooking_state = cookingState.CookingState(
cook_time=params["cook_time"],
power_level=params["power_level"],
@@ -271,11 +311,17 @@ async def cooking_loop_task():
cooking_state.update_tick()
log_counter += 1
# Print log output every 5 seconds (10 ticks x 500ms)
# Print log output and send periodic updates every 5 seconds (10 ticks x 500ms)
if log_counter % 10 == 0:
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
# Send periodic time remaining update while actively cooking
if not cooking_state.paused and cooking_state.state == cookingState.CookingStates.COOKING:
send_cooking_update(
estimated_remaining_time=cooking_state.get_remaining_time()
)
await asyncio.sleep_ms(500)
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- NET_ADMIN
environment:
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
- OT_INFRA_IF=wlan0
- OT_INFRA_IF=wlx6815790f3204
- OT_THREAD_IF=wpan0
- OT_LOG_LEVEL=6
- FIREWALL=0
+11 -2
View File
@@ -14,6 +14,7 @@ class DisplayManager:
self.screens: Dict[str, str] = {} # Map: service_name -> URL
self.aiozc: AsyncZeroconf | None = None
self.browser: AsyncServiceBrowser | None = None
self._on_screens_change_callback = None # Callback for screen changes
async def start(self):
"""Starts dynamic mDNS discovery for e-Paper screens."""
@@ -48,12 +49,20 @@ class DisplayManager:
url = f"http://{ip_str}:{port}{self.endpoint_path}"
self.screens[name] = url
if self._on_screens_change_callback:
self._on_screens_change_callback(self.screens)
print(f"[DisplayManager] 📺 Screen registered: {name} -> {url}")
elif state_change == ServiceStateChange.Removed:
if name in self.screens:
print(f"[DisplayManager] ❌ Screen disconnected: {name}")
self.screens.pop(name, None)
if self._on_screens_change_callback:
self._on_screens_change_callback(self.screens)
def set_on_screens_change_callback(self, callback):
"""Set a callback function to be called when screens are added or removed."""
self._on_screens_change_callback = callback
async def broadcast_state(self, microwave_states: dict, cloud_alert: bool):
"""Broadcasts the system state JSON to all discovered screens concurrently."""
@@ -113,9 +122,9 @@ class DisplayManager:
timeout=3.0
)
response.raise_for_status()
print(f"[DisplayManager] Updated {name}")
print(f"[DisplayManager] Updated {name}")
except Exception as e:
print(f"[DisplayManager] ⚠️ Error pushing state to {name}: {e}")
print(f"[DisplayManager] Error pushing state to {name}: {e}")
async def stop(self):
"""Clean up mDNS browser and Zeroconf instance."""
+54 -8
View File
@@ -11,9 +11,13 @@ from shared.logging import log
from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands
from shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd
from lib.systemd_logs import get_systemd_logs
from external_display_manager import DisplayManager
from rgb_lcd_manager import RGBLCDManager
rgb_lcd.setRGB(255, 255, 255, brightness=0.8)
rgb_lcd.setText("SmartWave\nOrchestrateur")
# --- CONFIGURATION CONSTANTS ---
DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting
@@ -69,6 +73,7 @@ display_update_queue = None
def notify_display_update():
"""Safely adds an update event timestamp to the display update queue."""
if display_update_queue is not None:
try:
loop = asyncio.get_running_loop()
@@ -79,18 +84,21 @@ def notify_display_update():
def set_cloud_alert(state: bool):
"""Setter for cloud_alert that triggers a display update when changed."""
global cloud_alert
if cloud_alert != state:
cloud_alert = state
notify_display_update()
update_lcd_cloud_alert()
# Global state trackers
microwave_states = {"2": MicrowaveState("2", on_change_callback=notify_display_update)}
button_state = False
cloud_alert = False # Global status flag for screen / UI display
cloud_alert = None # Global status flag for screen / UI display
async_event_queue = None
# Display Manager instance
display_manager = DisplayManager(endpoint_path="/api/display")
rgb_lcd_manager = RGBLCDManager(rgb_lcd, brightness=0.8, interval=5.0)
# Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings
@@ -112,6 +120,10 @@ mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
# DHT sensor warmup
time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start
temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state
if hasattr(mqtt_client._client, "loop_start"):
mqtt_client._client.loop_start()
print("[MQTT Local] Paho background loop started.")
@@ -192,6 +204,23 @@ async def cloud_mqtt_listener_task():
"data": payload
})
await asyncio.sleep(0.1)
# === LCD DISPLAY ===
def on_screens_change(screens):
"""Callback for when screens are added or removed."""
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}")
rgb_lcd_manager.set_external_screen_count(len(screens))
display_manager.set_on_screens_change_callback(on_screens_change)
def update_lcd_microwave_count():
"""Updates the LCD with the current number of connected microwaves."""
global microwave_states
count = len(microwave_states)
rgb_lcd_manager.set_microwave_count(count)
def update_lcd_cloud_alert():
"""Updates the LCD with the current cloud connectivity status."""
global cloud_alert
rgb_lcd_manager.set_cloud_alert(cloud_alert)
async def display_broadcast_worker_task():
"""
@@ -199,6 +228,8 @@ async def display_broadcast_worker_task():
Waits until DISPLAY_DEBOUNCE_SECONDS have passed since the first queued update
before sending state to all connected screens.
"""
global display_update_queue, microwave_states, cloud_alert
print("[Display Worker] Started debounced display broadcast worker.")
while True:
# Block until the FIRST update reminder arrives in the queue
@@ -331,7 +362,7 @@ async def handle_new_dish(microwave_id, detected_height):
# 6. Dispatch cloud request task
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
except asyncio.TimeoutError:
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
print(f"[{microwave_id}] Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None
finally:
@@ -377,6 +408,8 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
response.raise_for_status()
set_cloud_alert(False) # Clear any previous cloud alert
# JSON extraction
res_json = response.json()
@@ -400,12 +433,11 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
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: {res_json}")
print(f"[{microwave_id}] Invalid plan received: {res_json}")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
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].set_state(MicrowaveState.COOKING)
if config.DEBUG:
@@ -417,6 +449,8 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS
)
microwave_states[microwave_id].set_cooking_start_time(time.time())
return
except Exception as e:
@@ -436,7 +470,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
# Executed only if all 3 retries failed
print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
cloud_alert = True
set_cloud_alert(True)
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
# --- MAIN LOGIC TASKS ---
@@ -458,6 +492,7 @@ async def process_messages_task():
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
microwave_states[mw_id].set_cooking_state(n_state)
update_lcd_microwave_count() # Update the LCD with new microwave count
if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
@@ -465,7 +500,17 @@ async def process_messages_task():
if n_state == CookingStates.IDLE and microwave_states.get(mw_id).state == MicrowaveState.COOKING:
microwave_states[mw_id].set_state(MicrowaveState.DONE)
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
if "action" in data.get("data", {}):
action = data["data"]["action"]
if action == LoraCommands.COOKING_UPDATE:
mw_id = data["data"].get("id")
if mw_id in microwave_states:
state_info = data["data"]
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
else:
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
elif source == "MQTT":
topic = msg["topic"]
@@ -487,7 +532,8 @@ async def process_messages_task():
component_id,
on_change_callback=notify_display_update
)
notify_display_update()
notify_display_update() # Update the external screens
update_lcd_microwave_count() # Update the LCD with new microwave count
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
+1 -1
View File
@@ -34,7 +34,7 @@ class MicrowaveState:
if self.state != new_state:
self.state = new_state
self._notify_change()
# self._notify_change()
def set_paused(self, is_paused):
if self.paused != is_paused:
+138
View File
@@ -0,0 +1,138 @@
"""
Manages the RGB LCD display with the current state of the microwaves and cloud connectivity.
The display switches content every x seconds between microwaves, external screens,
and cloud connectivity state using a background thread.
"""
import threading
import time
SCREEN_SWITCH_INTERVAL = 5 # seconds
DEFAULT_BRIGHTNESS = 0.3 # Constant brightness (range 0.0 to 1.0)
class RGBLCDManager:
def __init__(self, rgb_lcd, interval=SCREEN_SWITCH_INTERVAL, brightness=DEFAULT_BRIGHTNESS, auto_start=True):
self.rgb_lcd = rgb_lcd
self.interval = interval
self.brightness = max(0.0, min(1.0, float(brightness)))
# Internal state store
self._microwave_count = "N/A"
self._external_screen_count = "N/A"
self._cloud_alert = None
# Screen rotation sequence
self.screens = [
self.display_microwave_state,
self.display_external_screens,
self.display_cloud_connectivity,
]
self.current_screen_idx = 0
# Threading controls
self._thread = None
self._running = False
self._stop_event = threading.Event()
if auto_start:
self.start()
# --- SETTER METHODS ---
def set_brightness(self, brightness):
"""Dynamically adjust backlight brightness (0.0 to 1.0)."""
self.brightness = max(0.0, min(1.0, float(brightness)))
def set_microwave_count(self, count):
"""Set the number of connected microwaves."""
self._microwave_count = count
def set_external_screen_count(self, count):
"""Set the number of connected external screens."""
self._external_screen_count = count
def set_cloud_alert(self, status: bool):
"""Set cloud connectivity status."""
self._cloud_alert = status
def set_data(self, microwave_count=None, external_screen_count=None, cloud_alert=None):
"""Convenience method to update all state variables at once."""
if microwave_count is not None:
self.set_microwave_count(microwave_count)
if external_screen_count is not None:
self.set_external_screen_count(external_screen_count)
if cloud_alert is not None:
self.set_cloud_alert(cloud_alert)
# --- DISPLAY RENDERERS ---
def display_microwave_state(self):
"""Display the number of connected microwaves."""
text = f"Microwaves:\nConnected: {self._microwave_count}"
self.rgb_lcd.setText(text)
self.rgb_lcd.setRGB(255, 255, 255, brightness=self.brightness)
def display_external_screens(self):
"""Display the number of connected external screens."""
text = f"Screens:\nConnected: {self._external_screen_count}"
self.rgb_lcd.setText(text)
self.rgb_lcd.setRGB(255, 255, 255, brightness=self.brightness)
def display_cloud_connectivity(self):
"""Display cloud connectivity status with brightness-scaled backlight color."""
status_str = None
# Base RGB colors
if self._cloud_alert is True:
rgb = (255, 0, 0) # Red
status_str = "Disconnected"
elif self._cloud_alert is False:
rgb = (0, 255, 0) # Green
status_str = "Connected"
else:
rgb = (255, 165, 0) # Orange
status_str = "N/A"
self.rgb_lcd.setRGB(rgb[0], rgb[1], rgb[2], brightness=self.brightness)
self.rgb_lcd.setText(f"Cloud Status:\n{status_str}")
# --- BACKGROUND THREAD LOGIC ---
def _display_loop(self):
"""Background loop that switches screens at regular intervals."""
while not self._stop_event.is_set():
try:
self.screens[self.current_screen_idx]()
self.current_screen_idx = (self.current_screen_idx + 1) % len(self.screens)
except Exception as e:
print(f"[RGBLCDManager] Error updating LCD: {e}")
sleep_ticks = int(self.interval * 10)
for _ in range(sleep_ticks):
if self._stop_event.is_set():
break
time.sleep(0.1)
def start(self):
"""Start the display background thread."""
if not self._running:
self._running = True
self._stop_event.clear()
self._thread = threading.Thread(target=self._display_loop, daemon=True)
self._thread.start()
def stop(self):
"""Stop the display loop and turn off the LCD backlight."""
if self._running:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
self._running = False
try:
self.rgb_lcd.setText("Bye !")
self.rgb_lcd.setRGB(0, 0, 0)
except Exception as e:
print(f"[RGBLCDManager] Error clearing LCD: {e}")
+1
View File
@@ -6,3 +6,4 @@ import sensors.button as button
import sensors.gps as gps
import sensors.camera as camera
import sensors.buzzer as buzzer
import sensors.rgb_lcd as rgb_lcd
+102
View File
@@ -0,0 +1,102 @@
import time
import sys
from sensors.lock import grove_lock
if sys.platform == 'uwp':
import winrt_smbus as smbus
bus = smbus.SMBus(1)
else:
import smbus
import RPi.GPIO as GPIO
rev = GPIO.RPI_REVISION
if rev == 2 or rev == 3:
bus = smbus.SMBus(1)
else:
bus = smbus.SMBus(0)
# Device I2C addresses
DISPLAY_RGB_ADDR = 0x62
DISPLAY_TEXT_ADDR = 0x3e
def setRGB(r, g, b, brightness=1.0):
"""Set backlight to (R,G,B) with optional brightness level (0.0 to 1.0)."""
brightness = max(0.0, min(1.0, float(brightness)))
r_scaled = int(max(0, min(255, r * brightness)))
g_scaled = int(max(0, min(255, g * brightness)))
b_scaled = int(max(0, min(255, b * brightness)))
with grove_lock:
try:
bus.write_byte_data(DISPLAY_RGB_ADDR, 0, 0)
bus.write_byte_data(DISPLAY_RGB_ADDR, 1, 0)
bus.write_byte_data(DISPLAY_RGB_ADDR, 0x08, 0xaa)
bus.write_byte_data(DISPLAY_RGB_ADDR, 4, r_scaled)
bus.write_byte_data(DISPLAY_RGB_ADDR, 3, g_scaled)
bus.write_byte_data(DISPLAY_RGB_ADDR, 2, b_scaled)
except OSError:
pass
def textCommand(cmd):
"""Send command to display (internal use)."""
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x80, cmd)
def setText(text):
"""Set display text (\n for second line or auto wrap)."""
with grove_lock:
try:
textCommand(0x01) # Clear display
time.sleep(0.05)
textCommand(0x08 | 0x04) # Display on, no cursor
textCommand(0x28) # 2 lines
time.sleep(0.05)
count = 0
row = 0
for c in text:
if c == '\n' or count == 16:
count = 0
row += 1
if row == 2:
break
textCommand(0xc0)
if c == '\n':
continue
count += 1
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
time.sleep(0.001) # Small pacing delay to prevent LCD buffer overflow
except OSError:
pass
def setText_norefresh(text):
"""Update display text without full screen erase."""
with grove_lock:
try:
textCommand(0x02) # Return home
time.sleep(0.05)
textCommand(0x08 | 0x04) # Display on, no cursor
textCommand(0x28) # 2 lines
time.sleep(0.05)
count = 0
row = 0
while len(text) < 32: # Clear rest of screen space
text += ' '
for c in text:
if c == '\n' or count == 16:
count = 0
row += 1
if row == 2:
break
textCommand(0xc0)
if c == '\n':
continue
count += 1
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
time.sleep(0.001) # Small pacing delay to prevent LCD buffer overflow
except OSError:
pass
+2 -2
View File
@@ -9,11 +9,11 @@ import shared.safeQueue as safeQueue
import shared.alerts as alerts
try:
import shared.lora_device as lora_device
except ImportError:
except (ImportError, SyntaxError):
pass # No need
try:
import shared.uart_comm as uart_comm
except ImportError:
except (ImportError, SyntaxError):
pass # No need as we are on the RPI
import shared.sensors
+1
View File
@@ -439,6 +439,7 @@ def get_lora_device(port_or_pins=None):
class LoraCommands:
PING = "ping"
COOKING_STATE_UPDATE = "cooking_state_update"
COOKING_UPDATE = "cooking_update"
TOGGLE_PAUSE = "toggle_pause"
TOGGLE_DEFROST = "toggle_defrost"
NEW_ALERT = "new_alert"