External Screen working
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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...");
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user