"Standby aktiv" und "Kessel zu warm" galten bisher auch fürs Aktivieren. Das war falsch herum gedacht: Der Modus schaltet die Heizung AB, man kam aber erst an den Schalter, nachdem die Maschine aufgeweckt war und schon zu heizen begonnen hatte — genau der Ablauf, den die Funktion vermeiden soll. Scharfschalten und Beziehen sind jetzt getrennt: - Scharfschalten (neues State-Feld cxArmable): blockiert nur durch fehlende Freischaltung, Wartungsmodus, Reinigungsassistent, PID-Tuning oder einen laufenden Bezug/Spülvorgang. - Bezug (unverändert cxAllowed): zusätzlich kein Standby, Kessel unter der Freigabeschwelle, gültiger Sensorwert. Ist der Modus scharf, der Bezug aber gesperrt, sagen Web-UI und Display das jetzt ausdrücklich. Bei Aktivierung mit warmem Kessel geht die Heizung sofort aus, die Rückmeldung nennt die Sperre bis zum Unterschreiten der Schwelle. P4: Der Aufweck-Dialog bekommt "Aufwecken mit Cold Extraction" — sendet startColdExtraction vor deactivateStandby, damit der Wasserkreis gar nicht erst anheizt. Der Schalter hängt jetzt an cxArmable; bei älterer S3-Firmware fällt das Feld auf cxEnabled zurück und bleibt bedienbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
15 KiB
C++
360 lines
15 KiB
C++
// =====================================================================================
|
|
// protocol_client.cpp - Implementierung des UART-Protokoll-Clients (P4-Seite)
|
|
// =====================================================================================
|
|
|
|
#include "protocol_client.h"
|
|
#include <ArduinoJson.h> // Bibliothek: "ArduinoJson" (v7) ueber den Library Manager
|
|
#if UART_SELFTEST_INTERNAL_LOOPBACK
|
|
#include "driver/uart.h" // fuer uart_set_loop_back()
|
|
#endif
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Setup / Hauptschleife
|
|
// -------------------------------------------------------------------------------------
|
|
void ProtocolClient::begin() {
|
|
// Groesserer RX-Puffer: OTA-Chunks (~1.4 KB) duerfen nicht ueberlaufen (Default 256).
|
|
DISPLAY_UART_PORT.setRxBufferSize(2048);
|
|
DISPLAY_UART_PORT.begin(DISPLAY_UART_BAUDRATE, SERIAL_8N1,
|
|
DISPLAY_UART_RX_PIN, DISPLAY_UART_TX_PIN);
|
|
// Bewusst NICHT auf PROTO_RX_LINE_MAX (4 KB) reserviert: Der interne RAM ist knapp
|
|
// (statisch ~98 %), und eine Arduino-String kann nicht ins PSRAM alloziert werden.
|
|
// 2 KB deckt die reale State-Zeile (~2,1 KB) fast vollstaendig ab; der Rest waechst
|
|
// in kleinen Schritten nach. Das Limit dient nur als Obergrenze gegen Muell/Overflow.
|
|
_rxBuf.reserve(2048);
|
|
_lastHelloMs = 0;
|
|
_lastHeartbeatMs = 0;
|
|
#if UART_SELFTEST_INTERNAL_LOOPBACK
|
|
// DIAGNOSE: TX intern auf RX legen. UART_NUM_1 muss zu DISPLAY_UART_PORT (Serial1) passen.
|
|
uart_set_loop_back(UART_NUM_1, true);
|
|
#endif
|
|
}
|
|
|
|
void ProtocolClient::loop() {
|
|
// --- Eingehende Bytes zeilenweise einsammeln ---
|
|
while (DISPLAY_UART_PORT.available() > 0) {
|
|
char c = (char)DISPLAY_UART_PORT.read();
|
|
rxByteCount++;
|
|
if (c == '\r') continue;
|
|
if (c == '\n') {
|
|
if (_rxOverflow) {
|
|
_rxBuf = "";
|
|
_rxOverflow = false;
|
|
} else {
|
|
String line = _rxBuf;
|
|
_rxBuf = "";
|
|
line.trim();
|
|
if (line.length() > 0) { rxLineCount++; handleLine(line); }
|
|
}
|
|
continue;
|
|
}
|
|
if (_rxOverflow) continue;
|
|
if (_rxBuf.length() >= PROTO_RX_LINE_MAX) { _rxOverflow = true; continue; }
|
|
_rxBuf += c;
|
|
}
|
|
|
|
maintainLink();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Verbindungspflege: hello-Handshake, Heartbeat, Timeout
|
|
// -------------------------------------------------------------------------------------
|
|
void ProtocolClient::maintainLink() {
|
|
unsigned long now = millis();
|
|
|
|
// Link-Timeout: zu lange nichts empfangen -> Verbindung als tot markieren
|
|
if (_state.linkUp && (now - _state.lastRxMs > PROTO_LINK_TIMEOUT_MS)) {
|
|
_state.linkUp = false;
|
|
}
|
|
|
|
if (!_state.linkUp) {
|
|
// Solange keine Verbindung: regelmaessig hello senden
|
|
if (now - _lastHelloMs >= PROTO_HELLO_RETRY_MS) {
|
|
_lastHelloMs = now;
|
|
sendHello();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Verbunden: Heartbeat senden, damit die S3 uns als aktiven Client behaelt
|
|
if (now - _lastHeartbeatMs >= PROTO_HEARTBEAT_MS) {
|
|
_lastHeartbeatMs = now;
|
|
sendPing();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Empfangene Zeile verarbeiten
|
|
// -------------------------------------------------------------------------------------
|
|
void ProtocolClient::handleLine(const String& line) {
|
|
_state.lastRxMs = millis();
|
|
lastRxLine = line;
|
|
|
|
JsonDocument doc;
|
|
DeserializationError err = deserializeJson(doc, line);
|
|
if (err) {
|
|
parseErrorCount++;
|
|
#ifdef DBG_SERIAL
|
|
DBG_SERIAL.print(F("[proto] JSON-Fehler: "));
|
|
DBG_SERIAL.println(err.c_str());
|
|
#endif
|
|
return;
|
|
}
|
|
|
|
const char* type = doc["type"] | "";
|
|
|
|
// OTA-Nachrichten (otaBegin/otaData/otaEnd/otaAbort) separat behandeln.
|
|
if (strncmp(type, "ota", 3) == 0) {
|
|
if (_otaCb) _otaCb(line);
|
|
return;
|
|
}
|
|
|
|
if (strcmp(type, "state") == 0) {
|
|
applyState(line);
|
|
_state.linkUp = true; // jeder gueltige state bestaetigt die Verbindung
|
|
if (_stateCb) _stateCb(_state);
|
|
|
|
} else if (strcmp(type, "hello") == 0) {
|
|
_state.protocolVersion = doc["protocolVersion"] | 0;
|
|
_state.firmwareVersion = String((const char*)(doc["firmwareVersion"] | ""));
|
|
_state.linkUp = true;
|
|
#ifdef DBG_SERIAL
|
|
DBG_SERIAL.printf("[proto] hello: FW=%s, proto=%u\n",
|
|
_state.firmwareVersion.c_str(), _state.protocolVersion);
|
|
if (_state.protocolVersion != PROTO_EXPECTED_VERSION) {
|
|
DBG_SERIAL.printf("[proto] WARNUNG: Protokollversion %u != erwartet %u\n",
|
|
_state.protocolVersion, (unsigned)PROTO_EXPECTED_VERSION);
|
|
}
|
|
#endif
|
|
|
|
} else if (strcmp(type, "ack") == 0) {
|
|
// S3 sendet das Erfolgs-Flag als "success"; "ok" als Fallback (aeltere Versionen).
|
|
bool ackOk = doc["success"] | (doc["ok"] | false);
|
|
if (_ackCb) _ackCb(doc["id"] | 0, ackOk,
|
|
String((const char*)(doc["message"] | "")));
|
|
|
|
} else if (strcmp(type, "error") == 0) {
|
|
if (_ackCb) _ackCb(doc["id"] | 0, false,
|
|
String((const char*)(doc["message"] | "")));
|
|
|
|
} else if (strcmp(type, "profiles") == 0) {
|
|
applyProfiles(line);
|
|
|
|
} else if (strcmp(type, "profileDetails") == 0) {
|
|
if (_profileDetailsCb) _profileDetailsCb(line);
|
|
|
|
} else if (strcmp(type, "usageStats") == 0) {
|
|
if (_usageStatsCb) _usageStatsCb(line);
|
|
|
|
} else if (strcmp(type, "wifiNetworks") == 0) {
|
|
if (_wifiNetworksCb) _wifiNetworksCb(line);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// state -> MachineState
|
|
// -------------------------------------------------------------------------------------
|
|
void ProtocolClient::applyState(const String& json) {
|
|
JsonDocument doc;
|
|
if (deserializeJson(doc, json)) return;
|
|
|
|
MachineState& s = _state;
|
|
s.protocolVersion = doc["protocolVersion"] | s.protocolVersion;
|
|
s.firmwareVersion = String((const char*)(doc["firmwareVersion"] | s.firmwareVersion.c_str()));
|
|
|
|
s.tempW = doc["tempW"] | s.tempW;
|
|
s.setW = doc["setW"] | s.setW;
|
|
s.tempD = doc["tempD"] | s.tempD;
|
|
s.setD = doc["setD"] | s.setD;
|
|
|
|
s.statusText = String((const char*)(doc["statusText"] | ""));
|
|
s.statusKey = String((const char*)(doc["statusKey"] | ""));
|
|
s.wasserSensorError = doc["wasserSensorError"] | false;
|
|
s.wasserSafetyShutdown = doc["wasserSafetyShutdown"] | false;
|
|
s.dampfSensorError = doc["dampfSensorError"] | false;
|
|
s.dampfSafetyShutdown = doc["dampfSafetyShutdown"] | false;
|
|
s.shotActive = doc["shotActive"] | false;
|
|
s.shotElapsedMs = doc["shotElapsedMs"] | 0;
|
|
s.scaleTaring = doc["scaleTaring"] | false;
|
|
s.steamCircuitActive = doc["steamCircuitActive"] | false;
|
|
s.steamElapsedMs = doc["steamElapsedMs"] | 0;
|
|
s.steamFlushActive = doc["steamFlushActive"] | false;
|
|
s.steamFlushElapsedMs= doc["steamFlushElapsedMs"]| 0;
|
|
s.flushActive = doc["flushActive"] | false;
|
|
s.flushElapsedMs = doc["flushElapsedMs"] | 0;
|
|
s.ecoActive = doc["ecoActive"] | false;
|
|
s.ecoCountdownSec = doc["ecoCountdownSec"] | (long)-1;
|
|
s.ecoShowInfo = doc["ecoShowInfo"] | false;
|
|
s.standbyActive = doc["standbyActive"] | false;
|
|
s.standbyShowClock = doc["standbyShowClock"] | false;
|
|
s.backlightActive = doc["backlightActive"] | s.backlightActive;
|
|
s.backlightStandbyClock = doc["backlightStandbyClock"] | s.backlightStandbyClock;
|
|
s.maintenanceActive = doc["maintenanceActive"] | false;
|
|
s.steamHeatDisabled = doc["steamHeatDisabled"] | false;
|
|
s.steamDelayActive = doc["steamDelayActive"] | false;
|
|
s.steamDelayRemainSec= doc["steamDelayRemainSec"]| (long)0;
|
|
s.heatUpMinutes = doc["heatUpMinutes"] | 0;
|
|
s.heatUpRemainSec = doc["heatUpRemainSec"] | (long)-1;
|
|
s.lightOn = doc["lightOn"] | false;
|
|
s.piezoEnabled = doc["piezoEnabled"] | false;
|
|
s.activeProfile = String((const char*)(doc["profile"] | "")); // fehlt bei aelterer S3-FW -> leer
|
|
s.profileDirty = doc["profDirty"] | false;
|
|
|
|
s.weight = doc["weight"] | s.weight;
|
|
s.targetWeight = doc["targetWeight"] | s.targetWeight;
|
|
s.lastShotWeight = doc["lastShotWeight"] | s.lastShotWeight;
|
|
s.lastShotByWeight = doc["lastShotByWeight"] | false;
|
|
s.flowRate = doc["flowRate"] | 0.0f;
|
|
s.scaleEnabled = doc["scaleEnabled"] | false;
|
|
s.scaleType = doc["scaleType"] | 0;
|
|
s.scaleConnected= doc["scaleConnected"]| false;
|
|
|
|
s.piEnabled = doc["piEnabled"] | false;
|
|
s.bbtEnabled = doc["bbtEnabled"] | false;
|
|
s.bbwEnabled = doc["bbwEnabled"] | false;
|
|
s.sbtEnabled = doc["sbtEnabled"] | false;
|
|
s.bbtSecs = doc["bbtSecs"] | s.bbtSecs;
|
|
s.sbtSecs = doc["sbtSecs"] | s.sbtSecs;
|
|
s.piDurSecs = doc["piDurSecs"] | s.piDurSecs;
|
|
s.piPauseSecs= doc["piPauseSecs"]| s.piPauseSecs;
|
|
s.piState = doc["piState"] | (uint8_t)0; // fehlt bei aelterer S3-FW -> 0 (UI leitet dann zeitbasiert ab)
|
|
s.bbwTarget = doc["bbwTarget"] | s.bbwTarget;
|
|
s.bbwOffset = doc["bbwOffset"] | s.bbwOffset;
|
|
|
|
// Cold Extraction (fehlt bei S3-FW < 5.3.0 -> cxEnabled bleibt false, UI blendet alles aus)
|
|
s.cxEnabled = doc["cxEnabled"] | false;
|
|
s.cxActive = doc["cxActive"] | false;
|
|
s.cxShot = doc["cxShot"] | false;
|
|
s.cxAllowed = doc["cxAllowed"] | false;
|
|
// Fehlt bei S3-FW < 5.3.5 -> auf cxEnabled zurueckfallen, sonst waere der Schalter tot
|
|
s.cxArmable = doc["cxArmable"] | (doc["cxEnabled"] | false);
|
|
s.cxResting = doc["cxResting"] | false;
|
|
s.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp;
|
|
s.cxTarget = doc["cxTarget"] | s.cxTarget;
|
|
s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec;
|
|
s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
|
|
s.cxBlock = doc["cxBlock"] | (uint8_t)0;
|
|
s.cxNotice = doc["cxNotice"] | (uint8_t)0;
|
|
|
|
s.caseSensorEnabled = doc["caseSensorEnabled"] | false;
|
|
s.caseTempDashboard = doc["caseTempDashboard"] | false;
|
|
s.caseSensorType = doc["caseSensorType"] | 0;
|
|
if (doc["caseTemp"].isNull()) {
|
|
s.hasCaseTemp = false;
|
|
} else {
|
|
s.hasCaseTemp = true;
|
|
s.caseTemp = doc["caseTemp"] | 0.0f;
|
|
}
|
|
|
|
s.maintenanceInterval = doc["maintenanceInterval"] | 0;
|
|
s.maintenanceIntervalCounter = doc["maintenanceIntervalCounter"] | 0;
|
|
s.flushDurationSeconds = doc["flushDurationSeconds"] | 0;
|
|
s.steamFlushDurationSeconds = doc["steamFlushDurationSeconds"] | 0;
|
|
s.cleaningActive = doc["cleaningActive"] | false;
|
|
s.cleaningWaiting = doc["cleaningWaiting"] | false;
|
|
s.cleaningBrewPhase = doc["cleaningBrewPhase"] | false;
|
|
s.cleaningCycle = doc["cleaningCycle"] | 0;
|
|
s.cleaningCycles = doc["cleaningCycles"] | 0;
|
|
s.cleaningBrewSeconds = doc["cleaningBrewSeconds"] | 0;
|
|
s.cleaningPauseSeconds = doc["cleaningPauseSeconds"] | 0;
|
|
s.cleaningPhaseRemainSec = doc["cleaningPhaseRemainSec"] | (long)0;
|
|
|
|
// v2
|
|
s.dutyW = doc["dutyW"] | 0.0f;
|
|
s.dutyD = doc["dutyD"] | 0.0f;
|
|
s.kpW = doc["kpW"] | 0.0f; s.kiW = doc["kiW"] | 0.0f; s.kdW = doc["kdW"] | 0.0f;
|
|
s.kpD = doc["kpD"] | 0.0f; s.kiD = doc["kiD"] | 0.0f; s.kdD = doc["kdD"] | 0.0f;
|
|
s.autoTuneW = doc["autoTuneW"] | false;
|
|
s.autoTuneD = doc["autoTuneD"] | false;
|
|
s.autoTuneWStatus = doc["autoTuneWStatus"] | s.autoTuneWStatus;
|
|
s.autoTuneDStatus = doc["autoTuneDStatus"] | s.autoTuneDStatus;
|
|
|
|
s.wifiConnected = doc["wifiConnected"] | false;
|
|
s.apMode = doc["apMode"] | false;
|
|
s.ip = String((const char*)(doc["ip"] | ""));
|
|
s.ssid = String((const char*)(doc["ssid"] | ""));
|
|
s.rssi = doc["rssi"] | 0;
|
|
s.timeStr = String((const char*)(doc["time"] | ""));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// profiles -> Callback (Array von Namen)
|
|
// -------------------------------------------------------------------------------------
|
|
void ProtocolClient::applyProfiles(const String& json) {
|
|
if (!_profilesCb) return;
|
|
JsonDocument doc;
|
|
if (deserializeJson(doc, json)) return;
|
|
|
|
JsonArray arr = doc["profiles"].as<JsonArray>();
|
|
static const int MAX_PROFILES = 32;
|
|
String names[MAX_PROFILES];
|
|
int count = 0;
|
|
for (JsonVariant v : arr) {
|
|
if (count >= MAX_PROFILES) break;
|
|
names[count++] = String((const char*)(v | ""));
|
|
}
|
|
_profilesCb(names, count);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Senden
|
|
// -------------------------------------------------------------------------------------
|
|
uint32_t ProtocolClient::sendCommand(const String& op, const String& extraFields) {
|
|
uint32_t id = _nextId++;
|
|
String msg = "{\"op\":\"" + op + "\",\"id\":" + String(id);
|
|
if (extraFields.length() > 0) {
|
|
msg += ",";
|
|
msg += extraFields;
|
|
}
|
|
msg += "}";
|
|
DISPLAY_UART_PORT.println(msg);
|
|
txCmdCount++;
|
|
return id;
|
|
}
|
|
|
|
uint32_t ProtocolClient::sendHello() { helloCount++; return sendCommand("hello", "\"firmwareVersion\":\"" DISPLAY_FW_VERSION "\""); }
|
|
uint32_t ProtocolClient::sendPing() { return sendCommand("ping", "\"firmwareVersion\":\"" DISPLAY_FW_VERSION "\""); }
|
|
uint32_t ProtocolClient::sendGetState() { return sendCommand("getState"); }
|
|
uint32_t ProtocolClient::sendListProfiles() { return sendCommand("listProfiles"); }
|
|
uint32_t ProtocolClient::sendGetUsageStats(){ return sendCommand("getUsageStats"); }
|
|
|
|
uint32_t ProtocolClient::sendLoadProfile(const String& profile) {
|
|
return sendCommand("loadProfile", "\"profile\":\"" + profile + "\"");
|
|
}
|
|
uint32_t ProtocolClient::sendGetProfileDetails(const String& profile) {
|
|
return sendCommand("getProfileDetails", "\"profile\":\"" + profile + "\"");
|
|
}
|
|
uint32_t ProtocolClient::sendAction(const String& action, const String& value) {
|
|
String fields = "\"action\":\"" + action + "\"";
|
|
if (value.length() > 0) fields += ",\"value\":\"" + value + "\"";
|
|
return sendCommand("action", fields);
|
|
}
|
|
uint32_t ProtocolClient::sendSetPid(const String& fieldsJson) {
|
|
return sendCommand("setPid", fieldsJson);
|
|
}
|
|
uint32_t ProtocolClient::sendStartAutotune(const String& target) {
|
|
return sendCommand("startAutotune", "\"target\":\"" + target + "\"");
|
|
}
|
|
uint32_t ProtocolClient::sendStopAutotune() {
|
|
return sendCommand("stopAutotune");
|
|
}
|
|
|
|
// Minimal-Escaping fuer JSON-Strings (Backslash + Anfuehrungszeichen).
|
|
static String jsonEsc(const String& s) {
|
|
String o; o.reserve(s.length() + 4);
|
|
for (size_t i = 0; i < s.length(); i++) {
|
|
char c = s[i];
|
|
if (c == '\\' || c == '"') { o += '\\'; o += c; }
|
|
else if (c == '\n' || c == '\r') { /* weglassen */ }
|
|
else o += c;
|
|
}
|
|
return o;
|
|
}
|
|
|
|
uint32_t ProtocolClient::sendScanWifi() {
|
|
return sendCommand("scanWifi");
|
|
}
|
|
uint32_t ProtocolClient::sendSetWifi(const String& ssid, const String& password) {
|
|
String f = "\"ssid\":\"" + jsonEsc(ssid) + "\",\"password\":\"" + jsonEsc(password) + "\"";
|
|
return sendCommand("setWifi", f);
|
|
}
|