From fde1ad6ca01c1ddbad8899629c723d378c4dda52 Mon Sep 17 00:00:00 2001 From: Matthias Guillitte Date: Sat, 15 Aug 2026 13:50:15 +0200 Subject: [PATCH] External Screen working --- ecran_externe/display_api.h | 54 ++++++++ ecran_externe/epaper_thread_node.yaml | 154 ++++++++++++++++------ orchestrateur/compose.yaml | 25 ++-- orchestrateur/external_display_manager.py | 101 ++++++++++++++ orchestrateur/main.py | 26 +++- orchestrateur/mqtt/mosquitto-plain.conf | 10 +- orchestrateur/mqtt/mosquitto-tls.conf | 11 +- orchestrateur/requirements.txt | 3 +- 8 files changed, 330 insertions(+), 54 deletions(-) create mode 100644 ecran_externe/display_api.h create mode 100644 orchestrateur/external_display_manager.py diff --git a/ecran_externe/display_api.h b/ecran_externe/display_api.h new file mode 100644 index 0000000..2c2a884 --- /dev/null +++ b/ecran_externe/display_api.h @@ -0,0 +1,54 @@ +#pragma once +#include "esphome.h" +#include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/json/json_util.h" +#include +#include + +// Callback signature: void(device_id, cloud_alert, defrost_mode) +using DisplayUpdateCallback = std::function; + +static DisplayUpdateCallback g_display_callback = nullptr; + +class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler { + public: + bool canHandle(esphome::web_server_idf::AsyncWebServerRequest *request) const override { + return request->url_to() == "/api/display"; + } + + void handleRequest(esphome::web_server_idf::AsyncWebServerRequest *request) override { + request->send(200, "application/json", "{\"status\":\"ok\"}"); + } + + void handleBody(esphome::web_server_idf::AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override { + std::string body((char *)data, len); + + esphome::json::parse_json(body, [](JsonObject root) -> bool { + std::string device_id = root["device_id"] | "unknown"; + bool cloud_alert = root["cloud_alert"] | false; + bool defrost_mode = root["defrost_mode"] | false; + + ESP_LOGI("custom_api", "Received update for %s (Alert: %d, Defrost: %d)", + device_id.c_str(), cloud_alert, defrost_mode); + + // Execute the callback passed from YAML + if (g_display_callback != nullptr) { + g_display_callback(device_id, cloud_alert, defrost_mode); + } + + return true; + }); + + request->send(200, "application/json", "{\"status\":\"ok\"}"); + } +}; + +inline void setup_display_api(DisplayUpdateCallback callback) { + g_display_callback = callback; + if (esphome::web_server_base::global_web_server_base != nullptr) { + esphome::web_server_base::global_web_server_base->add_handler(new DisplayApiHandler()); + ESP_LOGI("custom_api", "Successfully registered /api/display POST handler"); + } else { + ESP_LOGE("custom_api", "Failed to register endpoint: global_web_server_base is null"); + } +} \ No newline at end of file diff --git a/ecran_externe/epaper_thread_node.yaml b/ecran_externe/epaper_thread_node.yaml index 4a4313b..e08f9c2 100644 --- a/ecran_externe/epaper_thread_node.yaml +++ b/ecran_externe/epaper_thread_node.yaml @@ -2,18 +2,57 @@ substitutions: device_id: !include device_id.txt esphome: - name: epaper-node - friendly_name: "Microwave Thread Display Node" + name: "smartwave-epaper-${device_id}" + friendly_name: "SmartWave ePaper ExternalScreen ${device_id}" + includes: + - display_api.h on_boot: priority: -100.0 then: - - component.update: my_display + - lambda: |- + setup_display_api([](const std::string &dev_id, bool alert, bool defrost) { + // Update global state + id(cloud_alert) = alert; + id(defrost_mode) = defrost; + + // Set status message text + std::string msg = "Microwave " + dev_id; + id(status_message).publish_state(msg.c_str()); + + // Force immediate e-Paper refresh! + id(my_display).update(); + }); + +# ------------------------------------------------------------------- +# State Globals +# ------------------------------------------------------------------- +globals: + - id: cloud_alert + type: bool + initial_value: 'false' + - id: defrost_mode + type: bool + initial_value: 'false' + +web_server: + port: 5000 + +mdns: + disabled: false + services: + - service: "_displaytcp" + protocol: "_tcp" + port: 5000 + txt: + version: "1.0" + type: "epaper_2.13" esp32: - board: esp32-h2-devkitm-1 + board: epaper-node-display variant: esp32h2 framework: type: esp-idf + log_level: INFO advanced: loop_task_stack_size: 32768 sdkconfig_options: @@ -25,43 +64,39 @@ esp32: CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA: "y" logger: - level: INFO + level: VERBOSE hardware_uart: UART0 + logs: + openthread: INFO network: enable_ipv6: true -# ------------------------------------------------------------------- -# OpenThread Network Settings -# ------------------------------------------------------------------- openthread: - network_name: "OpenThread-7f41" - pan_id: 0x7f41 - channel: 24 - network_key: "0x3f543281275a135e38982974aa71e987" force_dataset: true + tlv: 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000 # ------------------------------------------------------------------- # MQTT Connection over Thread IPv6 # ------------------------------------------------------------------- -mqtt: - broker: "fd1a:dbbe:7435:e954:4455:28c5:b6cf:e990" - port: 8884 - skip_cert_cn_check: true - topic_prefix: microwave/display - keepalive: 60s - on_connect: - - mqtt.publish: - topic: smartwave/hello - payload: !lambda |- - return std::string("{\"id_microwave\":\"") + "${device_id}" + "\",\"type\":\"externalDisplay\"}"; - on_message: - - topic: microwave/display/status - then: - - text_sensor.template.publish: - id: status_message - state: !lambda 'return x;' - - component.update: my_display +# mqtt: +# broker: "mqtt.thread.smartwave.matthiasg.dev" +# # broker: "mqtt://[fd36:5102:8d0:1:f1cd:3499:e546:dbec]" +# # port: 1883 +# topic_prefix: "microwave/display" +# keepalive: 60s +# on_connect: +# - mqtt.publish: +# topic: smartwave/hello +# payload: !lambda |- +# return std::string("{\"id_microwave\":\"") + "${device_id}" + "\",\"type\":\"externalDisplay\"}"; +# on_message: +# - topic: microwave/display/status +# then: +# - text_sensor.template.publish: +# id: status_message +# state: !lambda 'return x;' +# - component.update: my_display # ------------------------------------------------------------------- # Template Text Sensor @@ -70,6 +105,19 @@ text_sensor: - platform: template name: "Display Status Message" id: status_message + - platform: openthread_info + ip_address: + name: "Thread IP" + id: thread_ip + role: + name: "Thread Role" + id: thread_role + channel: + name: "Thread Channel" + id: thread_channel + rloc16: + name: "Thread RLOC16" + id: thread_rloc16 # ------------------------------------------------------------------- # Hardware SPI Pin Mapping (ESP32-H2) @@ -82,12 +130,18 @@ spi: # Fonts # ------------------------------------------------------------------- font: - - file: "gfonts://Roboto" + - file: + type: gfonts + family: Open+Sans + weight: 700 + size: 20 id: font_header - size: 18 - - file: "gfonts://Roboto" + - file: + type: gfonts + family: Overpass + weight: 400 id: font_body - size: 12 + size: 14 # ------------------------------------------------------------------- # Waveshare 2.13-inch e-Paper Display (V4 panel = 2.13inv3 in ESPHome) @@ -100,16 +154,38 @@ display: busy_pin: GPIO3 model: 2.13inv3 rotation: 90° - update_interval: 30s + update_interval: 30min id: my_display lambda: |- if (id(status_message).has_state()) { - it.print(5, 3, id(font_header), "MICROWAVE STATUS"); - it.line(0, 20, it.get_width(), 20); - it.print(5, 25, id(font_body), id(status_message).state.c_str()); + // Header + it.print(5, 3, id(font_header), id(status_message).state.c_str()); + it.line(0, 26, it.get_width(), 26); + + // Mode Status + if (id(defrost_mode)) { + it.print(5, 32, id(font_header), "MODE: DEFROST"); + } else { + it.print(5, 32, id(font_header), "MODE: NORMAL"); + } + + // Cloud Alert Status Box + if (id(cloud_alert)) { + it.filled_rectangle(5, 60, 240, 24, COLOR_OFF); // Inverse background if supported, or frame box + it.print(10, 62, id(font_body), "CLOUD ALERT ACTIVE"); + } else { + it.print(10, 62, id(font_body), "System Normal"); + } + + // Footer: Thread Info + it.line(0, 95, it.get_width(), 95); + it.printf(5, 100, id(font_body), "Role: %s | Ch: %s", + id(thread_role).state.c_str(), + id(thread_channel).state.c_str()); } else { int center_x = it.get_width() / 2; int center_y = it.get_height() / 2; - it.printf(center_x, center_y, id(font_header), TextAlign::CENTER, "SmartWave"); + it.printf(center_x, center_y - 10, id(font_header), TextAlign::CENTER, "SmartWave"); + it.printf(center_x, center_y + 15, id(font_body), TextAlign::CENTER, "Waiting for Pi updates..."); } \ No newline at end of file diff --git a/orchestrateur/compose.yaml b/orchestrateur/compose.yaml index 952d6e0..bfe526f 100644 --- a/orchestrateur/compose.yaml +++ b/orchestrateur/compose.yaml @@ -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: \ No newline at end of file diff --git a/orchestrateur/external_display_manager.py b/orchestrateur/external_display_manager.py new file mode 100644 index 0000000..38eb5a8 --- /dev/null +++ b/orchestrateur/external_display_manager.py @@ -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.") \ No newline at end of file diff --git a/orchestrateur/main.py b/orchestrateur/main.py index 3b766fb..900156d 100644 --- a/orchestrateur/main.py +++ b/orchestrateur/main.py @@ -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"): diff --git a/orchestrateur/mqtt/mosquitto-plain.conf b/orchestrateur/mqtt/mosquitto-plain.conf index 99dc4b9..940d77b 100644 --- a/orchestrateur/mqtt/mosquitto-plain.conf +++ b/orchestrateur/mqtt/mosquitto-plain.conf @@ -8,5 +8,11 @@ log_type notice log_type information allow_anonymous true -listener 8884 :: -protocol mqtt \ No newline at end of file +listener 8884 +protocol mqtt + +# Unencrypted listener for local Thread nodes +listener 1883 +protocol mqtt +socket_domain ipv6 +allow_anonymous true \ No newline at end of file diff --git a/orchestrateur/mqtt/mosquitto-tls.conf b/orchestrateur/mqtt/mosquitto-tls.conf index 43829b4..ff1f43c 100644 --- a/orchestrateur/mqtt/mosquitto-tls.conf +++ b/orchestrateur/mqtt/mosquitto-tls.conf @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/orchestrateur/requirements.txt b/orchestrateur/requirements.txt index 51764b9..66d4003 100644 --- a/orchestrateur/requirements.txt +++ b/orchestrateur/requirements.txt @@ -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 \ No newline at end of file +# sudo apt install -y opencv-data +zeroconf>=0.131.0 \ No newline at end of file