From 71ef2c7ad0e7bbb16c33689fa94b31f7b3cfab3b Mon Sep 17 00:00:00 2001 From: MaPaLo76 <72209721+MaPaLo76@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:36:29 +0100 Subject: [PATCH] feat(mqtt): Phase 6 - MqttClient implementiert (PubSubClient, publishSession, Heartbeat, Reset-Subscribe) --- include/config.h | 2 +- include/laser_tracker.h | 7 ++ include/mqtt_client.h | 67 ++++++++++ platformio.ini | 31 +++++ src/laser_tracker.cpp | 14 ++- src/main.cpp | 14 +++ src/mqtt_client.cpp | 191 +++++++++++++++++++++++++++++ test_sketches/test_mqtt_client.cpp | 121 ++++++++++++++++++ 8 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 include/mqtt_client.h create mode 100644 src/mqtt_client.cpp create mode 100644 test_sketches/test_mqtt_client.cpp diff --git a/include/config.h b/include/config.h index 33318a0..b0b24b0 100644 --- a/include/config.h +++ b/include/config.h @@ -52,7 +52,7 @@ // ----------------------------------------------------------------------------- // MQTT – Default-Konfiguration (überschreibbar aus NVS) // ----------------------------------------------------------------------------- -#define DEFAULT_MQTT_BROKER "192.168.1.1" +#define DEFAULT_MQTT_BROKER "broker.hivemq.com" // Oeffentlicher Testbroker #define DEFAULT_MQTT_PORT 1883 #define DEFAULT_MQTT_USER "" #define DEFAULT_MQTT_PASSWORD "" diff --git a/include/laser_tracker.h b/include/laser_tracker.h index e93f4df..9335d2b 100644 --- a/include/laser_tracker.h +++ b/include/laser_tracker.h @@ -81,6 +81,12 @@ public: // Gesamtzeit (NVS + RAM-Session) auf 0 zuruecksetzen (Wartungsreset) void resetTotal(); + // Prueft ob seit dem letzten Aufruf ein Burst beendet wurde ("consume"). + // Gibt true zurueck, wenn ein neuer Burst abgeschlossen ist – danach + // kann getLastBurstSeconds() + getTotalMinutes() sicher gelesen werden. + // Wird in main.cpp fuer mqttClient.publishSession() verwendet. + bool consumeBurstEnd(); + // Debug-Ausgabe auf Serial void printToSerial() const; @@ -100,6 +106,7 @@ private: int _sessionNetSec; // Netto-Sekunden der Session (RAM, 0 nach begin/resetSession) float _totalMinutesBase; // NVS-Gesamtzeit (nach jedem Burst aktualisiert) int _lastBurstSec; // Netto-Sekunden des letzten abgeschlossenen Bursts + bool _burstEndPending; // true = Burst gerade beendet, noch nicht consumed // ---- Hilfsmethoden ------------------------------------------------------ bool readRaw() const; // GPIO lesen + Polaritaet anwenden diff --git a/include/mqtt_client.h b/include/mqtt_client.h new file mode 100644 index 0000000..8dcf26c --- /dev/null +++ b/include/mqtt_client.h @@ -0,0 +1,67 @@ +#pragma once + +// ============================================================================= +// mqtt_client.h - MQTT-Verbindung, Publish und Subscribe +// Projekt: MQTT-Display LaserCutter +// +// Wrapper um PubSubClient mit Non-Blocking Reconnect. +// +// Publish: +// publishSession() - beim Ende eines Laser-Bursts (aus LaserTracker) +// publishStatus() - Heartbeat alle MQTT_HEARTBEAT_MS +// +// Subscribe: +// lasercutter/reset - Payload "1" -> laserTracker.resetTotal() +// +// Verwendung: +// mqttClient.begin(); // einmalig in setup(), nach WiFi-Connect +// mqttClient.loop(); // in jedem loop()-Aufruf +// ============================================================================= + +#include +#include +#include +#include "config.h" +#include "settings.h" + +class MqttClient { +public: + MqttClient(); + + // MQTT konfigurieren und erste Verbindung versuchen (einmalig in setup()) + void begin(); + + // Verbindung halten, Reconnect, Heartbeat - in jedem loop()-Aufruf + void loop(); + + // Publish: Session-Ende (wird von LaserTracker-Logik in main aufgerufen) + // lastBurstSec : Netto-Sekunden des letzten Bursts + // totalMinutes : Gesamtzeit inkl. aktuelle Session (aus LaserTracker) + // gratisSec : konfigurierte Gratiszeit + void publishSession(int lastBurstSec, float totalMinutes, int gratisSec); + + // Verbindungsstatus + bool isConnected(); + + // Debug-Ausgabe auf Serial + void printToSerial(); + +private: + WiFiClient _wifiClient; + PubSubClient _client; + + uint32_t _lastReconnectMs; + uint32_t _lastHeartbeatMs; + + // Verbindungsaufbau (intern, non-blocking: gibt true zurueck wenn verbunden) + bool reconnect(); + + // Heartbeat-Publish (lasercutter/status) + void publishHeartbeat(); + + // Callback fuer eingehende Nachrichten (static wegen PubSubClient-API) + static void onMessage(const char* topic, byte* payload, unsigned int length); +}; + +// Globale Instanz +extern MqttClient mqttClient; \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index 7dbbc28..00e3216 100644 --- a/platformio.ini +++ b/platformio.ini @@ -148,3 +148,34 @@ build_flags = -Wno-cpp lib_deps = majicDesigns/MD_MAX72XX @ ^3.5.1 + +; ============================================================================= +; TEST ENVIRONMENT 6.5 – MqttClient Verifikation +; Flash: pio run -e test-mqtt --target upload +; Monitor: pio device monitor -e test-mqtt +; +; Verifikation: +; - MQTT Explorer -> broker.hivemq.com:1883 +; - Subscribe: lasercutter/# +; - lasercutter/status erscheint beim Connect (retained) und alle 60s +; - lasercutter/session erscheint nach Laser-AUS (GPIO 4 loslassen) +; - Publish lasercutter/reset Payload "1" -> Gesamtzeit wird geloescht +; - BOOT-Taste 3s halten -> lokaler Reset +; ============================================================================= +[env:test-mqtt] +platform = espressif32 +board = az-delivery-devkit-v4 +framework = arduino +upload_port = COM3 +monitor_speed = 115200 +monitor_echo = yes +board_build.partitions = min_spiffs.csv +build_src_filter = -<*> +<../test_sketches/test_mqtt_client.cpp> +<../src/settings.cpp> +<../src/display_manager.cpp> +<../src/laser_tracker.cpp> +<../src/wifi_connector.cpp> +<../src/mqtt_client.cpp> +build_flags = + -DCORE_DEBUG_LEVEL=1 + -Wno-cpp +lib_deps = + majicDesigns/MD_MAX72XX @ ^3.5.1 + knolleary/PubSubClient @ ^2.8 + tzapu/WiFiManager @ ^2.0.17 + bblanchon/ArduinoJson @ ^7.3.0 diff --git a/src/laser_tracker.cpp b/src/laser_tracker.cpp index 17cfbcd..4d25c6a 100644 --- a/src/laser_tracker.cpp +++ b/src/laser_tracker.cpp @@ -19,6 +19,7 @@ LaserTracker::LaserTracker() , _sessionNetSec(0) , _totalMinutesBase(0.0f) , _lastBurstSec(0) + , _burstEndPending(false) {} // --------------------------------------------------------------------------- @@ -108,7 +109,8 @@ void LaserTracker::onBurstEnd() { Serial.printf("[LaserTracker] BurstEnd: nur Gratiszeit gesamt=%ds " "total=%.2fmin\n", totalBurstSec, _totalMinutesBase); } - _burst = BurstState::INACTIVE; + _burst = BurstState::INACTIVE; + _burstEndPending = true; // Fuer consumeBurstEnd() in main } // --------------------------------------------------------------------------- @@ -172,6 +174,16 @@ void LaserTracker::printToSerial() const { getCountdownRemaining(), getTotalMinutes()); } +// --------------------------------------------------------------------------- +// Einmalig true wenn ein Burst gerade beendet wurde (consume-Semantik) +bool LaserTracker::consumeBurstEnd() { + if (_burstEndPending) { + _burstEndPending = false; + return true; + } + return false; +} + // --------------------------------------------------------------------------- bool LaserTracker::readRaw() const { bool pinHigh = digitalRead(LASER_SIGNAL_PIN); diff --git a/src/main.cpp b/src/main.cpp index d477ec0..1586863 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include "wifi_connector.h" #include "display_manager.h" #include "laser_tracker.h" +#include "mqtt_client.h" void setup() { Serial.begin(SERIAL_BAUD_RATE); @@ -24,11 +25,24 @@ void setup() { wifiConnector.begin(); wifiConnector.printToSerial(); + + mqttClient.begin(); + mqttClient.printToSerial(); } void loop() { laserTracker.loop(); wifiConnector.loop(); + mqttClient.loop(); + + // MQTT: Session-Publish nach jedem abgeschlossenen Burst + if (laserTracker.consumeBurstEnd()) { + mqttClient.publishSession( + laserTracker.getLastBurstSeconds(), + laserTracker.getTotalMinutes(), + settings.get().gratisSeconds + ); + } // Display mit aktuellen Werten aktualisieren display.showLaserTime((float)laserTracker.getSessionMinutes()); // Module 1-3 diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp new file mode 100644 index 0000000..31e8ad4 --- /dev/null +++ b/src/mqtt_client.cpp @@ -0,0 +1,191 @@ +// ============================================================================= +// mqtt_client.cpp - MQTT-Verbindung, Publish und Subscribe +// Projekt: MQTT-Display LaserCutter +// ============================================================================= + +#include "mqtt_client.h" +#include "laser_tracker.h" +#include "display_manager.h" +#include + +// Globale Instanz +MqttClient mqttClient; + +// -------------------------------------------------------------------------- +// Konstruktor +// -------------------------------------------------------------------------- +MqttClient::MqttClient() + : _client(_wifiClient) + , _lastReconnectMs(0) + , _lastHeartbeatMs(0) +{} + +// -------------------------------------------------------------------------- +// begin() - Broker konfigurieren und erste Verbindung versuchen +// -------------------------------------------------------------------------- +void MqttClient::begin() { + const auto& cfg = settings.get(); + + const char* broker = cfg.mqttBroker[0] != '\0' + ? cfg.mqttBroker + : DEFAULT_MQTT_BROKER; + uint16_t port = cfg.mqttPort > 0 ? cfg.mqttPort : DEFAULT_MQTT_PORT; + + _client.setServer(broker, port); + _client.setCallback(MqttClient::onMessage); + _client.setKeepAlive(60); + + Serial.printf("[MQTT] Broker: %s:%u\n", broker, port); + Serial.printf("[MQTT] Client-ID: %s\n", MQTT_CLIENT_ID); + + reconnect(); +} + +// -------------------------------------------------------------------------- +// loop() - Reconnect und Heartbeat +// -------------------------------------------------------------------------- +void MqttClient::loop() { + if (!WiFi.isConnected()) { + return; // Kein WiFi - nichts tun + } + + if (!_client.connected()) { + uint32_t now = millis(); + if (now - _lastReconnectMs >= MQTT_RECONNECT_MS) { + _lastReconnectMs = now; + reconnect(); + } + } else { + _client.loop(); // PubSubClient interne Verarbeitung + + // Heartbeat + uint32_t now = millis(); + if (now - _lastHeartbeatMs >= MQTT_HEARTBEAT_MS) { + _lastHeartbeatMs = now; + publishHeartbeat(); + } + } + + // Display MQTT-Fehler aktualisieren + display.showMqttError(!_client.connected()); +} + +// -------------------------------------------------------------------------- +// publishSession() - beim Ende eines Laser-Bursts +// -------------------------------------------------------------------------- +void MqttClient::publishSession(int lastBurstSec, float totalMinutes, int gratisSec) { + if (!_client.connected()) { + Serial.println("[MQTT] publishSession: nicht verbunden, uebersprungen"); + return; + } + + JsonDocument doc; + doc["session_s"] = lastBurstSec; + doc["total_min"] = serialized(String(totalMinutes, 2)); + doc["gratiszeit_s"] = gratisSec; + doc["ip"] = WiFi.localIP().toString(); + + char buf[128]; + serializeJson(doc, buf, sizeof(buf)); + + bool ok = _client.publish(MQTT_TOPIC_SESSION, buf, /*retained=*/false); + Serial.printf("[MQTT] publishSession: %s -> %s\n", buf, ok ? "OK" : "FEHLER"); +} + +// -------------------------------------------------------------------------- +// publishHeartbeat() - Status-Heartbeat +// -------------------------------------------------------------------------- +void MqttClient::publishHeartbeat() { + JsonDocument doc; + doc["online"] = true; + doc["total_min"] = serialized(String(laserTracker.getTotalMinutes(), 2)); + doc["ip"] = WiFi.localIP().toString(); + doc["uptime_s"] = (uint32_t)(millis() / 1000UL); + + char buf[128]; + serializeJson(doc, buf, sizeof(buf)); + + bool ok = _client.publish(MQTT_TOPIC_STATUS, buf, /*retained=*/true); + Serial.printf("[MQTT] Heartbeat: %s -> %s\n", buf, ok ? "OK" : "FEHLER"); +} + +// -------------------------------------------------------------------------- +// reconnect() - Verbindungsaufbau, non-blocking +// -------------------------------------------------------------------------- +bool MqttClient::reconnect() { + if (_client.connected()) return true; + + const auto& cfg = settings.get(); + const char* user = cfg.mqttUser[0] != '\0' ? cfg.mqttUser : nullptr; + const char* pass = cfg.mqttPassword[0] != '\0' ? cfg.mqttPassword : nullptr; + + // Offline-LWT (Last Will and Testament) + const char* lwtPayload = "{\"online\":false}"; + + Serial.printf("[MQTT] Verbinde als '%s'...\n", MQTT_CLIENT_ID); + + bool ok; + if (user && pass) { + ok = _client.connect(MQTT_CLIENT_ID, user, pass, + MQTT_TOPIC_STATUS, 0, true, lwtPayload); + } else { + ok = _client.connect(MQTT_CLIENT_ID, + nullptr, nullptr, + MQTT_TOPIC_STATUS, 0, true, lwtPayload); + } + + if (ok) { + Serial.println("[MQTT] Verbunden!"); + _client.subscribe(MQTT_TOPIC_RESET); + Serial.printf("[MQTT] Abonniert: %s\n", MQTT_TOPIC_RESET); + // Sofortigen Heartbeat senden + publishHeartbeat(); + _lastHeartbeatMs = millis(); + } else { + Serial.printf("[MQTT] Verbindung fehlgeschlagen, rc=%d\n", _client.state()); + } + + display.showMqttError(!ok); + return ok; +} + +// -------------------------------------------------------------------------- +// onMessage() - Callback fuer eingehende MQTT-Nachrichten +// -------------------------------------------------------------------------- +void MqttClient::onMessage(const char* topic, byte* payload, unsigned int length) { + // Payload als String kopieren + char msg[64] = {}; + size_t copyLen = length < sizeof(msg) - 1 ? length : sizeof(msg) - 1; + memcpy(msg, payload, copyLen); + + Serial.printf("[MQTT] Nachricht: topic=%s payload=%s\n", topic, msg); + + // Reset-Kommando + if (strcmp(topic, MQTT_TOPIC_RESET) == 0) { + if (strcmp(msg, "1") == 0) { + Serial.println("[MQTT] RESET-Kommando empfangen -> laserTracker.resetTotal()"); + laserTracker.resetTotal(); + } + } +} + +// -------------------------------------------------------------------------- +// isConnected() +// -------------------------------------------------------------------------- +bool MqttClient::isConnected() { + return _client.connected(); +} + +// -------------------------------------------------------------------------- +// printToSerial() +// -------------------------------------------------------------------------- +void MqttClient::printToSerial() { + const auto& cfg = settings.get(); + Serial.println("=== MqttClient ==="); + Serial.printf(" Broker : %s\n", cfg.mqttBroker[0] ? cfg.mqttBroker : DEFAULT_MQTT_BROKER); + Serial.printf(" Port : %u\n", cfg.mqttPort > 0 ? cfg.mqttPort : DEFAULT_MQTT_PORT); + Serial.printf(" ClientID : %s\n", MQTT_CLIENT_ID); + Serial.printf(" Verbunden: %s\n", _client.connected() ? "JA" : "NEIN"); + Serial.printf(" rc : %d\n", _client.state()); + Serial.println("=================="); +} \ No newline at end of file diff --git a/test_sketches/test_mqtt_client.cpp b/test_sketches/test_mqtt_client.cpp new file mode 100644 index 0000000..6d2150e --- /dev/null +++ b/test_sketches/test_mqtt_client.cpp @@ -0,0 +1,121 @@ +// ============================================================================= +// test_mqtt_client.cpp - Phase 6: MqttClient Verifikation +// Projekt: MQTT-Display LaserCutter +// +// Testet: WiFi (WifiManager) -> MQTT Connect -> Heartbeat -> publishSession +// -> Reset-Kommando empfangen +// +// Hardware: +// - Taste GPIO 4 (als Laser-Signal-Simulator, wie test_laser_tracker) +// - BOOT-Taste GPIO 0 (3s = Gesamtzeit loeschen + MQTT reset) +// +// Verwendung: +// 1. Flashen: pio run -e test-mqtt --target upload +// 2. Serial Monitor: pio device monitor -e test-mqtt +// 3. MQTT Explorer mit broker.hivemq.com verbinden +// - Subscribe: lasercutter/# -> Session/Status-Nachrichten beobachten +// - Publish: lasercutter/reset Payload: 1 -> Gesamt-Reset testen +// 4. Verifikation der Topics: +// lasercutter/session -> wird nach Laser-AUS veroeffentlicht +// lasercutter/status -> Heartbeat alle 60s (retained) +// ============================================================================= + +#include +#include "config.h" +#include "settings.h" +#include "wifi_connector.h" +#include "display_manager.h" +#include "laser_tracker.h" +#include "mqtt_client.h" + +// BOOT-Taste +static const int BOOT_BTN = 0; +static uint32_t bootPressMs = 0; + +// Status-Druck alle 5 Sekunden +static uint32_t lastStatusMs = 0; + +// NVS-Fix: Stellt sicher, dass keine alten Werte stoeren +void applyDefaults() { + settings.saveGratisSeconds(DEFAULT_GRATIS_SECONDS); +} + +void setup() { + Serial.begin(SERIAL_BAUD_RATE); + delay(500); + + settings.begin(); + applyDefaults(); + LOG_I("TEST-MQTT", "=== Phase 6 MQTT Test gestartet ==="); + LOG_I("TEST-MQTT", "Broker Default: %s:%d", DEFAULT_MQTT_BROKER, DEFAULT_MQTT_PORT); + settings.printToSerial(); + + display.begin(); + display.printToSerial(); + display.showWifiError(false); + display.showMqttError(false); + display.showLaserTime(0.0f); + display.showIdle(); + + laserTracker.begin(); + laserTracker.printToSerial(); + + wifiConnector.begin(); + wifiConnector.printToSerial(); + + mqttClient.begin(); + mqttClient.printToSerial(); + + pinMode(BOOT_BTN, INPUT); + LOG_I("TEST-MQTT", "Setup abgeschlossen. Taste GPIO4=Laser, GPIO0=Reset"); +} + +void loop() { + laserTracker.loop(); + wifiConnector.loop(); + mqttClient.loop(); + + // MQTT: Session-Publish nach Burst-Ende + if (laserTracker.consumeBurstEnd()) { + LOG_I("TEST-MQTT", "BurstEnd erkannt -> publishSession (lastBurst=%ds, total=%.2fmin)", + laserTracker.getLastBurstSeconds(), + laserTracker.getTotalMinutes()); + mqttClient.publishSession( + laserTracker.getLastBurstSeconds(), + laserTracker.getTotalMinutes(), + settings.get().gratisSeconds + ); + } + + // Display + display.showLaserTime((float)laserTracker.getSessionMinutes()); + int countdown = laserTracker.getCountdownRemaining(); + if (laserTracker.isActive() && countdown > 0) { + display.showCountdown(countdown); + } else { + display.showIdle(); + } + display.update(); + + // BOOT-Taste (GPIO 0): 3s halten -> Gesamtzeit loeschen + if (digitalRead(BOOT_BTN) == LOW) { + if (bootPressMs == 0) bootPressMs = millis(); + if (millis() - bootPressMs >= 3000) { + LOG_I("TEST-MQTT", "BOOT 3s: Gesamtzeit loeschen"); + laserTracker.resetTotal(); + bootPressMs = 0; + } + } else { + bootPressMs = 0; + } + + // Periodischer Status-Druck + if (millis() - lastStatusMs >= 5000) { + lastStatusMs = millis(); + LOG_I("TEST-MQTT", "--- Status ---"); + mqttClient.printToSerial(); + laserTracker.printToSerial(); + } + + delay(50); +} \ No newline at end of file