Der Aufweck-Dialog am Touch-Display wird zur Auswahl: "Womit aufwecken?" mit
"Espresso" (heizt normal auf) und "Cold Extraction (Wasser bleibt kalt)". Damit
lässt sich bei jedem Aufwecken neu entscheiden, ohne vorher die Web-UI zu bedienen.
Bei der kalten Variante wird der Modus scharf geschaltet, bevor der Standby endet —
der Wasserkreis heizt also gar nicht erst an.
Abschaltbar über die neue Einstellung "Abfrage beim Aufwecken" (EEPROM 982), zu
finden auf /Cold-Extraction und auf der Cold-Extraction-Seite des Displays. Ist sie
aus, erscheint wieder der bisherige Dialog. Ohne angeschlossenes Display oder bei
nicht freigeschalteter Funktion hat sie keine Wirkung. Neues Feld cxAskOnWake im
State und in saveBrew.
Dazu: Die Meldungstexte der kalten Extraktion sind jetzt mit echten Umlauten
geschrieben ("nicht möglich" statt "nicht moeglich"). Für das OLED übersetzt die
neue Hilfsfunktion oledUmlauts() sie beim Rendern nach CP437 — dort hätte UTF-8
sonst zwei Fehlzeichen je Umlaut ergeben, weshalb die Texte ursprünglich
transliteriert waren. Web-UI und Touch-Display stellen UTF-8 direkt dar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366 lines
16 KiB
C++
366 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.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);
|
|
}
|