Pretty display
This commit is contained in:
+42
-10
@@ -4,16 +4,33 @@
|
|||||||
#include "esphome/components/json/json_util.h"
|
#include "esphome/components/json/json_util.h"
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
// Callback signature: void(device_id, cloud_alert, defrost_mode)
|
struct MicrowaveData {
|
||||||
using DisplayUpdateCallback = std::function<void(const std::string &device_id, bool cloud_alert, bool defrost_mode)>;
|
std::string id;
|
||||||
|
std::string name;
|
||||||
|
int state;
|
||||||
|
bool paused;
|
||||||
|
int remaining_time;
|
||||||
|
int progress;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global states parsed from the API
|
||||||
|
static std::vector<MicrowaveData> g_microwaves;
|
||||||
|
static bool g_cloud_alert = false;
|
||||||
|
static std::string g_update_time = "--:--";
|
||||||
|
static bool g_received_update = false;
|
||||||
|
|
||||||
|
// Callback signature for refreshing UI
|
||||||
|
using DisplayUpdateCallback = std::function<void()>;
|
||||||
static DisplayUpdateCallback g_display_callback = nullptr;
|
static DisplayUpdateCallback g_display_callback = nullptr;
|
||||||
|
|
||||||
class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler {
|
class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler {
|
||||||
public:
|
public:
|
||||||
bool canHandle(esphome::web_server_idf::AsyncWebServerRequest *request) const override {
|
bool canHandle(esphome::web_server_idf::AsyncWebServerRequest *request) const override {
|
||||||
return request->url_to() == "/api/display";
|
// Provide the 513-byte buffer expected by ESPHome's new memory-safe API
|
||||||
|
char url_buf[513];
|
||||||
|
return request->url_to(url_buf) == "/api/display";
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleRequest(esphome::web_server_idf::AsyncWebServerRequest *request) override {
|
void handleRequest(esphome::web_server_idf::AsyncWebServerRequest *request) override {
|
||||||
@@ -24,16 +41,31 @@ class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler {
|
|||||||
std::string body((char *)data, len);
|
std::string body((char *)data, len);
|
||||||
|
|
||||||
esphome::json::parse_json(body, [](JsonObject root) -> bool {
|
esphome::json::parse_json(body, [](JsonObject root) -> bool {
|
||||||
std::string device_id = root["device_id"] | "unknown";
|
g_cloud_alert = root["cloud_alert"] | false;
|
||||||
bool cloud_alert = root["cloud_alert"] | false;
|
g_update_time = root["update_time"] | "--:--";
|
||||||
bool defrost_mode = root["defrost_mode"] | false;
|
|
||||||
|
|
||||||
ESP_LOGI("custom_api", "Received update for %s (Alert: %d, Defrost: %d)",
|
g_microwaves.clear();
|
||||||
device_id.c_str(), cloud_alert, defrost_mode);
|
JsonArray mw_array = root["microwaves"];
|
||||||
|
|
||||||
|
for (JsonObject mw : mw_array) {
|
||||||
|
MicrowaveData md;
|
||||||
|
md.id = mw["id"] | "unknown";
|
||||||
|
md.name = mw["name"] | "Microwave";
|
||||||
|
md.state = mw["state"] | 4;
|
||||||
|
md.paused = mw["paused"] | false;
|
||||||
|
md.remaining_time = mw["remaining_time"] | 0;
|
||||||
|
md.progress = mw["progress"] | 0;
|
||||||
|
|
||||||
|
g_microwaves.push_back(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_received_update = true;
|
||||||
|
|
||||||
|
ESP_LOGI("custom_api", "Received update. Cloud Alert: %d, Microwaves: %d",
|
||||||
|
g_cloud_alert, g_microwaves.size());
|
||||||
|
|
||||||
// Execute the callback passed from YAML
|
|
||||||
if (g_display_callback != nullptr) {
|
if (g_display_callback != nullptr) {
|
||||||
g_display_callback(device_id, cloud_alert, defrost_mode);
|
g_display_callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,30 +10,11 @@ esphome:
|
|||||||
priority: -100.0
|
priority: -100.0
|
||||||
then:
|
then:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
setup_display_api([](const std::string &dev_id, bool alert, bool defrost) {
|
setup_display_api([]() {
|
||||||
// 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!
|
// Force immediate e-Paper refresh!
|
||||||
id(my_display).update();
|
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:
|
web_server:
|
||||||
port: 5000
|
port: 5000
|
||||||
|
|
||||||
@@ -102,22 +83,13 @@ openthread:
|
|||||||
# Template Text Sensor
|
# Template Text Sensor
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
text_sensor:
|
text_sensor:
|
||||||
- platform: template
|
|
||||||
name: "Display Status Message"
|
|
||||||
id: status_message
|
|
||||||
- platform: openthread_info
|
- platform: openthread_info
|
||||||
ip_address:
|
|
||||||
name: "Thread IP"
|
|
||||||
id: thread_ip
|
|
||||||
role:
|
role:
|
||||||
name: "Thread Role"
|
name: "Thread Role"
|
||||||
id: thread_role
|
id: thread_role
|
||||||
channel:
|
channel:
|
||||||
name: "Thread Channel"
|
name: "Thread Channel"
|
||||||
id: thread_channel
|
id: thread_channel
|
||||||
rloc16:
|
|
||||||
name: "Thread RLOC16"
|
|
||||||
id: thread_rloc16
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Hardware SPI Pin Mapping (ESP32-H2)
|
# Hardware SPI Pin Mapping (ESP32-H2)
|
||||||
@@ -139,12 +111,18 @@ font:
|
|||||||
- file:
|
- file:
|
||||||
type: gfonts
|
type: gfonts
|
||||||
family: Overpass
|
family: Overpass
|
||||||
weight: 400
|
weight: 600
|
||||||
id: font_body
|
id: font_body
|
||||||
size: 14
|
size: 14
|
||||||
|
- file:
|
||||||
|
type: gfonts
|
||||||
|
family: Overpass
|
||||||
|
weight: 500
|
||||||
|
id: font_small
|
||||||
|
size: 12
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Waveshare 2.13-inch e-Paper Display (V4 panel = 2.13inv3 in ESPHome)
|
# Waveshare 2.13-inch e-Paper Display (250x122 standard resolution)
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
display:
|
display:
|
||||||
- platform: waveshare_epaper
|
- platform: waveshare_epaper
|
||||||
@@ -157,35 +135,75 @@ display:
|
|||||||
update_interval: 30min
|
update_interval: 30min
|
||||||
id: my_display
|
id: my_display
|
||||||
lambda: |-
|
lambda: |-
|
||||||
if (id(status_message).has_state()) {
|
// --- STARTUP SCREEN ---
|
||||||
// Header
|
if (!g_received_update) {
|
||||||
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_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 - 10, 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...");
|
it.printf(center_x, center_y + 15, id(font_body), TextAlign::CENTER, "Waiting for updates...");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MICROWAVE LAYOUT ---
|
||||||
|
int y_offset = 0;
|
||||||
|
for (const auto& mw : g_microwaves) {
|
||||||
|
// Name
|
||||||
|
it.print(0, y_offset, id(font_header), mw.name.c_str());
|
||||||
|
|
||||||
|
// Decode State Text
|
||||||
|
std::string state_str;
|
||||||
|
switch(mw.state) {
|
||||||
|
case 0: state_str = mw.paused ? "PAUSED" : "COOKING"; break;
|
||||||
|
case 1: state_str = "STIRRING REQ."; break;
|
||||||
|
case 2: state_str = "DONE"; break;
|
||||||
|
case 3: state_str = "ALERT"; break;
|
||||||
|
case 4: state_str = "IDLE"; break;
|
||||||
|
default: state_str = "UNKNOWN"; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print Status (Top Right Align)
|
||||||
|
it.printf(it.get_width(), y_offset + 5, id(font_body), TextAlign::TOP_RIGHT, "%s", state_str.c_str());
|
||||||
|
|
||||||
|
// Process active/cooking visualisations
|
||||||
|
if (mw.state == 0) {
|
||||||
|
int bar_y = y_offset + 28;
|
||||||
|
int bar_w = 175;
|
||||||
|
int bar_h = 16;
|
||||||
|
|
||||||
|
// Draw progress bar outline & fill
|
||||||
|
it.rectangle(0, bar_y, bar_w, bar_h);
|
||||||
|
if (mw.progress > 0) {
|
||||||
|
int fill_w = (mw.progress * bar_w) / 100;
|
||||||
|
it.filled_rectangle(0, bar_y, fill_w, bar_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw time remaining (or paused state)
|
||||||
|
if (mw.paused) {
|
||||||
|
it.printf(185, bar_y + 1, id(font_body), "PAUSED");
|
||||||
|
} else {
|
||||||
|
int mins = mw.remaining_time / 60;
|
||||||
|
int secs = mw.remaining_time % 60;
|
||||||
|
it.printf(185, bar_y + 1, id(font_body), "%02d:%02d", mins, secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (mw.state == 1) { // Stirring Required
|
||||||
|
it.filled_rectangle(0, y_offset + 28, 250, 20);
|
||||||
|
it.printf(125, y_offset + 28, id(font_body), COLOR_OFF, TextAlign::TOP_CENTER, "ACTION REQUIRED: STIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move cursor down (if we have more than 1 microwave on future larger screens)
|
||||||
|
y_offset += 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SYSTEM FOOTER ---
|
||||||
|
// Line separator right above footer
|
||||||
|
it.line(0, 102, it.get_width(), 102);
|
||||||
|
|
||||||
|
// Bottom Left: Cloud Reachability & Last Update Time
|
||||||
|
std::string cloud_status = g_cloud_alert ? "Cloud: OK" : "Cloud: ERR";
|
||||||
|
it.printf(0, 105, id(font_small), "%s | %s", cloud_status.c_str(), g_update_time.c_str());
|
||||||
|
|
||||||
|
// Bottom Right: OpenThread Diagnostics
|
||||||
|
std::string role = id(thread_role).state;
|
||||||
|
it.printf(it.get_width(), 105, id(font_small), TextAlign::TOP_RIGHT,
|
||||||
|
"Th: %s (Ch %s)", role.c_str(), id(thread_channel).state.c_str());
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
import ipaddress
|
import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from zeroconf import ServiceStateChange
|
from zeroconf import ServiceStateChange
|
||||||
from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo
|
from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo
|
||||||
@@ -55,20 +55,45 @@ class DisplayManager:
|
|||||||
print(f"[DisplayManager] ❌ Screen disconnected: {name}")
|
print(f"[DisplayManager] ❌ Screen disconnected: {name}")
|
||||||
self.screens.pop(name, None)
|
self.screens.pop(name, None)
|
||||||
|
|
||||||
async def broadcast_state(self, device_id: str, microwave_states: dict, button_state: bool, cloud_alert: bool):
|
async def broadcast_state(self, microwave_states: dict, cloud_alert: bool):
|
||||||
"""Broadcasts the system state JSON to all discovered screens concurrently."""
|
"""Broadcasts the system state JSON to all discovered screens concurrently."""
|
||||||
if not self.screens:
|
if not self.screens:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
now_time = time.time()
|
||||||
|
update_time_str = datetime.datetime.now().strftime("%H:%M")
|
||||||
|
|
||||||
|
microwaves_list = []
|
||||||
|
for mw_id, state_info in microwave_states.items():
|
||||||
|
# Extract state properties safely
|
||||||
|
state_val = state_info.cooking_state
|
||||||
|
paused = state_info.paused
|
||||||
|
rem_time = state_info.cooking_estimated_remaining_time
|
||||||
|
start_t = state_info.cooking_start_time
|
||||||
|
name = state_info.friendly_name
|
||||||
|
|
||||||
|
# Calculate progress percentage (0 to 100)
|
||||||
|
progress = 0
|
||||||
|
if start_t > 0 and rem_time > 0:
|
||||||
|
elapsed = now_time - start_t
|
||||||
|
total = elapsed + rem_time
|
||||||
|
if total > 0:
|
||||||
|
progress = int((elapsed / total) * 100)
|
||||||
|
progress = max(0, min(100, progress))
|
||||||
|
|
||||||
|
microwaves_list.append({
|
||||||
|
"id": mw_id,
|
||||||
|
"name": name,
|
||||||
|
"state": state_val,
|
||||||
|
"paused": paused,
|
||||||
|
"remaining_time": rem_time,
|
||||||
|
"progress": progress
|
||||||
|
})
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"device_id": device_id,
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
"cloud_alert": cloud_alert,
|
"cloud_alert": cloud_alert,
|
||||||
"defrost_mode": button_state,
|
"update_time": update_time_str,
|
||||||
"microwaves": [
|
"microwaves": microwaves_list
|
||||||
{"id": mw_id, "state": state}
|
|
||||||
for mw_id, state in microwave_states.items()
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Dispatch POST requests in parallel without blocking the main loop
|
# Dispatch POST requests in parallel without blocking the main loop
|
||||||
|
|||||||
+82
-42
@@ -4,6 +4,7 @@ import time
|
|||||||
import asyncio
|
import asyncio
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from orchestrateur.microwave_state import MicrowaveState
|
||||||
from orchestrateur.sensors import gps
|
from orchestrateur.sensors import gps
|
||||||
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
@@ -12,7 +13,10 @@ 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
|
from external_display_manager import DisplayManager
|
||||||
|
|
||||||
|
# --- CONFIGURATION CONSTANTS ---
|
||||||
|
DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting
|
||||||
|
|
||||||
# --- DB SETUP ---
|
# --- DB SETUP ---
|
||||||
DB_PATH = "orchestrateur/db.sqlite"
|
DB_PATH = "orchestrateur/db.sqlite"
|
||||||
@@ -60,16 +64,27 @@ def get_device_id():
|
|||||||
|
|
||||||
DEVICE_ID = get_device_id()
|
DEVICE_ID = get_device_id()
|
||||||
|
|
||||||
# --- STATE MACHINE DEFINITIONS ---
|
# --- DISPLAY QUEUE & NOTIFICATION HELPERS ---
|
||||||
class MicrowaveState:
|
display_update_queue = None
|
||||||
IDLE = "IDLE" # Microwave is empty
|
|
||||||
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
def notify_display_update():
|
||||||
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
"""Safely adds an update event timestamp to the display update queue."""
|
||||||
COOKING = "COOKING" # Microwave is active
|
if display_update_queue is not None:
|
||||||
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.call_soon_threadsafe(display_update_queue.put_nowait, time.time())
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_cloud_alert(state: bool):
|
||||||
|
"""Setter for cloud_alert that triggers a display update when changed."""
|
||||||
|
global cloud_alert
|
||||||
|
if cloud_alert != state:
|
||||||
|
cloud_alert = state
|
||||||
|
notify_display_update()
|
||||||
|
|
||||||
# Global state trackers
|
# Global state trackers
|
||||||
microwave_states = {"2": MicrowaveState.IDLE}
|
microwave_states = {"2": MicrowaveState("2", on_change_callback=notify_display_update)}
|
||||||
button_state = False
|
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
|
||||||
@@ -178,25 +193,40 @@ async def cloud_mqtt_listener_task():
|
|||||||
})
|
})
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
async def display_broadcast_task():
|
async def display_broadcast_worker_task():
|
||||||
"""Monitors state changes and broadcasts updates to all connected screens."""
|
"""
|
||||||
last_state = None
|
Debounced display broadcast task.
|
||||||
|
Waits until DISPLAY_DEBOUNCE_SECONDS have passed since the first queued update
|
||||||
|
before sending state to all connected screens.
|
||||||
|
"""
|
||||||
|
print("[Display Worker] Started debounced display broadcast worker.")
|
||||||
while True:
|
while True:
|
||||||
current_state = (dict(microwave_states), button_state, cloud_alert)
|
# Block until the FIRST update reminder arrives in the queue
|
||||||
if current_state != last_state:
|
first_event_time = await display_update_queue.get()
|
||||||
last_state = current_state
|
|
||||||
await display_manager.broadcast_state(
|
# Compute wait time relative to the first event's timestamp
|
||||||
device_id=DEVICE_ID,
|
elapsed = time.time() - first_event_time
|
||||||
microwave_states=microwave_states,
|
remaining_wait = DISPLAY_DEBOUNCE_SECONDS - elapsed
|
||||||
button_state=button_state,
|
if remaining_wait > 0:
|
||||||
cloud_alert=cloud_alert
|
await asyncio.sleep(remaining_wait)
|
||||||
)
|
|
||||||
await asyncio.sleep(3.0)
|
# Clear any subsequent events that arrived during the wait period
|
||||||
|
while not display_update_queue.empty():
|
||||||
|
try:
|
||||||
|
display_update_queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Broadcast state ONCE
|
||||||
|
await display_manager.broadcast_state(
|
||||||
|
microwave_states=microwave_states,
|
||||||
|
cloud_alert=cloud_alert
|
||||||
|
)
|
||||||
|
|
||||||
def button_callback():
|
def button_callback():
|
||||||
"""Button physical interrupt callback."""
|
"""Button physical interrupt callback."""
|
||||||
global button_state
|
global button_state
|
||||||
if microwave_states.get("2") == MicrowaveState.COOKING or microwave_states.get("2") == MicrowaveState.DONE:
|
if microwave_states.get("2").cooking_state == CookingStates.COOKING or microwave_states.get("2").cooking_state == CookingStates.DONE or microwave_states.get("2").cooking_state == CookingStates.STIRRING_REQUIRED:
|
||||||
print("[Button] Toggling pause/resume for microwave '2'.")
|
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
||||||
else:
|
else:
|
||||||
@@ -227,7 +257,7 @@ def on_new_alert(alert):
|
|||||||
# Send the alert to the lora device
|
# Send the alert to the lora device
|
||||||
lora.send_reliable(payloads.lora_new_alert(alert))
|
lora.send_reliable(payloads.lora_new_alert(alert))
|
||||||
|
|
||||||
# TODO : envoi de l'alerte aux écrans externes
|
microwave_states["2"].set_cooking_state(CookingStates.ALERT)
|
||||||
|
|
||||||
# --- HARDWARE CONTROLLERS ---
|
# --- HARDWARE CONTROLLERS ---
|
||||||
def _stop_hardware(microwave_id: str):
|
def _stop_hardware(microwave_id: str):
|
||||||
@@ -264,7 +294,7 @@ def read_local_sensors(microwave_id, initial_dish_height):
|
|||||||
|
|
||||||
async def handle_new_dish(microwave_id, detected_height):
|
async def handle_new_dish(microwave_id, detected_height):
|
||||||
"""Triggered when a new dish is placed inside."""
|
"""Triggered when a new dish is placed inside."""
|
||||||
microwave_states[microwave_id] = MicrowaveState.ANALYZING
|
microwave_states[microwave_id].set_state(MicrowaveState.ANALYZING)
|
||||||
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
|
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
|
||||||
|
|
||||||
# 1. Setup synchronization event and clear previous cache for this microwave
|
# 1. Setup synchronization event and clear previous cache for this microwave
|
||||||
@@ -286,7 +316,7 @@ async def handle_new_dish(microwave_id, detected_height):
|
|||||||
sensors_data = await sensor_task
|
sensors_data = await sensor_task
|
||||||
|
|
||||||
# Check if dish was removed while reading sensors
|
# Check if dish was removed while reading sensors
|
||||||
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
|
if microwave_states.get(microwave_id).state != MicrowaveState.ANALYZING:
|
||||||
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
|
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
|
||||||
ir_data_events.pop(microwave_id, None)
|
ir_data_events.pop(microwave_id, None)
|
||||||
return
|
return
|
||||||
@@ -310,7 +340,7 @@ async def handle_new_dish(microwave_id, detected_height):
|
|||||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||||
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
|
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
|
||||||
global cloud_alert, alert_manager
|
global cloud_alert, alert_manager
|
||||||
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
microwave_states[microwave_id].set_state(MicrowaveState.WAITING_FOR_CLOUD)
|
||||||
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||||
|
|
||||||
# Format image
|
# Format image
|
||||||
@@ -326,7 +356,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
HTTP_TIMEOUT = (10, 330)
|
HTTP_TIMEOUT = (10, 330)
|
||||||
|
|
||||||
for attempt in range(1, max_retries + 1):
|
for attempt in range(1, max_retries + 1):
|
||||||
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
|
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -341,7 +371,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Abort if state changed while waiting for cloud AI response
|
# Abort if state changed while waiting for cloud AI response
|
||||||
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -353,7 +383,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
# Error handling
|
# Error handling
|
||||||
if "error" in res_json:
|
if "error" in res_json:
|
||||||
print(f"[{microwave_id}] Cloud API returned error: {res_json['error']}")
|
print(f"[{microwave_id}] Cloud API returned error: {res_json['error']}")
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
|
||||||
|
|
||||||
if not res_json['is_safe']: # The cooking area is not safe, raise an alert
|
if not res_json['is_safe']: # The cooking area is not safe, raise an alert
|
||||||
alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message'])
|
alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message'])
|
||||||
@@ -371,12 +401,12 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
|
|
||||||
if c_time is None or c_power is None or c_temp is None:
|
if c_time is None or c_power is None or c_temp is None:
|
||||||
print(f"[{microwave_id}] ❌ Invalid plan received: {res_json}")
|
print(f"[{microwave_id}] ❌ Invalid plan received: {res_json}")
|
||||||
microwave_states[microwave_id] = None
|
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||||
cloud_alert = False # Reset alert flag on success
|
cloud_alert = False # Reset alert flag on success
|
||||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
microwave_states[microwave_id].set_state(MicrowaveState.COOKING)
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
c_time = 20 # Set to 20s for debug
|
c_time = 20 # Set to 20s for debug
|
||||||
@@ -399,7 +429,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
|
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
|
||||||
# Interruptible wait loop in case state changes mid-wait
|
# Interruptible wait loop in case state changes mid-wait
|
||||||
for _ in range(retry_delay_seconds):
|
for _ in range(retry_delay_seconds):
|
||||||
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
|
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
|
||||||
return
|
return
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
@@ -407,7 +437,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
# Executed only if all 3 retries failed
|
# Executed only if all 3 retries failed
|
||||||
print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
|
print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
|
||||||
cloud_alert = True
|
cloud_alert = True
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
|
||||||
|
|
||||||
# --- MAIN LOGIC TASKS ---
|
# --- MAIN LOGIC TASKS ---
|
||||||
async def process_messages_task():
|
async def process_messages_task():
|
||||||
@@ -427,11 +457,13 @@ async def process_messages_task():
|
|||||||
n_state = data["data"].get("new_cooking_state")
|
n_state = data["data"].get("new_cooking_state")
|
||||||
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
|
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
|
||||||
|
|
||||||
|
microwave_states[mw_id].set_cooking_state(n_state)
|
||||||
|
|
||||||
if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
|
if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
|
||||||
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
|
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
|
||||||
buzzer.buzzer_siren(beatsNb=beats)
|
buzzer.buzzer_siren(beatsNb=beats)
|
||||||
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
|
if n_state == CookingStates.IDLE and microwave_states.get(mw_id).state == MicrowaveState.COOKING:
|
||||||
microwave_states[mw_id] = MicrowaveState.DONE
|
microwave_states[mw_id].set_state(MicrowaveState.DONE)
|
||||||
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
||||||
|
|
||||||
elif source == "MQTT":
|
elif source == "MQTT":
|
||||||
@@ -450,6 +482,13 @@ async def process_messages_task():
|
|||||||
# Offload DB insertion to async thread execution pool
|
# Offload DB insertion to async thread execution pool
|
||||||
await asyncio.to_thread(save_connected_component, component_id, component_type)
|
await asyncio.to_thread(save_connected_component, component_id, component_type)
|
||||||
|
|
||||||
|
# Pass change callback to newly connected microwave
|
||||||
|
microwave_states[component_id] = MicrowaveState(
|
||||||
|
component_id,
|
||||||
|
on_change_callback=notify_display_update
|
||||||
|
)
|
||||||
|
notify_display_update()
|
||||||
|
|
||||||
mqtt_client.publish(
|
mqtt_client.publish(
|
||||||
config.MQTT_TOPIC_HELLO,
|
config.MQTT_TOPIC_HELLO,
|
||||||
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
|
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
|
||||||
@@ -497,7 +536,7 @@ async def handle_telemetry_request(endpoint: str):
|
|||||||
# 3. Build the telemetry payload (returns a JSON string)
|
# 3. Build the telemetry payload (returns a JSON string)
|
||||||
telemetry_payload = payloads.telemetry_payload(
|
telemetry_payload = payloads.telemetry_payload(
|
||||||
device_id=DEVICE_ID,
|
device_id=DEVICE_ID,
|
||||||
microwave_states=microwave_states,
|
microwave_states=microwave_states.to_dict(),
|
||||||
button_state=button_state,
|
button_state=button_state,
|
||||||
cloud_alert=cloud_alert,
|
cloud_alert=cloud_alert,
|
||||||
gps_data=gps.get_gps_data(),
|
gps_data=gps.get_gps_data(),
|
||||||
@@ -556,7 +595,7 @@ async def monitor_dish_height_task():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
dist = await get_filtered_dish_height()
|
dist = await get_filtered_dish_height()
|
||||||
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
|
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE).state
|
||||||
|
|
||||||
if dist is not None:
|
if dist is not None:
|
||||||
# Hysteresis Thresholds:
|
# Hysteresis Thresholds:
|
||||||
@@ -582,8 +621,8 @@ async def monitor_dish_height_task():
|
|||||||
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
|
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
|
||||||
consecutive_absent = 0
|
consecutive_absent = 0
|
||||||
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
||||||
microwave_states[mw_id] = MicrowaveState.IDLE
|
microwave_states[mw_id].set_state(MicrowaveState.IDLE)
|
||||||
cloud_alert = False # Reset error alert on dish removal
|
set_cloud_alert(False)
|
||||||
|
|
||||||
if current_state == MicrowaveState.COOKING:
|
if current_state == MicrowaveState.COOKING:
|
||||||
_stop_hardware(mw_id)
|
_stop_hardware(mw_id)
|
||||||
@@ -595,7 +634,7 @@ async def monitor_dish_height_task():
|
|||||||
|
|
||||||
# --- BOOTSTRAP ---
|
# --- BOOTSTRAP ---
|
||||||
async def main():
|
async def main():
|
||||||
global async_event_queue
|
global async_event_queue, display_update_queue
|
||||||
print("Orchestrateur prêt. Lancement des tâches...")
|
print("Orchestrateur prêt. Lancement des tâches...")
|
||||||
|
|
||||||
# Initialize the alert manager and set the callback
|
# Initialize the alert manager and set the callback
|
||||||
@@ -610,6 +649,7 @@ async def main():
|
|||||||
await display_manager.start()
|
await display_manager.start()
|
||||||
|
|
||||||
async_event_queue = asyncio.Queue()
|
async_event_queue = asyncio.Queue()
|
||||||
|
display_update_queue = asyncio.Queue()
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
lora_listener_task(),
|
lora_listener_task(),
|
||||||
@@ -617,7 +657,7 @@ async def main():
|
|||||||
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()
|
display_broadcast_worker_task()
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from shared.cookingState import CookingStates
|
||||||
|
import shared.config as config
|
||||||
|
|
||||||
|
class MicrowaveState:
|
||||||
|
state = None
|
||||||
|
paused = False
|
||||||
|
cooking_state = None
|
||||||
|
cooking_estimated_remaining_time = 0
|
||||||
|
cooking_start_time = 0
|
||||||
|
|
||||||
|
def __init__(self, id, on_change_callback=None):
|
||||||
|
self.friendly_name = f"Microwave-{id}"
|
||||||
|
self.state = MicrowaveState.IDLE
|
||||||
|
self.cooking_state = CookingStates.IDLE
|
||||||
|
self.on_change_callback = on_change_callback
|
||||||
|
|
||||||
|
def _notify_change(self):
|
||||||
|
"""Invokes callback if registered on state attribute changes."""
|
||||||
|
if self.on_change_callback:
|
||||||
|
try:
|
||||||
|
self.on_change_callback()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[MicrowaveState] Error in change callback: {e}")
|
||||||
|
|
||||||
|
def set_state(self, new_state):
|
||||||
|
if config.DEBUG:
|
||||||
|
assert new_state in (
|
||||||
|
MicrowaveState.IDLE,
|
||||||
|
MicrowaveState.ANALYZING,
|
||||||
|
MicrowaveState.WAITING_FOR_CLOUD,
|
||||||
|
MicrowaveState.COOKING,
|
||||||
|
MicrowaveState.DONE
|
||||||
|
), f"Invalid state: {new_state}"
|
||||||
|
|
||||||
|
if self.state != new_state:
|
||||||
|
self.state = new_state
|
||||||
|
self._notify_change()
|
||||||
|
|
||||||
|
def set_paused(self, is_paused):
|
||||||
|
if self.paused != is_paused:
|
||||||
|
self.paused = is_paused
|
||||||
|
self._notify_change()
|
||||||
|
|
||||||
|
def toggle_pause(self):
|
||||||
|
self.paused = not self.paused
|
||||||
|
self._notify_change()
|
||||||
|
|
||||||
|
def set_cooking_state(self, new_cooking_state):
|
||||||
|
if config.DEBUG:
|
||||||
|
assert new_cooking_state in (
|
||||||
|
CookingStates.IDLE,
|
||||||
|
CookingStates.COOKING,
|
||||||
|
CookingStates.DONE,
|
||||||
|
CookingStates.STIRRING_REQUIRED,
|
||||||
|
CookingStates.ALERT
|
||||||
|
), "Invalid cooking state: " + str(new_cooking_state)
|
||||||
|
|
||||||
|
if self.cooking_state != new_cooking_state:
|
||||||
|
self.cooking_state = new_cooking_state
|
||||||
|
self._notify_change()
|
||||||
|
|
||||||
|
def set_cooking_estimated_remaining_time(self, time_seconds):
|
||||||
|
if self.cooking_estimated_remaining_time != time_seconds:
|
||||||
|
self.cooking_estimated_remaining_time = time_seconds
|
||||||
|
self._notify_change()
|
||||||
|
|
||||||
|
def set_cooking_start_time(self, start_time):
|
||||||
|
if self.cooking_start_time != start_time:
|
||||||
|
self.cooking_start_time = start_time
|
||||||
|
self._notify_change()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"friendly_name": self.friendly_name,
|
||||||
|
"state": self.state,
|
||||||
|
"paused": self.paused,
|
||||||
|
"cooking_estimated_remaining_time": self.cooking_estimated_remaining_time,
|
||||||
|
"cooking_start_time": self.cooking_start_time
|
||||||
|
}
|
||||||
|
|
||||||
|
IDLE = "IDLE" # Microwave is empty
|
||||||
|
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
||||||
|
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
||||||
|
COOKING = "COOKING" # Microwave is active
|
||||||
|
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
# Debugging and others
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
DEBUG_DANGEROUS_AREA=False
|
||||||
|
|
||||||
# LoRa
|
# LoRa
|
||||||
LORA_HEARTBEAT_INTERVAL = 30
|
LORA_HEARTBEAT_INTERVAL = 30
|
||||||
|
|||||||
Reference in New Issue
Block a user