Files
Dual-PID/JC_Display_Firmware/protocol_client.cpp
raw-designsandClaude Opus 5 7c1e485722 feat: Espresso/Cold-Auswahl bei jedem Aufwecken, auch per Schalter (S3 5.6.0 / P4 1.4.0)
Die Abfrage war eine reine Vorab-Abfrage des Displays und erschien nur beim
Antippen des Standby-Bildschirms. Beim Aufwecken über den Schalter ist der Standby
aber schon beendet, bevor das Display überhaupt fragen könnte — dieser Weg war nie
abgedeckt.

Die Steuerung hält jetzt nach dem Aufwecken ein Auswahlfenster offen (20 s): Der
Wasserkreis heizt in dieser Zeit nicht, das Display blendet die Auswahl von sich aus
ein und zeigt einen Countdown. Ohne Entscheidung wird danach normal geheizt, es geht
also nichts verloren, wenn niemand hinsieht.

Das Fenster öffnet nur mit angeschlossenem Display (sonst könnte niemand wählen),
bei freigeschalteter Funktion und aktiver Einstellung "Abfrage beim Aufwecken", und
nur wenn der Modus nicht ohnehin schon läuft. Es schließt bei Entscheidung,
Bezug/Spülen oder erneutem Standby.

Neue Aktion wakeEspresso (beendet Standby falls aktiv und schließt das Fenster) —
das Display sendet sie statt deactivateStandby, sobald die Auswahl relevant ist,
sonst würde direkt nach dem Aufwecken nochmal gefragt. Neue State-Felder
cxWakeChoice und cxWakeChoiceSec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:46:47 +02:00

368 lines
16 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.cxPreInfDuty = doc["cxPreInfDuty"] | s.cxPreInfDuty;
s.cxDuty = doc["cxDuty"] | s.cxDuty;
s.cxPulseMs = doc["cxPulseMs"] | s.cxPulseMs;
s.cxPumpMaxRun = doc["cxPumpMaxRun"] | s.cxPumpMaxRun;
s.cxPumpRest = doc["cxPumpRest"] | s.cxPumpRest;
s.cxAskOnWake = doc["cxAskOnWake"] | false;
s.cxWakeChoice = doc["cxWakeChoice"] | false;
s.cxWakeChoiceSec = doc["cxWakeChoiceSec"] | (long)0;
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);
}