// ===================================================================================== // protocol_client.cpp - Implementierung des UART-Protokoll-Clients (P4-Seite) // ===================================================================================== #include "protocol_client.h" #include // 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; 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(); 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); }