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
+54
View File
@@ -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 <functional>
#include <string>
// Callback signature: void(device_id, cloud_alert, defrost_mode)
using DisplayUpdateCallback = std::function<void(const std::string &device_id, bool cloud_alert, bool defrost_mode)>;
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");
}
}
+115 -39
View File
@@ -2,18 +2,57 @@ substitutions:
device_id: !include device_id.txt device_id: !include device_id.txt
esphome: esphome:
name: epaper-node name: "smartwave-epaper-${device_id}"
friendly_name: "Microwave Thread Display Node" friendly_name: "SmartWave ePaper ExternalScreen ${device_id}"
includes:
- display_api.h
on_boot: on_boot:
priority: -100.0 priority: -100.0
then: 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: esp32:
board: esp32-h2-devkitm-1 board: epaper-node-display
variant: esp32h2 variant: esp32h2
framework: framework:
type: esp-idf type: esp-idf
log_level: INFO
advanced: advanced:
loop_task_stack_size: 32768 loop_task_stack_size: 32768
sdkconfig_options: sdkconfig_options:
@@ -25,43 +64,39 @@ esp32:
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA: "y" CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA: "y"
logger: logger:
level: INFO level: VERBOSE
hardware_uart: UART0 hardware_uart: UART0
logs:
openthread: INFO
network: network:
enable_ipv6: true enable_ipv6: true
# -------------------------------------------------------------------
# OpenThread Network Settings
# -------------------------------------------------------------------
openthread: openthread:
network_name: "OpenThread-7f41"
pan_id: 0x7f41
channel: 24
network_key: "0x3f543281275a135e38982974aa71e987"
force_dataset: true force_dataset: true
tlv: 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# MQTT Connection over Thread IPv6 # MQTT Connection over Thread IPv6
# ------------------------------------------------------------------- # -------------------------------------------------------------------
mqtt: # mqtt:
broker: "fd1a:dbbe:7435:e954:4455:28c5:b6cf:e990" # broker: "mqtt.thread.smartwave.matthiasg.dev"
port: 8884 # # broker: "mqtt://[fd36:5102:8d0:1:f1cd:3499:e546:dbec]"
skip_cert_cn_check: true # # port: 1883
topic_prefix: microwave/display # topic_prefix: "microwave/display"
keepalive: 60s # keepalive: 60s
on_connect: # on_connect:
- mqtt.publish: # - mqtt.publish:
topic: smartwave/hello # topic: smartwave/hello
payload: !lambda |- # payload: !lambda |-
return std::string("{\"id_microwave\":\"") + "${device_id}" + "\",\"type\":\"externalDisplay\"}"; # return std::string("{\"id_microwave\":\"") + "${device_id}" + "\",\"type\":\"externalDisplay\"}";
on_message: # on_message:
- topic: microwave/display/status # - topic: microwave/display/status
then: # then:
- text_sensor.template.publish: # - text_sensor.template.publish:
id: status_message # id: status_message
state: !lambda 'return x;' # state: !lambda 'return x;'
- component.update: my_display # - component.update: my_display
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Template Text Sensor # Template Text Sensor
@@ -70,6 +105,19 @@ text_sensor:
- platform: template - platform: template
name: "Display Status Message" name: "Display Status Message"
id: 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) # Hardware SPI Pin Mapping (ESP32-H2)
@@ -82,12 +130,18 @@ spi:
# Fonts # Fonts
# ------------------------------------------------------------------- # -------------------------------------------------------------------
font: font:
- file: "gfonts://Roboto" - file:
type: gfonts
family: Open+Sans
weight: 700
size: 20
id: font_header id: font_header
size: 18 - file:
- file: "gfonts://Roboto" type: gfonts
family: Overpass
weight: 400
id: font_body id: font_body
size: 12 size: 14
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Waveshare 2.13-inch e-Paper Display (V4 panel = 2.13inv3 in ESPHome) # Waveshare 2.13-inch e-Paper Display (V4 panel = 2.13inv3 in ESPHome)
@@ -100,16 +154,38 @@ display:
busy_pin: GPIO3 busy_pin: GPIO3
model: 2.13inv3 model: 2.13inv3
rotation: 90° rotation: 90°
update_interval: 30s update_interval: 30min
id: my_display id: my_display
lambda: |- lambda: |-
if (id(status_message).has_state()) { if (id(status_message).has_state()) {
it.print(5, 3, id(font_header), "MICROWAVE STATUS"); // Header
it.line(0, 20, it.get_width(), 20); it.print(5, 3, id(font_header), id(status_message).state.c_str());
it.print(5, 25, id(font_body), 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 { } else {
int center_x = it.get_width() / 2; int center_x = it.get_width() / 2;
int center_y = it.get_height() / 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...");
} }
+16 -9
View File
@@ -1,26 +1,32 @@
version: '3.8'
services: services:
otbr: otbr:
image: openthread/otbr:latest image: openthread/border-router:latest
container_name: otbr container_name: otbr
restart: unless-stopped restart: unless-stopped
privileged: true privileged: true
network_mode: host 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: devices:
# Change /dev/ttyACM0 if your ESP32-H2 RCP enumerates as /dev/ttyUSB0 - /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_ba78c1181566ee11b42a8d6293cd958c-if00-port0:/dev/ttyUSB1
- /dev/ttyACM0:/dev/ttyACM0 - /dev/net/tun:/dev/net/tun
command: --radio-url "spinel+hdlc+uart:///dev/ttyACM0?uart-baudrate=460800" volumes:
- otbr-data:/data
mqtt-broker: mqtt-broker:
image: eclipse-mosquitto:2.0 image: eclipse-mosquitto:2.0
container_name: mqtt-broker container_name: mqtt-broker
restart: unless-stopped restart: unless-stopped
network_mode: host
environment: environment:
MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true} MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true}
ports:
# Listens on all interfaces (IPv4 and IPv6) for Thread compatibility
- "8884:8884"
volumes: volumes:
- ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro - ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro
- ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro - ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro
@@ -31,5 +37,6 @@ services:
command: ["/bin/sh", "/scripts/start-broker.sh"] command: ["/bin/sh", "/scripts/start-broker.sh"]
volumes: volumes:
otbr-data:
mqtt-data: mqtt-data:
mqtt-log: 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 shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
from lib.systemd_logs import get_systemd_logs from lib.systemd_logs import get_systemd_logs
from external_display_manager import DisplayManager # <-- Import display manager
# --- DB SETUP --- # --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite" DB_PATH = "orchestrateur/db.sqlite"
@@ -73,6 +74,9 @@ button_state = False
cloud_alert = False # Global status flag for screen / UI display cloud_alert = False # Global status flag for screen / UI display
async_event_queue = None async_event_queue = None
# Display Manager instance
display_manager = DisplayManager(endpoint_path="/api/display")
# 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()
@@ -174,6 +178,21 @@ async def cloud_mqtt_listener_task():
}) })
await asyncio.sleep(0.1) 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(): def button_callback():
"""Button physical interrupt callback.""" """Button physical interrupt callback."""
global button_state global button_state
@@ -587,6 +606,9 @@ async def main():
# Initialize SQLite database table # Initialize SQLite database table
init_db() init_db()
# Start mDNS Display discovery
await display_manager.start()
async_event_queue = asyncio.Queue() async_event_queue = asyncio.Queue()
await asyncio.gather( await asyncio.gather(
@@ -594,7 +616,8 @@ async def main():
mqtt_listener_task(), mqtt_listener_task(),
cloud_mqtt_listener_task(), cloud_mqtt_listener_task(),
process_messages_task(), process_messages_task(),
monitor_dish_height_task() monitor_dish_height_task(),
display_broadcast_task()
) )
if __name__ == "__main__": if __name__ == "__main__":
@@ -603,6 +626,7 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nArrêt manuel.") print("\nArrêt manuel.")
finally: finally:
asyncio.run(display_manager.stop())
if hasattr(mqtt_client._client, "loop_stop"): if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop() mqtt_client._client.loop_stop()
if hasattr(cloud_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 log_type information
allow_anonymous true allow_anonymous true
listener 8884 :: listener 8884
protocol mqtt 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 log_type information
allow_anonymous true allow_anonymous true
listener 8884 :: listener 8884
protocol mqtt protocol mqtt
cafile /mosquitto/certs/ca.crt cafile /mosquitto/certs/ca.crt
certfile /mosquitto/certs/server.crt certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key keyfile /mosquitto/certs/server.key
require_certificate false 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 # picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
# OpenCV # OpenCV
# sudo apt install -y python3-opencv # sudo apt install -y python3-opencv
# sudo apt install -y opencv-data # sudo apt install -y opencv-data
zeroconf>=0.131.0