Files
Smartwave/orchestrateur/external_display_manager.py
T
Ninluc fde1ad6ca0
Build, push image, and notify Watchtower / build-image (push) Successful in 3m48s
Build, push image, and notify Watchtower / notify (push) Successful in 21s
External Screen working
2026-08-15 13:50:15 +02:00

101 lines
4.0 KiB
Python

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.")