External Screen working
Build, push image, and notify Watchtower / build-image (push) Successful in 3m48s
Build, push image, and notify Watchtower / notify (push) Successful in 21s

This commit is contained in:
2026-08-15 13:50:15 +02:00
parent 08f6991ef5
commit fde1ad6ca0
8 changed files with 330 additions and 54 deletions
+16 -9
View File
@@ -1,26 +1,32 @@
version: '3.8'
services:
otbr:
image: openthread/otbr:latest
image: openthread/border-router:latest
container_name: otbr
restart: unless-stopped
privileged: true
network_mode: host
cap_add:
- NET_ADMIN
environment:
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
- OT_INFRA_IF=wlan0
- OT_THREAD_IF=wpan0
- OT_LOG_LEVEL=6
- FIREWALL=0
- NAT64=1
devices:
# Change /dev/ttyACM0 if your ESP32-H2 RCP enumerates as /dev/ttyUSB0
- /dev/ttyACM0:/dev/ttyACM0
command: --radio-url "spinel+hdlc+uart:///dev/ttyACM0?uart-baudrate=460800"
- /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_ba78c1181566ee11b42a8d6293cd958c-if00-port0:/dev/ttyUSB1
- /dev/net/tun:/dev/net/tun
volumes:
- otbr-data:/data
mqtt-broker:
image: eclipse-mosquitto:2.0
container_name: mqtt-broker
restart: unless-stopped
network_mode: host
environment:
MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true}
ports:
# Listens on all interfaces (IPv4 and IPv6) for Thread compatibility
- "8884:8884"
volumes:
- ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro
- ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro
@@ -31,5 +37,6 @@ services:
command: ["/bin/sh", "/scripts/start-broker.sh"]
volumes:
otbr-data:
mqtt-data:
mqtt-log:
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import time
import requests
import ipaddress
from typing import Dict
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo
SERVICE_TYPE = "_displaytcp._tcp.local."
class DisplayManager:
def __init__(self, endpoint_path: str = "/api/display"):
self.endpoint_path = endpoint_path
self.screens: Dict[str, str] = {} # Map: service_name -> URL
self.aiozc: AsyncZeroconf | None = None
self.browser: AsyncServiceBrowser | None = None
async def start(self):
"""Starts dynamic mDNS discovery for e-Paper screens."""
self.aiozc = AsyncZeroconf()
self.browser = AsyncServiceBrowser(
self.aiozc.zeroconf,
SERVICE_TYPE,
handlers=[self._on_service_state_change]
)
print(f"[DisplayManager] 🔍 Listening for mDNS screens ({SERVICE_TYPE})...")
def _on_service_state_change(self, zeroconf, service_type, name, state_change):
asyncio.create_task(self._update_service(name, state_change))
async def _update_service(self, name: str, state_change: ServiceStateChange):
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
info = AsyncServiceInfo(SERVICE_TYPE, name)
if await info.async_request(self.aiozc.zeroconf, 3000):
addresses = info.parsed_addresses()
if addresses:
# Prefer IPv4 if available, fallback to IPv6 (Thread)
ipv4_addrs = [a for a in addresses if ":" not in a]
if ipv4_addrs:
ip_str = ipv4_addrs[0]
else:
raw_ip = addresses[0]
# Wrap IPv6 addresses in square brackets for valid HTTP URLs
ip_str = f"[{raw_ip}]" if not raw_ip.startswith("[") else raw_ip
port = info.port or 5000
url = f"http://{ip_str}:{port}{self.endpoint_path}"
self.screens[name] = url
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)
async def broadcast_state(self, device_id: str, microwave_states: dict, button_state: bool, cloud_alert: bool):
"""Broadcasts the system state JSON to all discovered screens concurrently."""
if not self.screens:
return
payload = {
"device_id": device_id,
"timestamp": int(time.time()),
"cloud_alert": cloud_alert,
"defrost_mode": button_state,
"microwaves": [
{"id": mw_id, "state": state}
for mw_id, state in microwave_states.items()
]
}
# Dispatch POST requests in parallel without blocking the main loop
tasks = [
self._post_to_screen(name, url, payload)
for name, url in list(self.screens.items())
]
await asyncio.gather(*tasks, return_exceptions=True)
async def _post_to_screen(self, name: str, url: str, payload: dict):
try:
response = await asyncio.to_thread(
requests.post,
url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=3.0
)
response.raise_for_status()
print(f"[DisplayManager] ✅ Updated {name}")
except Exception as e:
print(f"[DisplayManager] ⚠️ Error pushing state to {name}: {e}")
async def stop(self):
"""Clean up mDNS browser and Zeroconf instance."""
if self.browser:
await self.browser.async_cancel()
if self.aiozc:
await self.aiozc.async_close()
print("[DisplayManager] Stopped service browser.")
+25 -1
View File
@@ -12,6 +12,7 @@ from shared.lora_device import LoraCommands
from shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
from lib.systemd_logs import get_systemd_logs
from external_display_manager import DisplayManager # <-- Import display manager
# --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite"
@@ -73,6 +74,9 @@ button_state = False
cloud_alert = False # Global status flag for screen / UI display
async_event_queue = None
# Display Manager instance
display_manager = DisplayManager(endpoint_path="/api/display")
# Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event()
@@ -174,6 +178,21 @@ async def cloud_mqtt_listener_task():
})
await asyncio.sleep(0.1)
async def display_broadcast_task():
"""Monitors state changes and broadcasts updates to all connected screens."""
last_state = None
while True:
current_state = (dict(microwave_states), button_state, cloud_alert)
if current_state != last_state:
last_state = current_state
await display_manager.broadcast_state(
device_id=DEVICE_ID,
microwave_states=microwave_states,
button_state=button_state,
cloud_alert=cloud_alert
)
await asyncio.sleep(3.0)
def button_callback():
"""Button physical interrupt callback."""
global button_state
@@ -587,6 +606,9 @@ async def main():
# Initialize SQLite database table
init_db()
# Start mDNS Display discovery
await display_manager.start()
async_event_queue = asyncio.Queue()
await asyncio.gather(
@@ -594,7 +616,8 @@ async def main():
mqtt_listener_task(),
cloud_mqtt_listener_task(),
process_messages_task(),
monitor_dish_height_task()
monitor_dish_height_task(),
display_broadcast_task()
)
if __name__ == "__main__":
@@ -603,6 +626,7 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("\nArrêt manuel.")
finally:
asyncio.run(display_manager.stop())
if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop()
if hasattr(cloud_mqtt_client._client, "loop_stop"):
+8 -2
View File
@@ -8,5 +8,11 @@ log_type notice
log_type information
allow_anonymous true
listener 8884 ::
protocol mqtt
listener 8884
protocol mqtt
# Unencrypted listener for local Thread nodes
listener 1883
protocol mqtt
socket_domain ipv6
allow_anonymous true
+9 -2
View File
@@ -8,10 +8,17 @@ log_type notice
log_type information
allow_anonymous true
listener 8884 ::
listener 8884
protocol mqtt
cafile /mosquitto/certs/ca.crt
certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key
require_certificate false
tls_version tlsv1.2
tls_version tlsv1.2
# Unencrypted listener for local Thread nodes
# Thread is already encrypted, so we don't need to use TLS for this listener
listener 1883 ::
protocol mqtt
socket_domain ipv6
allow_anonymous true
+2 -1
View File
@@ -3,4 +3,5 @@ pyserial>=3.5,<4
# picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
# OpenCV
# sudo apt install -y python3-opencv
# sudo apt install -y opencv-data
# sudo apt install -y opencv-data
zeroconf>=0.131.0