feat(mqtt): FR-012 Home Assistant MQTT Discovery (v1.5.0)

- MQTT_TOPIC_AVAILABILITY + MQTT_DISCOVERY_PREFIX in config.h
- LWT auf lasercutter/availability (online/offline, retained)
- publishDiscovery(): 8 retained Entities nach jedem (Re-)Connect
    binary_sensor: laser_active (device_class: running)
    sensor: session_sum (min), session_sec (s)
    sensor: total_time (hh:mm via Jinja2 value_template)
    sensor: firmware (diagnostic)
    switch: display (state aus status.display_on, cmd -> lasercutter/cmd)
    button: reset_session, reboot (entity_category: config)
- Heartbeat: neue Felder laser_active, session_minutes_sum,
    session_seconds, total_minutes fuer HA value_templates
- PubSubClient Buffer: 512 -> 1024 (Discovery-Nachrichten ~640 Bytes)
This commit is contained in:
MaPaLo76 2026-03-04 19:40:54 +01:00
parent d8c9489e21
commit ae3e40f98d
4 changed files with 164 additions and 16 deletions

View File

@ -70,9 +70,11 @@
#define MQTT_HEARTBEAT_MS 60000 // Heartbeat-Intervall in ms
// MQTT Topics
#define MQTT_TOPIC_SESSION "lasercutter/session" // Publish beim Session-Ende
#define MQTT_TOPIC_STATUS "lasercutter/status" // Publish Heartbeat
#define MQTT_TOPIC_CMD "lasercutter/cmd" // Subscribe Steuerkommandos
#define MQTT_TOPIC_SESSION "lasercutter/session" // Publish beim Session-Ende
#define MQTT_TOPIC_STATUS "lasercutter/status" // Publish Heartbeat
#define MQTT_TOPIC_CMD "lasercutter/cmd" // Subscribe Steuerkommandos
#define MQTT_TOPIC_AVAILABILITY "lasercutter/availability" // LWT + online/offline (HA Discovery)
#define MQTT_DISCOVERY_PREFIX "homeassistant" // HA MQTT Discovery Prefix
// -----------------------------------------------------------------------------
// WiFiManager

View File

@ -61,6 +61,9 @@ public:
// Session-Zähler zurücksetzen (bei reset / reset_session Kommando)
void resetSessionCounter();
// Home Assistant MQTT Discovery (retained Config-Nachrichten für alle Entities)
void publishDiscovery();
// Debug-Ausgabe auf Serial
void printToSerial();

View File

@ -23,7 +23,7 @@ build_flags =
-DCORE_DEBUG_LEVEL=1 ; 0=keine, 1=Fehler, 3=Info, 5=Verbose
-DARDUINO_LOOP_STACK_SIZE=8192
-DELEGANTOTA_USE_ASYNC_WEBSERVER=1
-DFIRMWARE_VERSION='"1.4.1"' ; Semantic Versioning hier erhöhen bei neuem Release
-DFIRMWARE_VERSION='"1.5.0"' ; Semantic Versioning hier erhöhen bei neuem Release
lib_deps =
majicDesigns/MD_Parola @ ^3.7.3
majicDesigns/MD_MAX72XX @ ^3.5.1
@ -59,7 +59,10 @@ monitor_rts = 0
;upload_port = 172.30.30.90
upload_port = 192.168.2.62
upload_protocol = espota
; upload_flags = --auth=DEIN_WEBPASSWORT ; <-- auskommentieren und anpassen wenn Passwort gesetzt
upload_flags = --auth=${sysenv.LASERCUTTER_OTA_PW}
; Vor dem Upload in PowerShell: $env:LASERCUTTER_OTA_PW = "DeinPasswort"
; Vor dem Upload in CMD: set LASERCUTTER_OTA_PW=DeinPasswort
; Kein Passwort gesetzt: Variable leer lassen oder weglassen (espota ignoriert leeren --auth)
; =============================================================================
; OTA ENVIRONMENT (HTTP) Firmware via ElegantOTA Webinterface

View File

@ -172,26 +172,33 @@ bool MqttClient::_doPublishSession(int lastSessionSec, int gratisSec, time_t sta
// publishHeartbeat() - Status-Heartbeat
// --------------------------------------------------------------------------
void MqttClient::publishHeartbeat() {
char totalBuf[12];
snprintf(totalBuf, sizeof(totalBuf), "%.2f", laserTracker.getTotalMinutes());
char ipBuf[16];
IPAddress ip = WiFi.localIP();
snprintf(ipBuf, sizeof(ipBuf), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
int totalMin = (int)laserTracker.getTotalMinutes();
// Kein ArduinoJson (JsonDocument) reine snprintf-Formatierung, kein Heap-Alloc
char buf[300];
char buf[512];
snprintf(buf, sizeof(buf),
"{\"online\":true,"
"\"laser_active\":%s,"
"\"session_sum\":%d,"
"\"machine_running_time_min\":\"%s\","
"\"session_minutes_sum\":%d,"
"\"session_seconds\":%d,"
"\"machine_running_time_min\":\"%.2f\","
"\"total_minutes\":%d,"
"\"display_on\":%s,"
"\"ip\":\"%s\","
"\"uptime_s\":%lu,"
"\"firmware_version\":\"%s (%s)\","
"\"reset_reason\":\"%s\"}",
laserTracker.isActive() ? "true" : "false",
laserTracker.getAllSessionsSumMinutes(),
totalBuf,
laserTracker.getAllSessionsSumMinutes(),
laserTracker.getRunningSessionSeconds(),
laserTracker.getTotalMinutes(),
totalMin,
display.isEnabled() ? "true" : "false",
ipBuf,
(unsigned long)(millis() / 1000UL),
@ -202,6 +209,137 @@ void MqttClient::publishHeartbeat() {
LOG_I("MQTT", "Heartbeat: %s -> %s", buf, ok ? "OK" : "FEHLER");
}
// --------------------------------------------------------------------------
// publishDiscovery() Home Assistant MQTT Discovery
// Publiziert retained Config-Nachrichten für alle 8 Entities als ein Device.
// Wird nach jedem erfolgreichen (Re-)Connect aufgerufen.
// --------------------------------------------------------------------------
void MqttClient::publishDiscovery() {
// Device-Block in jede Entity-Config identisch eingebettet
char dev[220];
snprintf(dev, sizeof(dev),
"\"device\":{"
"\"identifiers\":[\"lasercutter-display\"],"
"\"name\":\"Laser Cutter Display\","
"\"model\":\"ESP32 MQTT Display\","
"\"manufacturer\":\"Custom\","
"\"sw_version\":\"%s (%s)\"}", FIRMWARE_VERSION, __DATE__);
// Availability-Schlüssel (gemeinsam für alle Entities)
const char* av = "\"availability_topic\":\"" MQTT_TOPIC_AVAILABILITY "\"";
char t[90]; // Topic-Buffer
char p[680]; // Payload-Buffer
// ---- 1. binary_sensor Laser aktiv ------------------------------------
snprintf(t, sizeof(t), "%s/binary_sensor/lasercutter-display/laser_active/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Laser aktiv\","
"\"unique_id\":\"lc_laser_active\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{{ value_json.laser_active }}\","
"\"payload_on\":\"True\",\"payload_off\":\"False\","
"\"device_class\":\"running\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 2. sensor Laserzeit Summe ---------------------------------------
snprintf(t, sizeof(t), "%s/sensor/lasercutter-display/session_sum/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Laserzeit Summe\","
"\"unique_id\":\"lc_session_sum\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{{ value_json.session_minutes_sum }}\","
"\"unit_of_measurement\":\"min\","
"\"icon\":\"mdi:timer\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 3. sensor Laserzeit Aktuell -------------------------------------
snprintf(t, sizeof(t), "%s/sensor/lasercutter-display/session_sec/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Laserzeit Aktuell\","
"\"unique_id\":\"lc_session_sec\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{{ value_json.session_seconds }}\","
"\"unit_of_measurement\":\"s\","
"\"icon\":\"mdi:timer-outline\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 4. sensor Gesamtzeit (hh:mm via Jinja2) -------------------------
snprintf(t, sizeof(t), "%s/sensor/lasercutter-display/total_time/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Gesamtzeit\","
"\"unique_id\":\"lc_total_time\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{% set m=value_json.total_minutes|int %}"
"{{'%%02d:%%02d'|format(m//60,m%%60)}}\","
"\"icon\":\"mdi:counter\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 5. sensor Firmware ----------------------------------------------
snprintf(t, sizeof(t), "%s/sensor/lasercutter-display/firmware/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Firmware\","
"\"unique_id\":\"lc_firmware\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{{ value_json.firmware_version }}\","
"\"entity_category\":\"diagnostic\","
"\"icon\":\"mdi:chip\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 6. switch Display -----------------------------------------------
// payload_on/off: das sind die MQTT-Payloads die HA bei Toggle sendet
snprintf(t, sizeof(t), "%s/switch/lasercutter-display/display/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Display\","
"\"unique_id\":\"lc_display\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{{ 'ON' if value_json.display_on else 'OFF' }}\","
"\"command_topic\":\"" MQTT_TOPIC_CMD "\","
"\"payload_on\":\"{\\\"display\\\":true}\","
"\"payload_off\":\"{\\\"display\\\":false}\","
"\"icon\":\"mdi:television\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 7. button Session zurücksetzen ----------------------------------
snprintf(t, sizeof(t), "%s/button/lasercutter-display/reset_session/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Session zuruecksetzen\","
"\"unique_id\":\"lc_reset_session\","
"\"command_topic\":\"" MQTT_TOPIC_CMD "\","
"\"payload_press\":\"{\\\"reset_session_ram\\\":true}\","
"\"entity_category\":\"config\","
"\"icon\":\"mdi:timer-off\",%s,%s}", av, dev);
_client->publish(t, p, true);
vTaskDelay(pdMS_TO_TICKS(50));
// ---- 8. button Neustart ----------------------------------------------
snprintf(t, sizeof(t), "%s/button/lasercutter-display/reboot/config",
MQTT_DISCOVERY_PREFIX);
snprintf(p, sizeof(p),
"{\"name\":\"Neustart\","
"\"unique_id\":\"lc_reboot\","
"\"command_topic\":\"" MQTT_TOPIC_CMD "\","
"\"payload_press\":\"{\\\"reboot\\\":true}\","
"\"entity_category\":\"config\","
"\"icon\":\"mdi:restart\",%s,%s}", av, dev);
_client->publish(t, p, true);
LOG_I("MQTT", "HA Discovery: 8 Entities publiziert (retained)");
}
// --------------------------------------------------------------------------
// _rebuildClient() - TLS-Client sauber abreissen und neu aufbauen
// MUSS auf Core 0 aufgerufen werden (mbedtls Heap-Kontext-Binding)
@ -235,7 +373,7 @@ void MqttClient::_rebuildClient() {
_client->setServer(_broker, _port);
_client->setCallback(MqttClient::onMessage);
_client->setKeepAlive(60);
_client->setBufferSize(512);
_client->setBufferSize(1024); // Discovery-Nachrichten bis ~640 Bytes + MQTT-Header
LOG_I("MQTT", "Client neu aufgebaut (sauberer TLS-Kontext)");
}
@ -249,24 +387,26 @@ bool MqttClient::reconnect() {
const char* user = cfg.mqttUser[0] != '\0' ? cfg.mqttUser : nullptr;
const char* pass = cfg.mqttPassword[0] != '\0' ? cfg.mqttPassword : nullptr;
const char* lwtPayload = "{\"online\":false}";
const char* lwtPayload = "offline";
LOG_I("MQTT", "Verbinde als '%s'...", _clientId);
bool ok;
if (user && pass) {
ok = _client->connect(_clientId, user, pass,
MQTT_TOPIC_STATUS, 0, true, lwtPayload);
MQTT_TOPIC_AVAILABILITY, 0, true, lwtPayload);
} else {
ok = _client->connect(_clientId,
nullptr, nullptr,
MQTT_TOPIC_STATUS, 0, true, lwtPayload);
MQTT_TOPIC_AVAILABILITY, 0, true, lwtPayload);
}
if (ok) {
LOG_I("MQTT", "Verbunden!");
_client->publish(MQTT_TOPIC_AVAILABILITY, "online", /*retained=*/true);
_client->subscribe(MQTT_TOPIC_CMD);
LOG_I("MQTT", "Abonniert: %s", MQTT_TOPIC_CMD);
publishDiscovery();
publishHeartbeat();
_lastHeartbeatMs = millis();
} else {
@ -314,7 +454,7 @@ void MqttClient::_taskLoop() {
_client->setServer(broker, port);
_client->setCallback(MqttClient::onMessage);
_client->setKeepAlive(60);
_client->setBufferSize(512);
_client->setBufferSize(1024); // Discovery-Nachrichten bis ~640 Bytes + MQTT-Header
for (;;) {
// Kein WiFi: warten