feat(mqtt): Phase 6 - MqttClient implementiert (PubSubClient, publishSession, Heartbeat, Reset-Subscribe)

This commit is contained in:
MaPaLo76 2026-02-22 19:36:29 +01:00
parent f389a7deea
commit 71ef2c7ad0
8 changed files with 445 additions and 2 deletions

View File

@ -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 ""

View File

@ -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

67
include/mqtt_client.h Normal file
View File

@ -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 <Arduino.h>
#include <PubSubClient.h>
#include <WiFi.h>
#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;

View File

@ -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

View File

@ -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);

View File

@ -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

191
src/mqtt_client.cpp Normal file
View File

@ -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 <ArduinoJson.h>
// 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("==================");
}

View File

@ -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 <Arduino.h>
#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);
}