diff --git a/JC_Display_Firmware/Changelog.txt b/JC_Display_Firmware/Changelog.txt index cfab9b7..a757c71 100644 --- a/JC_Display_Firmware/Changelog.txt +++ b/JC_Display_Firmware/Changelog.txt @@ -1,3 +1,26 @@ +Version 1.0.11: +- Verlauf-Seite ausgebaut (nur P4, keine S3-/Protokolländerung nötig): + - Umschaltbares Zeitfenster: Buttons „2 min / 10 min / 30 min / 60 min" in der Kopfzeile. + Intern läuft eine 1-s-Historie als Ringpuffer über volle 60 Minuten; das Chart zeigt + weiterhin 120 Punkte und dezimiert je Fenster. Dadurch ist beim Fensterwechsel der + komplette Verlauf sofort da (kein leeres Chart, kein Neuaufbau). Die Wahl wird P4-lokal + im NVS gespeichert und überlebt Neustarts. + - Gestrichelte Soll-Linien für Wasser und Dampf im Chart (Farbe der jeweiligen Kurve, + gedimmt). Die Y-Skala bezieht die Soll-Werte mit ein, damit die Linien im Bild liegen — + aber nur für Kreise, deren Ist-Kurve im Fenster sichtbar ist (ein kalter Dampfkessel + zieht die Skala nicht auseinander). + - Heizleistung zuschaltbar: Button „Leistung" blendet Duty-Cycle Wasser/Dampf als gedimmte + Zusatzkurven auf einer zweiten Achse (0–100 %) ein, inkl. Live-Legende. Einstellung + ebenfalls P4-lokal im NVS. + - Feinere Kurven: Die Temperatur-Historie wird jetzt in Zehntelgrad geführt und gezeichnet + (vorher ganzzahlig gerundet) — PID-Schwingungen um ±1 °C sind damit sauber ablesbar. +- PID-AutoTune sichtbar gemacht: Während eines Tuning-Laufs erscheint auf der Verlauf-Seite + eine Live-Karte (mit Spinner) — je Kreis Laufzeit, aktuelle Phase (heizt/kühlt ab) und + Schwingungshub samt Mittelpunkt (Min/Max der Ist-Temperatur seit Start). Nach dem Ende + zeigt die Karte ~30 s das Ergebnis (Erfolg/Abbruch samt Grund, farbcodiert). Die + PID-Tuning-Meldung in der Statuszeile ist jetzt antippbar und springt zur Verlauf-Seite, + wo die Schwingung auch als Kurve zu sehen ist. + Version 1.0.10: - Live-Bezugsschirm (abschaltbar): Beim Start eines echten Bezugs blendet sich ein Vollbild-Overlay weich ein — große Bezugszeit, Live-Gewicht und Flow samt Flow-Verlaufskurve der letzten 30 Sekunden diff --git a/JC_Display_Firmware/config.h b/JC_Display_Firmware/config.h index 76d9d41..cbfdbfa 100644 --- a/JC_Display_Firmware/config.h +++ b/JC_Display_Firmware/config.h @@ -65,7 +65,7 @@ // ------------------------------------------------------------------------------------- // Firmware // ------------------------------------------------------------------------------------- -#define DISPLAY_FW_VERSION "1.0.10" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) +#define DISPLAY_FW_VERSION "1.0.11" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) // ------------------------------------------------------------------------------------- // Protokoll diff --git a/JC_Display_Firmware/ui.cpp b/JC_Display_Firmware/ui.cpp index 94fcbe4..2dfc867 100644 --- a/JC_Display_Firmware/ui.cpp +++ b/JC_Display_Firmware/ui.cpp @@ -118,6 +118,40 @@ static lv_chart_series_t* g_serC = nullptr; // Zusatzsensor (Gehaeuse/Tassenab static lv_obj_t* g_legW = nullptr; static lv_obj_t* g_legD = nullptr; static lv_obj_t* g_legC = nullptr; static lv_obj_t* g_axHi = nullptr; static lv_obj_t* g_axMid = nullptr; static lv_obj_t* g_axLo = nullptr; // Y-Skala #define COL_CASE lv_color_hex(0xba68c8) // Farbe Zusatzsensor im Chart (Violett; klar distinkt von Wasser-Gold/Dampf-Teal) +// Umschaltbares Zeitfenster: das Chart zeigt immer 120 Punkte; die Werte kommen aus einer +// 1-s-Historie (Ringpuffer ueber 60 min) und werden je Fenster dezimiert. So bleibt beim +// Fensterwechsel der komplette Verlauf sofort sichtbar (kein leeres Chart). +static const uint16_t CHART_WIN_MIN[4] = { 2, 10, 30, 60 }; // waehlbare Fenster (Minuten) +#define CHART_HIST_MAX 3600 // Historie: 60 min à 1 s +#define CHART_HIST_NONE INT16_MIN // Markierung "keine Probe" +static int16_t g_histW[CHART_HIST_MAX]; // Zehntelgrad +static int16_t g_histD[CHART_HIST_MAX]; +static int16_t g_histC[CHART_HIST_MAX]; +static uint8_t g_histDutyW[CHART_HIST_MAX]; // Heizleistung (%) +static uint8_t g_histDutyD[CHART_HIST_MAX]; +static uint16_t g_histPos = 0, g_histFill = 0; +static uint8_t g_chartWin = 0; // Index in CHART_WIN_MIN (P4-lokal in NVS, "chartwin") +static bool g_chartDuty = false; // Heizleistung als Zusatzkurven (NVS "chartduty") +static lv_obj_t* g_chartTitle = nullptr; +static lv_obj_t* g_chartWinBtn[4] = {}; +static lv_obj_t* g_chartDutyBtn = nullptr; +static lv_chart_series_t* g_serDutyW = nullptr; // Heizleistung auf Sekundaerachse 0..100 % +static lv_chart_series_t* g_serDutyD = nullptr; +static lv_obj_t* g_legDuty = nullptr; +static lv_obj_t* g_lineSetW = nullptr; // gestrichelte Soll-Linien im Chart +static lv_obj_t* g_lineSetD = nullptr; +static lv_point_precise_t g_lineSetPts[2][2]; +#define COL_DUTY_W lv_color_hex(0x8a6206) // gedimmtes Gold (Heizleistung Wasser) +#define COL_DUTY_D lv_color_hex(0x4d7a76) // gedimmtes Teal (Heizleistung Dampf) +// PID-Tuning-Live-Karte (Verlauf-Seite): Laufzeit, Heiz-/Abkuehlphase und Schwingungshub +static lv_obj_t* g_tuneCard = nullptr; +static lv_obj_t* g_tuneCardTitle = nullptr; +static lv_obj_t* g_tuneCardBody = nullptr; +static lv_obj_t* g_tuneCardSpin = nullptr; +static bool g_tuneCardShown = false; +static uint32_t g_tuneStartW = 0, g_tuneStartD = 0; // millis() beim Tuning-Start +static float g_tuneMinW = 0, g_tuneMaxW = 0; // Schwingungs-Min/Max seit Start +static float g_tuneMinD = 0, g_tuneMaxD = 0; // Shot-Zusammenfassung (Karte nach Bezug; blendet weich ein/aus) static lv_obj_t* g_shotSum = nullptr; @@ -901,10 +935,12 @@ static lv_obj_t* infoCell(lv_obj_t* parent, const char* title, lv_obj_t** val, l lv_obj_set_style_text_align(*val, LV_TEXT_ALIGN_CENTER, 0); // fuer mehrzeilige Werte return card; } -// Tipp auf die Statuszeile: Wartungsmeldung -> Serviceseite, Reinigungsmeldung -> Sub-Screen +// Tipp auf die Statuszeile: Wartung -> Serviceseite, Reinigung -> Sub-Screen, +// PID-Tuning -> Verlauf-Seite (dort liegen Live-Karte und Schwingungskurve) static void status_line_cb(lv_event_t*) { if (g_statusShown == ST_MAINT) show_page(PG_SERVICE); else if (g_statusShown == ST_CLEAN) cln_open(nullptr); + else if (g_statusShown == ST_TUNE) show_page(PG_CHART); } static void wifi_icon_cb(lv_event_t*) { show_page(PG_WIFI); } // Tipp auf WLAN-Symbol -> WiFi-Seite static void build_dashboard(lv_obj_t* p) { @@ -1260,9 +1296,107 @@ static void build_light(lv_obj_t* p) { // Verlauf (Chart): Wasser/Dampf + Zusatzsensor (Gehaeuse/Tassenablage), ~2 min, // mit automatisch skalierter Temperatur-Achse und Y-Skala-Beschriftung links // ===================================================================================== +// Zeitfenster-/Leistungs-Buttons gemaess Auswahl einfaerben, Titel aktualisieren +static void chart_win_btns_refresh() { + for (int i = 0; i < 4; i++) { + if (!g_chartWinBtn[i]) continue; + bool sel = (g_chartWin == i); + lv_obj_set_style_bg_color(g_chartWinBtn[i], sel ? COL_ACCENT : COL_CARD2, 0); + lv_obj_set_style_text_color(lv_obj_get_child(g_chartWinBtn[i], 0), + sel ? lv_color_hex(0x000000) : COL_TEXT, 0); + } + if (g_chartDutyBtn) { + lv_obj_set_style_bg_color(g_chartDutyBtn, g_chartDuty ? COL_ACCENT : COL_CARD2, 0); + lv_obj_set_style_text_color(lv_obj_get_child(g_chartDutyBtn, 0), + g_chartDuty ? lv_color_hex(0x000000) : COL_TEXT, 0); + } + if (g_chartTitle) + lv_label_set_text_fmt(g_chartTitle, "Temperatur-Verlauf (%u min)", (unsigned)CHART_WIN_MIN[g_chartWin]); +} +static void chart_redraw(); // Definition weiter unten +static void chart_win_btn_cb(lv_event_t* e) { + int idx = (int)(intptr_t)lv_event_get_user_data(e); + if (idx == (int)g_chartWin) return; + g_chartWin = (uint8_t)idx; + { Preferences prefs; prefs.begin("ui", false); prefs.putUChar("chartwin", g_chartWin); prefs.end(); } + chart_win_btns_refresh(); + chart_redraw(); // neues Fenster sofort aus der Historie zeichnen +} +static void chart_duty_btn_cb(lv_event_t*) { + g_chartDuty = !g_chartDuty; + { Preferences prefs; prefs.begin("ui", false); prefs.putBool("chartduty", g_chartDuty); prefs.end(); } + chart_win_btns_refresh(); + if (g_chart && g_serDutyW) { + lv_chart_hide_series(g_chart, g_serDutyW, !g_chartDuty); + lv_chart_hide_series(g_chart, g_serDutyD, !g_chartDuty); + } + if (g_legDuty) { + if (g_chartDuty) lv_obj_remove_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN); + } + chart_redraw(); +} static void build_chart(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); - section_title(p, "Temperatur-Verlauf (2 min)"); + + // Kopfzeile: Titel links, Zeitfenster-Umschalter + Heizleistung-Toggle rechts + lv_obj_t* hdr = row(p, nullptr); + g_chartTitle = section_title(hdr, "Temperatur-Verlauf (2 min)"); + lv_obj_t* btns = lv_obj_create(hdr); + lv_obj_set_size(btns, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(btns, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(btns, 0, 0); + lv_obj_set_style_pad_all(btns, 0, 0); + lv_obj_set_style_pad_column(btns, 6, 0); + lv_obj_set_flex_flow(btns, LV_FLEX_FLOW_ROW); + lv_obj_clear_flag(btns, LV_OBJ_FLAG_SCROLLABLE); + static const char* winNames[4] = { "2 min", "10 min", "30 min", "60 min" }; + for (int i = 0; i < 4; i++) { + g_chartWinBtn[i] = th_button(btns, winNames[i], COL_CARD2, COL_TEXT); + lv_obj_set_style_pad_hor(g_chartWinBtn[i], 10, 0); + lv_obj_set_style_pad_ver(g_chartWinBtn[i], 5, 0); + lv_obj_set_style_text_font(lv_obj_get_child(g_chartWinBtn[i], 0), &lv_font_maven_pro_14, 0); + lv_obj_add_event_cb(g_chartWinBtn[i], chart_win_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + } + g_chartDutyBtn = th_button(btns, "Leistung", COL_CARD2, COL_TEXT); + lv_obj_set_style_pad_hor(g_chartDutyBtn, 10, 0); + lv_obj_set_style_pad_ver(g_chartDutyBtn, 5, 0); + lv_obj_set_style_text_font(lv_obj_get_child(g_chartDutyBtn, 0), &lv_font_maven_pro_14, 0); + lv_obj_add_event_cb(g_chartDutyBtn, chart_duty_btn_cb, LV_EVENT_CLICKED, nullptr); + chart_win_btns_refresh(); + + // PID-Tuning-Live-Karte: nur waehrend/kurz nach einem AutoTune-Lauf sichtbar + // (Inhalt kommt aus ui_update; die Statuszeilen-Meldung ist antippbar und fuehrt hierher) + g_tuneCard = th_card(p); + lv_obj_set_width(g_tuneCard, LV_PCT(100)); + lv_obj_set_height(g_tuneCard, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(g_tuneCard, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(g_tuneCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(g_tuneCard, 12, 0); + lv_obj_clear_flag(g_tuneCard, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(g_tuneCard, LV_OBJ_FLAG_HIDDEN); + g_tuneCardSpin = lv_spinner_create(g_tuneCard); + lv_spinner_set_anim_params(g_tuneCardSpin, 1000, 60); + lv_obj_set_size(g_tuneCardSpin, 28, 28); + lv_obj_set_style_arc_width(g_tuneCardSpin, 4, LV_PART_MAIN); + lv_obj_set_style_arc_width(g_tuneCardSpin, 4, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(g_tuneCardSpin, COL_CARD2, LV_PART_MAIN); + lv_obj_set_style_arc_color(g_tuneCardSpin, COL_WARN, LV_PART_INDICATOR); + lv_obj_t* tcol = lv_obj_create(g_tuneCard); + lv_obj_set_size(tcol, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(tcol, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(tcol, 0, 0); + lv_obj_set_style_pad_all(tcol, 0, 0); + lv_obj_set_style_pad_row(tcol, 2, 0); + lv_obj_set_flex_flow(tcol, LV_FLEX_FLOW_COLUMN); + lv_obj_clear_flag(tcol, LV_OBJ_FLAG_SCROLLABLE); + g_tuneCardTitle = lv_label_create(tcol); + lv_label_set_text(g_tuneCardTitle, "PID-Tuning läuft..."); + lv_obj_set_style_text_color(g_tuneCardTitle, COL_WARN, 0); + lv_obj_set_style_text_font(g_tuneCardTitle, &lv_font_maven_pro_18, 0); + g_tuneCardBody = lv_label_create(tcol); + lv_label_set_text(g_tuneCardBody, "--"); + lv_obj_set_style_text_color(g_tuneCardBody, COL_TEXT, 0); g_chart = lv_chart_create(p); lv_obj_set_width(g_chart, LV_PCT(100)); @@ -1276,17 +1410,38 @@ static void build_chart(lv_obj_t* p) { lv_obj_set_style_width(g_chart, 0, LV_PART_INDICATOR); // keine Punkt-Marker lv_obj_set_style_height(g_chart, 0, LV_PART_INDICATOR); lv_chart_set_type(g_chart, LV_CHART_TYPE_LINE); - lv_chart_set_point_count(g_chart, 120); // 120 * 1 s = 2 min - lv_chart_set_update_mode(g_chart, LV_CHART_UPDATE_MODE_SHIFT); + lv_chart_set_point_count(g_chart, 120); // fest; Zeit je Punkt haengt am Fenster lv_chart_set_div_line_count(g_chart, 5, 0); - lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, 80, 160); // Temperaturen (Grad C) + lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, 800, 1600); // Zehntelgrad (feinere Kurven) + lv_chart_set_range(g_chart, LV_CHART_AXIS_SECONDARY_Y, 0, 100); // Heizleistung (%) g_serW = lv_chart_add_series(g_chart, COL_ACCENT, LV_CHART_AXIS_PRIMARY_Y); g_serD = lv_chart_add_series(g_chart, lv_color_hex(0x80cbc4), LV_CHART_AXIS_PRIMARY_Y); g_serC = lv_chart_add_series(g_chart, COL_CASE, LV_CHART_AXIS_PRIMARY_Y); + g_serDutyW = lv_chart_add_series(g_chart, COL_DUTY_W, LV_CHART_AXIS_SECONDARY_Y); + g_serDutyD = lv_chart_add_series(g_chart, COL_DUTY_D, LV_CHART_AXIS_SECONDARY_Y); lv_chart_set_all_value(g_chart, g_serW, LV_CHART_POINT_NONE); lv_chart_set_all_value(g_chart, g_serD, LV_CHART_POINT_NONE); lv_chart_set_all_value(g_chart, g_serC, LV_CHART_POINT_NONE); + lv_chart_set_all_value(g_chart, g_serDutyW, LV_CHART_POINT_NONE); + lv_chart_set_all_value(g_chart, g_serDutyD, LV_CHART_POINT_NONE); + lv_chart_hide_series(g_chart, g_serDutyW, !g_chartDuty); + lv_chart_hide_series(g_chart, g_serDutyD, !g_chartDuty); + + // Gestrichelte Soll-Linien (Wasser/Dampf); Position rechnet chart_redraw aus der Skala + lv_color_t setCols[2] = { COL_ACCENT, lv_color_hex(0x80cbc4) }; + lv_obj_t** setLines[2] = { &g_lineSetW, &g_lineSetD }; + for (int i = 0; i < 2; i++) { + lv_obj_t* ln = lv_line_create(g_chart); + lv_obj_set_style_line_width(ln, 1, 0); + lv_obj_set_style_line_color(ln, setCols[i], 0); + lv_obj_set_style_line_opa(ln, LV_OPA_60, 0); + lv_obj_set_style_line_dash_width(ln, 6, 0); + lv_obj_set_style_line_dash_gap(ln, 6, 0); + lv_obj_clear_flag(ln, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(ln, LV_OBJ_FLAG_HIDDEN); + *setLines[i] = ln; + } // Y-Skala-Beschriftung (Grad C) im linken Gutter: oben=hi, mitte, unten=lo g_axHi = lv_label_create(g_chart); lv_obj_align(g_axHi, LV_ALIGN_TOP_LEFT, -38, -2); @@ -1308,39 +1463,100 @@ static void build_chart(lv_obj_t* p) { g_legC = lv_label_create(leg); lv_label_set_text(g_legC, ""); lv_obj_set_style_text_color(g_legC, COL_CASE, 0); lv_obj_add_flag(g_legC, LV_OBJ_FLAG_HIDDEN); // nur wenn Zusatzsensor aktiv + g_legDuty = lv_label_create(leg); lv_label_set_text(g_legDuty, "Leistung --"); + lv_obj_set_style_text_color(g_legDuty, COL_TEXT_DIM, 0); + if (!g_chartDuty) lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN); +} +// Zeichnet die 120 Chart-Punkte aus der 1-s-Historie (je Fenster dezimiert), skaliert die +// Y-Achse ueber das sichtbare Fenster (Werte < 30 Grad = kalter/inaktiver Sensor werden +// fuer die Skala ignoriert) und positioniert die gestrichelten Soll-Linien. +static void chart_redraw() { + if (!g_chart) return; + const int stride = (int)CHART_WIN_MIN[g_chartWin] * 60 / 120; // Sekunden je Chart-Punkt + int32_t lo = INT32_MAX, hi = INT32_MIN; // Zehntelgrad + bool anyW = false, anyD = false; + for (int i = 0; i < 120; i++) { + int age = (119 - i) * stride; // Alter der Probe (Sekunden) + int32_t vW = LV_CHART_POINT_NONE, vD = LV_CHART_POINT_NONE, vC = LV_CHART_POINT_NONE; + int32_t dW = LV_CHART_POINT_NONE, dD = LV_CHART_POINT_NONE; + if (age < (int)g_histFill) { + int idx = (int)g_histPos - 1 - age; + if (idx < 0) idx += CHART_HIST_MAX; + vW = g_histW[idx]; + vD = g_histD[idx]; + if (g_histC[idx] != CHART_HIST_NONE) vC = g_histC[idx]; + dW = g_histDutyW[idx]; + dD = g_histDutyD[idx]; + if (vW > 300) { anyW = true; if (vW < lo) lo = vW; if (vW > hi) hi = vW; } + if (vD > 300) { anyD = true; if (vD < lo) lo = vD; if (vD > hi) hi = vD; } + if (vC != LV_CHART_POINT_NONE && vC > 300) { if (vC < lo) lo = vC; if (vC > hi) hi = vC; } + } + lv_chart_set_value_by_id(g_chart, g_serW, i, vW); + lv_chart_set_value_by_id(g_chart, g_serD, i, vD); + lv_chart_set_value_by_id(g_chart, g_serC, i, vC); + lv_chart_set_value_by_id(g_chart, g_serDutyW, i, dW); + lv_chart_set_value_by_id(g_chart, g_serDutyD, i, dD); + } + // Soll-Werte in die Skala einbeziehen, damit die Soll-Linien im Bild liegen - + // aber nur fuer Kreise, deren Ist-Kurve im Fenster ueberhaupt sichtbar ist + int32_t setWt = (int32_t)(g_state.setW * 10.0f + 0.5f); + int32_t setDt = (int32_t)(g_state.setD * 10.0f + 0.5f); + bool showLineW = anyW && setWt > 300; + bool showLineD = anyD && setDt > 300; + if (showLineW) { if (setWt < lo) lo = setWt; if (setWt > hi) hi = setWt; } + if (showLineD) { if (setDt < lo) lo = setDt; if (setDt > hi) hi = setDt; } + if (hi >= lo) { + int loDeg = ((int)(lo / 10) - 3) / 5 * 5; // auf 5 Grad abrunden (mit Rand) + int hiDeg = ((int)(hi / 10) + 7) / 5 * 5; // auf 5 Grad aufrunden (mit Rand) + if (hiDeg - loDeg < 10) hiDeg = loDeg + 10; // Mindestspanne 10 Grad + lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, loDeg * 10, hiDeg * 10); + if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hiDeg); + if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (loDeg + hiDeg) / 2); + if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", loDeg); + + // Soll-Linien positionieren (horizontale Linie ueber die volle Plotbreite) + int32_t w = lv_obj_get_content_width(g_chart); + int32_t h = lv_obj_get_content_height(g_chart); + struct { lv_obj_t* line; lv_point_precise_t* pts; int32_t val; bool show; } L[2] = { + { g_lineSetW, g_lineSetPts[0], setWt, showLineW }, + { g_lineSetD, g_lineSetPts[1], setDt, showLineD }, + }; + for (int i = 0; i < 2; i++) { + if (!L[i].line) continue; + int32_t span = (hiDeg - loDeg) * 10; + if (L[i].show && w > 0 && h > 0 && span > 0 && + L[i].val >= loDeg * 10 && L[i].val <= hiDeg * 10) { + int32_t y = (int32_t)((int64_t)h * (hiDeg * 10 - L[i].val) / span); + L[i].pts[0].x = 0; L[i].pts[0].y = 0; + L[i].pts[1].x = w; L[i].pts[1].y = 0; + lv_line_set_points(L[i].line, L[i].pts, 2); + lv_obj_align(L[i].line, LV_ALIGN_TOP_LEFT, 0, y); + lv_obj_remove_flag(L[i].line, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(L[i].line, LV_OBJ_FLAG_HIDDEN); + } + } + } + lv_chart_refresh(g_chart); } static void chart_sample_cb(lv_timer_t*) { if (!g_chart) return; - int wv = (int)(g_state.tempW + 0.5f); - int dv = (int)(g_state.tempD + 0.5f); + // 1-s-Probe in die 60-min-Historie (Ringpuffer); Anzeige zeichnet chart_redraw bool caseOn = g_state.caseSensorEnabled && g_state.hasCaseTemp; - int cv = caseOn ? (int)(g_state.caseTemp + 0.5f) : 0; - lv_chart_set_next_value(g_chart, g_serW, (int32_t)wv); - lv_chart_set_next_value(g_chart, g_serD, (int32_t)dv); - lv_chart_set_next_value(g_chart, g_serC, caseOn ? (int32_t)cv : LV_CHART_POINT_NONE); + g_histW[g_histPos] = (int16_t)(g_state.tempW * 10.0f + 0.5f); + g_histD[g_histPos] = (int16_t)(g_state.tempD * 10.0f + 0.5f); + g_histC[g_histPos] = caseOn ? (int16_t)(g_state.caseTemp * 10.0f + 0.5f) : CHART_HIST_NONE; + float dw = g_state.dutyW, dd = g_state.dutyD; + g_histDutyW[g_histPos] = (uint8_t)(dw < 0.0f ? 0 : (dw > 100.0f ? 100 : (int)(dw + 0.5f))); + g_histDutyD[g_histPos] = (uint8_t)(dd < 0.0f ? 0 : (dd > 100.0f ? 100 : (int)(dd + 0.5f))); + g_histPos = (uint16_t)((g_histPos + 1) % CHART_HIST_MAX); + if (g_histFill < CHART_HIST_MAX) g_histFill++; - // Automatische Skalierung der Temperatur-Achse ueber ein gleitendes 120-Werte-Fenster. - // Werte < 30 (kalter/inaktiver Sensor) werden ignoriert, damit sie die Skala nicht - // an den unteren Rand ziehen (sonst sehen die Kurven flach aus). - static int16_t hW[120], hD[120], hC[120]; - static int pos = 0, fill = 0; - hW[pos] = (int16_t)wv; hD[pos] = (int16_t)dv; hC[pos] = caseOn ? (int16_t)cv : -1000; - pos = (pos + 1) % 120; if (fill < 120) fill++; - int lo = 1000, hi = -1000; - for (int i = 0; i < fill; i++) { - if (hW[i] > 30) { if (hW[i] < lo) lo = hW[i]; if (hW[i] > hi) hi = hW[i]; } - if (hD[i] > 30) { if (hD[i] < lo) lo = hD[i]; if (hD[i] > hi) hi = hD[i]; } - if (hC[i] > 30) { if (hC[i] < lo) lo = hC[i]; if (hC[i] > hi) hi = hC[i]; } - } - if (hi >= lo) { - lo = ((lo - 3) / 5) * 5; // auf 5 abrunden (mit Rand) - hi = ((hi + 7) / 5) * 5; // auf 5 aufrunden (mit Rand) - if (hi - lo < 10) hi = lo + 10; // Mindestspanne 10 Grad - lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, lo, hi); - if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hi); - if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (lo + hi) / 2); - if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", lo); - } + chart_redraw(); + + if (g_legDuty && g_chartDuty) + lv_label_set_text_fmt(g_legDuty, "Leistung W %d %% / D %d %%", + (int)(dw + 0.5f), (int)(dd + 0.5f)); if (g_legW) lv_label_set_text(g_legW, ("Wasser " + tempStr(g_state.tempW, 1)).c_str()); if (g_legD) lv_label_set_text(g_legD, ("Dampf " + tempStr(g_state.tempD, 0)).c_str()); @@ -2576,6 +2792,9 @@ void ui_init(ProtocolClient* client) { { Preferences prefs; prefs.begin("ui", false); g_tempDesign = prefs.getUChar("tempdsgn", TD_GAUGE); g_brewLiveEnabled = prefs.getBool("brewlive", true); + g_chartWin = prefs.getUChar("chartwin", 0); + if (g_chartWin > 3) g_chartWin = 0; + g_chartDuty = prefs.getBool("chartduty", false); prefs.end(); if (g_tempDesign >= TD_DESIGN_COUNT) g_tempDesign = TD_GAUGE; } @@ -2914,6 +3133,25 @@ static void appendTuneResult(String& out, const char* kreis, uint8_t status, int if (s > sev) sev = s; } +// Eine Live-Zeile der Tuning-Karte: Kreis, Laufzeit, Heiz-/Abkuehlphase und Schwingungshub +// (Min/Max der Ist-Temperatur seit Tuning-Start; zeigt, wie stark der Kessel pendelt). +static void appendTuneLive(String& out, const char* kreis, uint32_t elapsedMs, + float duty, float tMin, float tMax) { + if (out.length()) out += "\n"; + uint32_t s = elapsedMs / 1000; + char b[16]; + snprintf(b, sizeof(b), "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60)); + out += kreis; + out += " · "; + out += b; + out += " min · "; + out += (duty >= 50.0f) ? "heizt" : "kühlt ab"; + float hub = tMax - tMin; + if (hub >= 0.05f) { + out += " · Hub " + tempStr(hub, 1) + " um " + tempStr((tMin + tMax) * 0.5f, 1); + } +} + // ===================================================================================== // Live-Aktualisierung // ===================================================================================== @@ -3041,10 +3279,15 @@ void ui_update(const MachineState& st) { status_set(ST_CLEAN, false); } - // PID-Tuning-Hinweis (AutoTune Wasser/Dampf laeuft) + // PID-Tuning: Statuszeile (antippbar -> Verlauf-Seite) + Live-Karte auf der Verlauf-Seite { - // Fallende Flanke je Kreis erkennen -> Ergebnis ~30 s einblenden uint32_t nowMs = millis(); + // Steigende Flanke je Kreis: Startzeit latchen, Schwingungs-Min/Max zuruecksetzen + if (!g_tunePrevW && st.autoTuneW) { g_tuneStartW = nowMs; g_tuneMinW = g_tuneMaxW = st.tempW; } + if (!g_tunePrevD && st.autoTuneD) { g_tuneStartD = nowMs; g_tuneMinD = g_tuneMaxD = st.tempD; } + if (st.autoTuneW) { if (st.tempW < g_tuneMinW) g_tuneMinW = st.tempW; if (st.tempW > g_tuneMaxW) g_tuneMaxW = st.tempW; } + if (st.autoTuneD) { if (st.tempD < g_tuneMinD) g_tuneMinD = st.tempD; if (st.tempD > g_tuneMaxD) g_tuneMaxD = st.tempD; } + // Fallende Flanke je Kreis erkennen -> Ergebnis ~30 s einblenden if (g_tunePrevW && !st.autoTuneW) { g_tuneResW = st.autoTuneWStatus; g_tuneResUntilW = nowMs + 30000; } if (g_tunePrevD && !st.autoTuneD) { g_tuneResD = st.autoTuneDStatus; g_tuneResUntilD = nowMs + 30000; } g_tunePrevW = st.autoTuneW; @@ -3055,26 +3298,55 @@ void ui_update(const MachineState& st) { if (g_tuneResUntilW != 0 && !showResW) g_tuneResUntilW = 0; // abgelaufen -> aus if (g_tuneResUntilD != 0 && !showResD) g_tuneResUntilD = 0; + // Ergebnistext des/der zuletzt beendeten Laufs/Laeufe (Statuszeile und Karte teilen ihn) + String resTxt; int resSev = 0; + if (showResW) appendTuneResult(resTxt, "Wasser", g_tuneResW, resSev); + if (showResD) appendTuneResult(resTxt, "Dampf", g_tuneResD, resSev); + if (st.autoTuneW || st.autoTuneD) { const char* txt = (st.autoTuneW && st.autoTuneD) ? LV_SYMBOL_SETTINGS " PID-Tuning läuft (Wasser + Dampf)..." : st.autoTuneW ? LV_SYMBOL_SETTINGS " PID-Tuning Wasser läuft..." : LV_SYMBOL_SETTINGS " PID-Tuning Dampf läuft..."; status_set(ST_TUNE, true, txt, COL_WARN, lv_color_hex(0x000000)); - } else if (showResW || showResD) { - // Ergebnis des/der zuletzt beendeten Laufs/Laeufe anzeigen - String txt; int sev = 0; - if (showResW) appendTuneResult(txt, "Wasser", g_tuneResW, sev); - if (showResD) appendTuneResult(txt, "Dampf", g_tuneResD, sev); - if (txt.length()) { - lv_color_t bg = (sev >= 2) ? COL_DANGER : (sev == 1 ? COL_WARN : COL_OK); - lv_color_t fg = (sev == 1) ? lv_color_hex(0x000000) : COL_TEXT; - status_set(ST_TUNE, true, txt.c_str(), bg, fg); - } else { - status_set(ST_TUNE, false); - } + } else if (resTxt.length()) { + lv_color_t bg = (resSev >= 2) ? COL_DANGER : (resSev == 1 ? COL_WARN : COL_OK); + lv_color_t fg = (resSev == 1) ? lv_color_hex(0x000000) : COL_TEXT; + status_set(ST_TUNE, true, resTxt.c_str(), bg, fg); } else { status_set(ST_TUNE, false); } + + // Live-Karte auf der Verlauf-Seite: waehrend des Laufs Laufzeit/Phase/Schwingungshub, + // nach dem Ende ~30 s das Ergebnis; erscheint und verschwindet weich (Fade) + bool wantCard = st.autoTuneW || st.autoTuneD || resTxt.length() > 0; + if (g_tuneCard) { + if (st.autoTuneW || st.autoTuneD) { + if (g_tuneCardSpin) lv_obj_remove_flag(g_tuneCardSpin, LV_OBJ_FLAG_HIDDEN); + if (g_tuneCardTitle) { + lv_label_set_text(g_tuneCardTitle, "PID-Tuning läuft..."); + lv_obj_set_style_text_color(g_tuneCardTitle, COL_WARN, 0); + } + String b; + if (st.autoTuneW) appendTuneLive(b, "Wasser", nowMs - g_tuneStartW, st.dutyW, g_tuneMinW, g_tuneMaxW); + if (st.autoTuneD) appendTuneLive(b, "Dampf", nowMs - g_tuneStartD, st.dutyD, g_tuneMinD, g_tuneMaxD); + if (g_tuneCardBody) label_set_if_changed(g_tuneCardBody, b.c_str()); + } else if (wantCard) { + if (g_tuneCardSpin) lv_obj_add_flag(g_tuneCardSpin, LV_OBJ_FLAG_HIDDEN); + lv_color_t c = (resSev >= 2) ? COL_DANGER : (resSev == 1 ? COL_WARN : COL_OK); + if (g_tuneCardTitle) { + lv_label_set_text(g_tuneCardTitle, "PID-Tuning beendet"); + lv_obj_set_style_text_color(g_tuneCardTitle, c, 0); + } + String b = resTxt; + b.replace(" | ", "\n"); // Karte darf mehrzeilig sein + if (g_tuneCardBody) label_set_if_changed(g_tuneCardBody, b.c_str()); + } + if (wantCard != g_tuneCardShown) { + g_tuneCardShown = wantCard; + if (wantCard) ui_fade_in(g_tuneCard, UI_FADE_MS); + else ui_fade_out_hide(g_tuneCard, UI_FADE_MS); + } + } } // Reinigungsassistent-Sub-Screen: Fortschritt/Sichtbarkeit aktualisieren (nur wenn offen)