Repository navigation
Expand file tree
/
Copy pathwebfunctions.cpp
More file actions
1168 lines (1089 loc) · 63.5 KB
/
Copy pathwebfunctions.cpp
File metadata and controls
1168 lines (1089 loc) · 63.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include <LittleFS.h>
#include <WiFiManager.h> //https://github.com/tzapu/WiFiManager
#include <ArduinoJson.h> //https://github.com/bblanchon/ArduinoJson
#include "webfunctions.h"
#include "decode.h"
#include "version.h"
// flag for saving
bool shouldSaveConfig = false;
static const char refreshMeta[] PROGMEM = "<meta http-equiv='refresh' content='5; url=/' />";
// CSS is embedded (styles the existing w3-class names): the UI must work
// without internet access, previously w3.css and jquery came from CDNs
// charset MUSS frueh im <head> stehen (in den ersten 1024 Bytes), sonst raet
// der Browser und macht aus "laeuft" mit Umlaut Kauderwelsch. Bis 3.12.0 war
// jeder Text der Oberflaeche in ae/oe/ue geschrieben und die Zeile deshalb
// entbehrlich; die Notbetriebsseite liest im Ernstfall jemand aus der Familie,
// und dort steht ab 3.13.0 richtiges Deutsch.
static const char webHeader[] PROGMEM = "<!DOCTYPE html><html><title>Heisha monitor</title><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'><style>"
"*{box-sizing:border-box}body{font-family:Verdana,sans-serif;margin:0}h4{margin:10px 0}"
".w3-container{padding:0.01em 16px}.w3-card-4{box-shadow:0 4px 10px 0 rgba(0,0,0,.2)}"
".w3-card{box-shadow:0 2px 5px 0 rgba(0,0,0,.2)}.w3-center{text-align:center}.w3-left{float:left}"
".w3-small{font-size:12px}.w3-medium{font-size:15px}.w3-text-grey{color:#757575}"
".w3-xlarge{font-size:24px}.w3-padding-large{padding:12px 24px}"
".w3-panel{padding:0.01em 16px;margin:16px 0}"
".w3-button{border:none;padding:8px 16px;cursor:pointer;background:inherit;display:inline-block}"
// Farben NACH .w3-button: dessen background:inherit hat dieselbe
// Spezifitaet und wuerde eine vorher stehende Farbe ueberschreiben. Genau
// daran war der Notbetriebsknopf grau statt rot - und der Save-Knopf der
// Settings-Seite grau statt gruen.
".w3-theme,.w3-blue{background:#2196F3;color:#fff}.w3-green{background:#4CAF50;color:#fff}"
".w3-red{background:#f44336;color:#fff}.w3-orange{background:#ff9800;color:#fff}"
".w3-yellow{background:#ffeb3b;color:#000}"
// Blassgelb fuer den Kurvenhinweis (3.14.1): ein Hinweis, keine Sperre -
// Orange bleibt der Sperre vorbehalten, das kraeftige Gelb dem laufenden
// Vorgang. Beides daneben zu stellen, waere ein drittes Warnsignal.
".w3-pale-yellow{background:#ffffcc;color:#000}"
".w3-sidebar{position:fixed;top:0;left:0;height:100%;width:200px;background:#fff;z-index:2;overflow:auto}"
".w3-bar-block .w3-bar-item{display:block;width:100%;text-align:left;text-decoration:none;color:#000;padding:8px 16px}"
".w3-bar-block .w3-bar-item:hover{background:#ccc}"
".w3-table-all{border-collapse:collapse;width:100%;margin:16px 0;border:1px solid #ccc}"
".w3-table-all th,.w3-table-all td{padding:6px 8px;text-align:left;border-bottom:1px solid #ddd}"
".w3-table-all tr:nth-child(even){background:#f1f1f1}"
"input{padding:8px;border:1px solid #ccc;margin:4px 0}"
"</style>";
// stage-specific page title comes as build flag from platformio.ini, fallback = stage 1
#ifndef HEISHA_STAGE_NAME
#define HEISHA_STAGE_NAME "Heisha Stufe 1"
#endif
static const char webBodyStart[] PROGMEM = "<button class='w3-button w3-blue w3-medium w3-left' onclick='openLeftMenu()'>☰</button><header class='w3-container w3-card-4 w3-theme'><h4>" HEISHA_STAGE_NAME "</h4></header>";
static const char webFooter[] PROGMEM = "</body></html>";
static const char menuJS[] PROGMEM = "<script> function openLeftMenu() {var x = document.getElementById('leftMenu');if (x.style.display === 'none') {x.style.display = 'block';} else {x.style.display = 'none';}}</script>";
// table refresh via native fetch instead of jquery (was loaded from CDN)
static const char refreshJS[] PROGMEM = "<script>function refreshTable(){fetch('/tablerefresh').then(function(r){return r.text()}).then(function(t){document.getElementById('heishavalues').innerHTML=t}).catch(function(){}).finally(function(){setTimeout(refreshTable,30000)})}document.addEventListener('DOMContentLoaded',refreshTable);</script></head><body>";
static const char sidebar[] PROGMEM = "<a href='/' class='w3-bar-item w3-button w3-small'>Home</a><a href='/reboot' class='w3-bar-item w3-button w3-small'>Reboot</a><a href='/firmware' class='w3-bar-item w3-button w3-small'>Firmware</a><a href='/settings' class='w3-bar-item w3-button w3-small'>Settings</a><a href='/notbetrieb' class='w3-bar-item w3-button w3-small'>Notbetrieb</a><a href='/togglelog' class='w3-bar-item w3-button w3-small'>Toggle mqtt log</a><a href='/toggledebug' class='w3-bar-item w3-button w3-small'>Toggle debug log</a>";
// callback notifying us of the need to save config
void saveConfigCallback()
{
Serial.println("Should save config");
shouldSaveConfig = true;
}
/*****************************************************************************/
/* Ein Feld aus der config.json uebernehmen */
/* */
/* Fehlt der Schluessel oder steht dort kein Text, bleibt der einkompilierte */
/* Standardwert stehen. Vorher stand hier strncpy(dst, jsonDoc[key], 39): */
/* ArduinoJson liefert fuer einen fehlenden Schluessel einen NULLZEIGER, und */
/* strncpy stuerzt damit ab - das Geraet waere in einer Boot-Endlosschleife */
/* gelandet und nur per USB an der Waermepumpe zu retten. */
/* Der realistische Ausloeser ist nicht das Bearbeiten der Datei von Hand, */
/* sondern ein spaeterer Firmware-Stand, der ein NEUES Feld liest: die */
/* config.json auf dem Geraet kennt es noch nicht, und beide Kaskadenstufen */
/* wuerden direkt nach dem OTA gleichzeitig ausfallen. */
/*****************************************************************************/
static void loadConfigValue(char *dst, size_t dstsize, JsonDocument &jsonDoc, const char *key)
{
const char *value = jsonDoc[key];
if (value == nullptr)
{
Serial.printf("Config: key '%s' missing, keeping default '%s'\n", key, dst);
return;
}
// strlcpy terminiert immer - die frueheren dst[n] = '\0'-Zeilen entfallen
(void)strlcpy(dst, value, dstsize);
}
/*****************************************************************************/
/* Passwort des Setup-Hotspots (WPA2, seit 3.8.1) */
/* */
/* Das Konfigurationsportal ist kein reines Erstboot-Thema: Faellt das WLAN */
/* aus, startet der Watchdog das Geraet nach 5 min neu, autoConnect scheitert */
/* nach 10 s und oeffnet dann fuer 180 s den AP - zyklisch, solange die */
/* Stoerung dauert. Die Parameterfelder sind dabei mit den ECHTEN Werten */
/* vorbefuellt, darunter OTA- und MQTT-Passwort. Ohne WPA2 waere das ein */
/* Fenster, in dem jeder in Funkreichweite die Zugangsdaten mitlesen oder */
/* dem Geraet einen fremden MQTT-Broker unterschieben kann. */
/* */
/* Das Passwort kommt als Build-Flag aus platformio_user_env.ini und steht */
/* NICHT in git - gleiches Muster wie Ports, IPs und OTA-Passwort. Der */
/* Fallback unten ist nur der Notnagel fuer einen Build ohne diese Datei; er */
/* liegt im oeffentlichen Repo und schuetzt entsprechend wenig. */
/*****************************************************************************/
#ifndef HEISHA_AP_PASSWORD
#define HEISHA_AP_PASSWORD "heishamon"
#warning "HEISHA_AP_PASSWORD nicht gesetzt - der Setup-AP laeuft mit dem oeffentlich bekannten Fallback (siehe platformio_user_env_sample.ini)"
#endif
// WPA2-Grenzen pruefen, solange es nichts kostet: ein zu kurzes Passwort
// verwirft der WiFiManager still und macht den AP wieder offen auf. Der Build
// bricht hier lieber, als dass es erst an der Waermepumpe auffaellt.
static_assert(sizeof(HEISHA_AP_PASSWORD) >= 9, "HEISHA_AP_PASSWORD braucht mindestens 8 Zeichen (WPA2)");
static_assert(sizeof(HEISHA_AP_PASSWORD) <= 64, "HEISHA_AP_PASSWORD darf hoechstens 63 Zeichen haben (WPA2)");
void setupWifi(char *wifi_hostname, char *ota_password, char *mqtt_server, char *mqtt_port, char *mqtt_username, char *mqtt_password, char *hydraulik_switch, char *knx_schnittstelle)
{
// Local intialization. Once its business is done, there is no need to keep it around
WiFiManager wifiManager;
wifiManager.setDebugOutput(true); // this is debugging on serial port, because serial swap is done after full startup this is ok
Serial.println("mounting LittleFS...");
if (LittleFS.begin(true)) // format on first mount
{
Serial.println("Mount file system");
if (LittleFS.exists("/config.json"))
{
// file exists, reading and loading
Serial.println("Read config file");
File configFile = LittleFS.open("/config.json", "r");
if (configFile)
{
Serial.println("Open config file");
size_t size = configFile.size();
// Puffer eins groesser als die Datei und selbst terminieren:
// deserializeJson erwartet einen nullterminierten String und haette
// sonst hinter dem Dateiende weitergelesen
std::unique_ptr<char[]> buf(new char[size + 1]);
size_t read = configFile.readBytes(buf.get(), size);
buf[read] = '\0';
// Seit 3.16.0 (ArduinoJson 7) waechst das Dokument elastisch; die
// frueher noetige feste Groesse ist entfallen. Beim LESEN war sie
// ohnehin unkritisch: Passte die Datei nicht, kam ein
// DeserializationError, den der else-Zweig unten abfaengt.
JsonDocument jsonDoc;
DeserializationError error = deserializeJson(jsonDoc, buf.get());
serializeJson(jsonDoc, Serial);
if (!error)
{
Serial.println("\nparsed json");
// fehlende Schluessel lassen den Standardwert stehen, siehe oben
loadConfigValue(wifi_hostname, CONFIG_FIELD_LEN, jsonDoc, "wifi_hostname");
loadConfigValue(ota_password, CONFIG_FIELD_LEN, jsonDoc, "ota_password");
loadConfigValue(mqtt_server, CONFIG_FIELD_LEN, jsonDoc, "mqtt_server");
loadConfigValue(mqtt_port, CONFIG_PORT_LEN, jsonDoc, "mqtt_port");
loadConfigValue(mqtt_username, CONFIG_FIELD_LEN, jsonDoc, "mqtt_username");
loadConfigValue(mqtt_password, CONFIG_FIELD_LEN, jsonDoc, "mqtt_password");
// Fehlt der Schluessel (config.json von vor 3.15.0), bleibt das Feld
// leer - loadConfigValue laesst den Standardwert stehen. Genau dafuer
// ist es gebaut, siehe Kommentar dort.
loadConfigValue(hydraulik_switch, CONFIG_FIELD_LEN, jsonDoc, "hydraulik_switch");
// Ebenso die KNX-Schnittstelle (3.21.0): Fehlt der Schluessel, bleibt
// das Feld leer, und der Vorderhausschritt endet ROT.
loadConfigValue(knx_schnittstelle, CONFIG_FIELD_LEN, jsonDoc, "knx_schnittstelle");
}
else
{
Serial.println("Failed to load config, forcing config reset.");
wifiManager.resetSettings();
}
configFile.close();
}
}
else
{
Serial.println("No config. Forcing reset to default.");
wifiManager.resetSettings();
}
}
else
{
Serial.println("Failed to mount FS");
}
// The extra parameters to be configured (can be either global or just in the setup)
// After connecting, parameter.getValue() will get you the configured value
// id/name placeholder/prompt default length
WiFiManagerParameter custom_text1("<p>Hostname and OTA password</p>");
WiFiManagerParameter custom_wifi_hostname("wifi_hostname", "wifi hostname", wifi_hostname, CONFIG_FIELD_LEN - 1);
WiFiManagerParameter custom_ota_password("ota_password", "ota password", ota_password, CONFIG_FIELD_LEN - 1);
WiFiManagerParameter custom_text2("<p>MQTT settings</p>");
WiFiManagerParameter custom_mqtt_server("server", "mqtt server", mqtt_server, CONFIG_FIELD_LEN - 1);
WiFiManagerParameter custom_mqtt_port("port", "mqtt port", mqtt_port, CONFIG_PORT_LEN - 1);
WiFiManagerParameter custom_mqtt_username("username", "mqtt username", mqtt_username, CONFIG_FIELD_LEN - 1);
WiFiManagerParameter custom_mqtt_password("password", "mqtt password", mqtt_password, CONFIG_FIELD_LEN - 1);
WiFiManagerParameter custom_text3("<p>Hydraulik switch (Tasmota, IP or hostname)</p>");
WiFiManagerParameter custom_hydraulik_switch("hydraulik_switch", "hydraulik switch", hydraulik_switch, CONFIG_FIELD_LEN - 1);
WiFiManagerParameter custom_text4("<p>KNX interface for Vorderhaus (IP, optional :port)</p>");
WiFiManagerParameter custom_knx_schnittstelle("knx_schnittstelle", "knx interface", knx_schnittstelle, CONFIG_FIELD_LEN - 1);
// set config save notify callback
wifiManager.setSaveConfigCallback(saveConfigCallback);
// add all your parameters here
wifiManager.addParameter(&custom_text1);
wifiManager.addParameter(&custom_wifi_hostname);
wifiManager.addParameter(&custom_ota_password);
wifiManager.addParameter(&custom_text2);
wifiManager.addParameter(&custom_mqtt_server);
wifiManager.addParameter(&custom_mqtt_port);
wifiManager.addParameter(&custom_mqtt_username);
wifiManager.addParameter(&custom_mqtt_password);
wifiManager.addParameter(&custom_text3);
wifiManager.addParameter(&custom_hydraulik_switch);
wifiManager.addParameter(&custom_text4);
wifiManager.addParameter(&custom_knx_schnittstelle);
wifiManager.setConfigPortalTimeout(180);
wifiManager.setConnectTimeout(10);
// AP mit WPA2 statt offen - Begruendung und Herkunft des Passworts siehe oben
if (!wifiManager.autoConnect("HeishaMon-Setup", HEISHA_AP_PASSWORD))
{
Serial.println("failed to connect and hit timeout");
delay(3000);
// reset and try again, or maybe put it to deep sleep
ESP.restart();
delay(5000);
}
// if you get here you have connected to the WiFi
Serial.println("Wifi connected...yeey :)");
// Werte aus dem Konfigurationsportal uebernehmen (strlcpy terminiert immer)
(void)strlcpy(wifi_hostname, custom_wifi_hostname.getValue(), CONFIG_FIELD_LEN);
(void)strlcpy(ota_password, custom_ota_password.getValue(), CONFIG_FIELD_LEN);
(void)strlcpy(mqtt_server, custom_mqtt_server.getValue(), CONFIG_FIELD_LEN);
(void)strlcpy(mqtt_port, custom_mqtt_port.getValue(), CONFIG_PORT_LEN);
(void)strlcpy(mqtt_username, custom_mqtt_username.getValue(), CONFIG_FIELD_LEN);
(void)strlcpy(mqtt_password, custom_mqtt_password.getValue(), CONFIG_FIELD_LEN);
(void)strlcpy(hydraulik_switch, custom_hydraulik_switch.getValue(), CONFIG_FIELD_LEN);
(void)strlcpy(knx_schnittstelle, custom_knx_schnittstelle.getValue(), CONFIG_FIELD_LEN);
// Set hostname on wifi rather than ESP_xxxxx
WiFi.setHostname(wifi_hostname);
// modem sleep breaks inbound connections (ping/http time out while outbound
// mqtt keeps working) - disable it, device is mains powered
WiFi.setSleep(false);
// save the custom parameters to FS
if (shouldSaveConfig)
{
Serial.println("Save config");
JsonDocument jsonDoc;
jsonDoc["wifi_hostname"] = wifi_hostname;
jsonDoc["ota_password"] = ota_password;
jsonDoc["mqtt_server"] = mqtt_server;
jsonDoc["mqtt_port"] = mqtt_port;
jsonDoc["mqtt_username"] = mqtt_username;
jsonDoc["mqtt_password"] = mqtt_password;
jsonDoc["hydraulik_switch"] = hydraulik_switch;
jsonDoc["knx_schnittstelle"] = knx_schnittstelle;
// Ueberlaufpruefung, siehe die ausfuehrliche Begruendung in handleSettings:
// Eine halb geschriebene config.json kostet nach dem Neustart womoeglich
// das MQTT-Passwort. Lieber den alten Stand behalten.
if (jsonDoc.overflowed())
{
Serial.println("Config document overflowed, keeping previous config.json");
}
else
{
File configFile = LittleFS.open("/config.json", "w");
if (!configFile)
{
Serial.println("Failed to open config file for writing");
}
else
{
serializeJson(jsonDoc, Serial);
serializeJson(jsonDoc, configFile);
configFile.close();
}
}
// end save
}
Serial.println("local ip");
Serial.println(WiFi.localIP());
}
/*****************************************************************************/
/* Die Saetze der Verbindungsanzeige */
/* */
/* Owner-Beobachtung 2026-08-21: Waehrend der Broker weg war, heizte die */
/* Waermepumpe mit dem zuletzt gesetzten Sollwert einfach weiter - kein */
/* Alarm, kein Hinweis, nichts. Wer die Seite oeffnet, soll das sehen. */
/* */
/* Der zweite Satz ist der wichtigere: Er beantwortet die Frage, die der */
/* erste aufwirft ("ist die Heizung jetzt aus?"). Ohne ihn loest die Meldung */
/* eine Panik aus, die die Lage nicht hergibt. */
/*****************************************************************************/
#define VB_TXT_VERBUNDEN "Hausteuerung: verbunden"
#define VB_TXT_WEG_VOR "Hausteuerung seit "
#define VB_TXT_WEG_NACH " nicht erreichbar."
#define VB_TXT_WEG_NEUSTART "Hausteuerung seit dem Neustart dieses Geräts nicht erreichbar."
#define VB_TXT_FOLGE "Die Wärmepumpe läuft mit dem zuletzt gesetzten Sollwert weiter. Wird es zu kalt, hilft der Notbetrieb."
#define VB_TXT_FOLGE_HIER "Die Wärmepumpe läuft mit dem zuletzt gesetzten Sollwert weiter."
// Der zweite Ausfall: Der Server im Keller antwortet, aber es kommt nichts mehr
// von ihm. Der Text muss sich deutlich vom ersten unterscheiden - wer zum
// Server läuft, soll wissen, ob dort überhaupt etwas zu holen ist. Deshalb
// steht hier ausdrücklich "erreichbar", und der Hinweis zeigt auf die
// Steuerungssoftware statt auf den Server.
#define VB_TXT_STUMM_VOR "Hausteuerung erreichbar, sendet aber seit "
#define VB_TXT_STUMM_NACH " keine Vorgaben."
#define VB_TXT_STUMM_FOLGE "Der Server antwortet, aber die Steuerung rechnet nicht mehr. Die Wärmepumpe läuft mit dem zuletzt gesetzten Sollwert weiter."
/*****************************************************************************/
/* Die Verbindungsanzeige - einmal als JavaScript, einmal als C++ */
/* */
/* Beide Seiten (Startseite und Notbetriebsseite) benutzen dieselben zwei */
/* Funktionen. Die Zahlen kommen aus der Statusroute, die Textform der Dauer */
/* ("14 Minuten", "mehr als 30 Tagen") kommt FERTIG von dort - gerechnet */
/* wird sie in src/verbindung.h, also an genau einer Stelle und vom Hosttest */
/* abgedeckt. Das JavaScript setzt nur den Rahmensatz darum. */
/* */
/* zeigeOk unterscheidet die beiden Seiten: Auf der Notbetriebsseite steht */
/* auch im Normalfall eine Zeile ("Hausteuerung: verbunden"), weil dort die */
/* Entscheidung "Knopf druecken oder nicht" ansteht und ein ruhiges */
/* "verbunden" die haeufigere Fehlentscheidung verhindert. Auf der */
/* Startseite steht im Normalfall nichts - sie ist ein Nachschauwerkzeug, */
/* keine Statusampel. */
/*****************************************************************************/
static const char verbindungJS[] PROGMEM =
"<script>"
"function vbSetzen(lage,dauer,zeigeOk){"
"var e=document.getElementById('nbverb');if(!e)return;"
// Lage 0 = verbunden, 1 = getrennt aber noch in der Karenz. Die Karenz
// sieht auf der Seite bewusst aus wie "verbunden": Ein Adapter-Neustart
// soll keine Meldung ausloesen, die von selbst wieder verschwindet.
"if(lage==0||lage==1){"
"if(zeigeOk){e.className='w3-text-grey w3-small';e.innerHTML='<p>" VB_TXT_VERBUNDEN "</p>';}"
"else{e.className='';e.innerHTML='';}return;}"
// Lage 4 = Broker da, aber keine Vorgaben mehr. Eigener Text, weil sonst
// jemand zum Server laeuft, um dort nach dem falschen Fehler zu suchen.
"if(lage==4){e.className='w3-panel w3-orange';"
"e.innerHTML='<h3>" VB_TXT_STUMM_VOR "'+dauer+'" VB_TXT_STUMM_NACH "</h3><p>" VB_TXT_STUMM_FOLGE "</p>';return;}"
// Lage 3 = seit dem Neustart nie verbunden. Dort ist die wahre Dauer
// unbekannt, eine Minutenangabe waere gelogen.
"var s=(lage==3)?'" VB_TXT_WEG_NEUSTART "':('" VB_TXT_WEG_VOR "'+dauer+'" VB_TXT_WEG_NACH "');"
"e.className='w3-panel w3-orange';"
"e.innerHTML='<h3>'+s+'</h3><p>'+(zeigeOk?'" VB_TXT_FOLGE_HIER "':'" VB_TXT_FOLGE "')+'</p>';}"
// Nachfuehren der Startseite: dieselbe Route wie der Notbetriebsknopf,
// aber im 30-s-Takt der Tabelle statt alle zwei Sekunden. Die
// Notbetriebsseite ruft vbSetzen aus ihrem eigenen 2-s-Takt heraus auf
// und braucht diese Funktion nicht.
"function vbState(){fetch('/notbetrieb/status').then(r=>r.text()).then(t=>{"
"var p=t.trim().split(';');vbSetzen(parseInt(p[5]),p[6],false);"
"}).catch(()=>{}).finally(()=>{setTimeout(vbState,30000)})}"
"</script>";
// Nur die Startseite startet das Nachfuehren von sich aus. Die
// Notbetriebsseite ruft vbSetzen() aus ihrem eigenen 2-s-Takt heraus auf und
// wuerde die Route sonst doppelt abfragen.
static const char verbindungStartJS[] PROGMEM =
"<script>document.addEventListener('DOMContentLoaded',vbState);</script>";
/*****************************************************************************/
/* Dieselbe Zeile beim Aufbau der Seite */
/* */
/* Wer die Seite oeffnet, soll die Lage sofort lesen koennen - auch in der */
/* Sekunde vor der ersten Statusabfrage. Gleiche Begruendung wie beim */
/* Sperrhinweis des Notbetriebsknopfs darunter. */
/*****************************************************************************/
static void verbindungszeile(String &out, bool zeigeOk)
{
const VerbindungsLage lage = verbindung_lage(&hausteuerung);
if (lage == VERBINDUNG_VERBUNDEN || lage == VERBINDUNG_KARENZ)
{
if (zeigeOk)
{
out += "<div id='nbverb' class='w3-text-grey w3-small'><p>" VB_TXT_VERBUNDEN "</p></div>";
}
else
{
out += "<div id='nbverb'></div>"; // leer, das JavaScript fuellt es bei Bedarf
}
return;
}
// Der Puffer ist grosszuegig: der laengste Text ist "mehr als 30 Tagen"
char dauer[32];
verbindung_dauer_text(dauer, sizeof(dauer),
verbindung_ausfall_sekunden(&hausteuerung),
verbindung_ueber_deckel(&hausteuerung));
out += "<div id='nbverb' class='w3-panel w3-orange'><h3>";
if (lage == VERBINDUNG_STEUERUNG_STUMM)
{
out += VB_TXT_STUMM_VOR;
out += dauer;
out += VB_TXT_STUMM_NACH;
out += "</h3><p>" VB_TXT_STUMM_FOLGE "</p></div>";
return;
}
if (lage == VERBINDUNG_GESTOERT_SEIT_NEUSTART)
{
out += VB_TXT_WEG_NEUSTART;
}
else
{
out += VB_TXT_WEG_VOR;
out += dauer;
out += VB_TXT_WEG_NACH;
}
out += "</h3><p>";
out += zeigeOk ? VB_TXT_FOLGE_HIER : VB_TXT_FOLGE;
out += "</p></div>";
}
void handleRoot(WebServerClass *httpServer)
{
httpServer->setContentLength(CONTENT_LENGTH_UNKNOWN);
httpServer->send(200, "text/html");
httpServer->sendContent_P(webHeader);
httpServer->sendContent_P(webBodyStart);
httpServer->sendContent_P(menuJS);
httpServer->sendContent_P(verbindungJS);
// Die Startseite fuehrt die Verbindungszeile im 30-s-Takt der Tabelle nach,
// nicht alle zwei Sekunden wie die Notbetriebsseite: Hier steht keine
// Entscheidung an, und das Geraet fragt nebenher die Waermepumpe ab.
httpServer->sendContent_P(verbindungStartJS);
// refreshJS schliesst den <head> und oeffnet den <body> - alles, was in den
// Kopf gehoert, muss davor stehen.
httpServer->sendContent_P(refreshJS);
String httptext = "<div class='w3-sidebar w3-bar-block w3-card w3-animate-left' style='display:none' id='leftMenu'>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(sidebar);
httptext = "<hr><div class='w3-text-grey w3-small'>Version: ";
httptext = httptext + heishamon_version + "<br><a href = 'https://github.com/gregor-samosir/HeishaMonKaskade '>Heishamon</a></div><hr></div>";
// Auf der Startseite steht die Zeile NUR im Stoerfall (zeigeOk = false).
// Sie ist ein Nachschauwerkzeug, keine Statusampel - ein dauerhaftes
// "verbunden" ueber der Topic-Tabelle waere Rauschen und wuerde nach
// kurzer Zeit uebersehen, samt der Stoermeldung an derselben Stelle.
httptext += "<br><div class='w3-container'>";
verbindungszeile(httptext, false);
httptext = httptext + "<table class = 'w3-table-all w3-card-4 w3-small'><thead><tr class = 'w3-blue'><th>Topic</th><th>Name</th><th>Value</th><th>Description</th></tr></thead><tbody id =\"heishavalues\"><tr><td>... Loading...</td><td>.</td><td>.</td><td>.</td></tr></tbody></table></div>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(webFooter);
httpServer->sendContent("");
httpServer->client().stop();
}
/*****************************************************************************/
/* Table rows, collected into TCP-sized blocks */
/* */
/* One sendContent() per row used to cost about one network round trip on */
/* the ESP32 (~20 ms each, measured before 3.4.0): the core pushes every */
/* write out as its own packet and waits for the ack, so 99 rows added up to */
/* ~1.9 s of "Loading". Filling a buffer close to the TCP segment size first */
/* turns those 99 sends into about six. (The ESP8266 core, supported until */
/* 3.15.0, coalesced writes and never showed the effect.) */
/* */
/* sendbuf is static on purpose: 1400 bytes would be a large share of the */
/* stack, and this handler is only ever entered from loop(). */
/*****************************************************************************/
#define TABLE_SENDBUF 1400 // just below the usual TCP MSS of 1460
void handleTableRefresh(WebServerClass *httpServer, char actual_data[][MAXVALUELEN])
{
// rows are built in a fixed buffer instead of concatenating one big
// String (heap fragmentation on every 30s browser refresh)
char rowbuf[256];
static char sendbuf[TABLE_SENDBUF];
size_t used = 0;
httpServer->setContentLength(CONTENT_LENGTH_UNKNOWN);
httpServer->send(200, "text/html");
// index laeuft ueber die Tabellenzeilen, angezeigt wird die TOP-Nummer der Zeile
for (unsigned int index = 0; index < NUMBEROFTOPICS; index++)
{
const StateTopic &topic = stateTopics[index];
const char *topicdesc;
if (strcmp(topic.desc[0], "value") == 0)
{
topicdesc = topic.desc[1];
}
else
{
// Bereichspruefung nach BEIDEN Seiten: -1 fuer unbekannte Rohwerte nach
// unten, das Ende der Liste nach oben. Die Regel steht in desc_text()
// (decode.h), damit der Hosttest dieselbe benutzt - siehe dort, warum
// ein zu grosser Index das Geraet zum Absturz bringen konnte.
topicdesc = desc_text(topic.desc, atoi(actual_data[index]));
}
if (strcmp(actual_data[index], "unused") != 0)
{
int written = snprintf(rowbuf, sizeof(rowbuf), "<tr><td>TOP%u</td><td>%s</td><td>%s</td><td>%s</td></tr>\n", topic.number, topic.name, actual_data[index], topicdesc);
if (written < 0)
{
continue; // formatting failed, skip this row
}
// snprintf returns what it WOULD have written: clamp to what fits
size_t rowlen = ((size_t)written >= sizeof(rowbuf)) ? sizeof(rowbuf) - 1 : (size_t)written;
// flush before the row would overflow the block (+1 for the terminator)
if (used + rowlen + 1 > sizeof(sendbuf))
{
sendbuf[used] = '\0';
httpServer->sendContent(sendbuf);
used = 0;
}
// a single row can never exceed the block, but stay defensive
if (rowlen + 1 <= sizeof(sendbuf))
{
memcpy(sendbuf + used, rowbuf, rowlen);
used += rowlen;
}
}
}
if (used > 0) // send whatever is left over
{
sendbuf[used] = '\0';
httpServer->sendContent(sendbuf);
}
httpServer->sendContent("");
httpServer->client().stop();
}
void handleReboot(WebServerClass *httpServer)
{
httpServer->setContentLength(CONTENT_LENGTH_UNKNOWN);
httpServer->send(200, "text/html");
httpServer->sendContent_P(webHeader);
httpServer->sendContent_P(refreshMeta);
httpServer->sendContent_P(webBodyStart);
String httptext = "<div class='w3-container w3-center'>";
httptext = httptext + "<p>Rebooting</p>";
httptext = httptext + "</div>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(menuJS);
httpServer->sendContent_P(webFooter);
httpServer->sendContent("");
httpServer->client().stop();
delay(1000);
ESP.restart();
}
void handleSettings(WebServerClass *httpServer, char *wifi_hostname, char *ota_password, char *mqtt_server, char *mqtt_port, char *mqtt_username, char *mqtt_password, char *hydraulik_switch, char *knx_schnittstelle)
{
httpServer->setContentLength(CONTENT_LENGTH_UNKNOWN);
httpServer->send(200, "text/html");
httpServer->sendContent_P(webHeader);
httpServer->sendContent_P(webBodyStart);
String httptext = "<div class='w3-sidebar w3-bar-block w3-card w3-animate-left' style='display:none' id='leftMenu'>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(sidebar);
httptext = "</div>";
httpServer->sendContent(httptext);
// check if POST was made with save settings, if yes then save and reboot
if (httpServer->args())
{
// Bis 3.15.0 stand hier eine feste Dokumentgroesse (zuletzt 1024 Byte, davor
// 512), und die musste bei jedem neuen Feld nachgerechnet werden. Mit
// ArduinoJson 7 waechst das Dokument elastisch - die Rechnerei entfaellt,
// die GEFAHR aber nicht: Sie heisst jetzt nicht mehr "Groesse zu klein
// geschaetzt", sondern "Allokation fehlgeschlagen". Der Ausgang waere
// derselbe: serializeJson schriebe die config.json still unvollstaendig,
// und nach dem Neustart fehlte womoeglich das MQTT-Passwort.
//
// Deshalb wird unten overflowed() geprueft und im Zweifel GAR NICHT
// geschrieben. Der alte Stand ist besser als ein halber neuer: Das Geraet
// startet nach dem Speichern neu, und ein fehlendes MQTT-Passwort faellt
// erst auf, wenn die Hausteuerung nichts mehr bekommt.
JsonDocument jsonDoc;
jsonDoc["wifi_hostname"] = wifi_hostname;
jsonDoc["ota_password"] = ota_password;
jsonDoc["mqtt_server"] = mqtt_server;
jsonDoc["mqtt_port"] = mqtt_port;
jsonDoc["mqtt_username"] = mqtt_username;
jsonDoc["mqtt_password"] = mqtt_password;
jsonDoc["hydraulik_switch"] = hydraulik_switch;
jsonDoc["knx_schnittstelle"] = knx_schnittstelle;
if (httpServer->hasArg("wifi_hostname"))
{
jsonDoc["wifi_hostname"] = httpServer->arg("wifi_hostname");
}
if (httpServer->hasArg("new_ota_password") && (httpServer->arg("new_ota_password") != NULL) && (httpServer->arg("current_ota_password") != NULL))
{
if (httpServer->hasArg("current_ota_password") && (strcmp(ota_password, httpServer->arg("current_ota_password").c_str()) == 0))
{
jsonDoc["ota_password"] = httpServer->arg("new_ota_password");
}
else
{
httptext = "<div class='w3-container w3-center'>";
httptext = httptext + "<h3>------- wrong current password -------</h3>";
httptext = httptext + "<h3>-- do factory reset if password lost --</h3>";
httptext = httptext + "</div>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(refreshMeta);
httpServer->sendContent_P(webFooter);
httpServer->sendContent("");
httpServer->client().stop();
return;
}
}
if (httpServer->hasArg("mqtt_server"))
{
jsonDoc["mqtt_server"] = httpServer->arg("mqtt_server");
}
if (httpServer->hasArg("mqtt_port"))
{
jsonDoc["mqtt_port"] = httpServer->arg("mqtt_port");
}
if (httpServer->hasArg("mqtt_username"))
{
jsonDoc["mqtt_username"] = httpServer->arg("mqtt_username");
}
// empty field means: keep current password (it is no longer prefilled in the form)
if (httpServer->hasArg("mqtt_password") && (httpServer->arg("mqtt_password").length() > 0))
{
jsonDoc["mqtt_password"] = httpServer->arg("mqtt_password");
}
// Anders als beim Passwort ist ein LEERES Feld hier eine gueltige Eingabe:
// Sie schaltet die Hydraulikumschaltung ab (der Notbetrieb bricht dann im
// ersten Schritt ab, statt sie ungeprueft zu lassen). Wer den Switch
// ausbaut, muss das Feld leeren koennen.
if (httpServer->hasArg("hydraulik_switch"))
{
jsonDoc["hydraulik_switch"] = httpServer->arg("hydraulik_switch");
}
// Die KNX-Schnittstelle (3.21.0). Leer ist erlaubt - dann endet der
// Vorderhausschritt ROT, er entfaellt nicht still (Owner 2026-09-12).
// Alles andere muss eine IP sein, wahlweise mit :Port - dieselbe Pruefung
// wie beim Druck auf den Knopf (knx_schnittstelle_lesen(), knxtunnel.h).
// Eine ungueltige Eingabe wird ABGELEHNT und nichts gespeichert: Sie fiele
// sonst erst auf, wenn jemand im Ernstfall den Knopf drueckt. Die Eingabe
// wird dabei bewusst NICHT in die Seite zurueckgeschrieben.
if (httpServer->hasArg("knx_schnittstelle"))
{
const String knx = httpServer->arg("knx_schnittstelle");
uint8_t knx_ip[4];
uint16_t knx_port = 0;
if (knx.length() > 0 && !knx_schnittstelle_lesen(knx.c_str(), knx_ip, &knx_port))
{
httptext = "<div class='w3-container w3-center'>";
httptext = httptext + "<h3>--- KNX interface: not a valid IP address ---</h3>";
httptext = httptext + "<h3>e.g. 192.168.2.127 or 192.168.2.127:3671 - nothing saved</h3>";
httptext = httptext + "</div>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(refreshMeta);
httpServer->sendContent_P(webFooter);
httpServer->sendContent("");
httpServer->client().stop();
return;
}
jsonDoc["knx_schnittstelle"] = knx;
}
// Nicht schreiben, wenn eine Allokation fehlgeschlagen ist (Begruendung
// oben beim Anlegen des Dokuments). Faellt der Code hier durch, bleibt die
// bisherige config.json stehen, es wird NICHT neu gestartet, und der
// Browser bekommt die Settings-Seite mit den alten Werten zurueck - der
// Nutzer sieht also, dass nichts uebernommen wurde.
if (!jsonDoc.overflowed() && LittleFS.begin(true)) // format on first mount
{
File configFile = LittleFS.open("/config.json", "w");
if (configFile)
{
serializeJson(jsonDoc, configFile);
configFile.close();
delay(1000);
httptext = "<div class='w3-container w3-center'>";
httptext = httptext + "<h3>--- saved ---</h3>";
httptext = httptext + "<h3>-- rebooting --</h3>";
httptext = httptext + "</div>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(refreshMeta);
httpServer->sendContent_P(webFooter);
httpServer->sendContent("");
httpServer->client().stop();
delay(1000);
ESP.restart();
}
}
}
httptext = "<div class='w3-container w3-center'>";
httptext = httptext + "<h2>Settings</h2>";
httptext = httptext + "<form action='/settings' method='POST'>";
httptext = httptext + "Hostname:<br>";
httptext = httptext + "<input type='text' name='wifi_hostname' value='" + wifi_hostname + "'>";
httptext = httptext + "<br><br>";
httptext = httptext + "Current update password:<br>";
httptext = httptext + "<input type='password' name='current_ota_password' value=''>";
httptext = httptext + "<br><br>";
httptext = httptext + "New update password:<br>";
httptext = httptext + "<input type='password' name='new_ota_password' value=''>";
httptext = httptext + "<br><br>";
httptext = httptext + "Mqtt server:<br>";
httptext = httptext + "<input type='text' name='mqtt_server' value='" + mqtt_server + "'>";
httptext = httptext + "<br><br>";
httptext = httptext + "Mqtt port:<br>";
httptext = httptext + "<input type='number' name='mqtt_port' value='" + mqtt_port + "'>";
httptext = httptext + "<br><br>";
httptext = httptext + "Mqtt username:<br>";
httptext = httptext + "<input type='text' name='mqtt_username' value='" + mqtt_username + "'>";
httptext = httptext + "<br><br>";
// never render the stored password into the page source; empty = keep current
httptext = httptext + "Mqtt password (leave empty to keep current):<br>";
httptext = httptext + "<input type='password' name='mqtt_password' value=''>";
httptext = httptext + "<br><br>";
// Der Tasmota-Switch der Hydraulik. Steht er nicht drin, laesst sich der
// Notbetrieb nicht ausloesen - das ist Absicht, siehe notbetrieb.cpp.
httptext = httptext + "Hydraulik switch (Tasmota, IP or hostname, empty = off):<br>";
httptext = httptext + "<input type='text' name='hydraulik_switch' value='" + hydraulik_switch + "'>";
httptext = httptext + "<br><br>";
// Die KNX-Schnittstelle des Vorderhausschritts (3.21.0): nur eine IP,
// wahlweise mit :Port. Die Gruppenadressen stehen fest in knxtunnel.h.
httptext = httptext + "KNX interface for Vorderhaus (IP, optional :port, empty = step fails):<br>";
httptext = httptext + "<input type='text' name='knx_schnittstelle' value='" + knx_schnittstelle + "'>";
httptext = httptext + "<br><br>";
httptext = httptext + "<input class='w3-green w3-button' type='submit' value='Save and reboot'>";
httptext = httptext + "</form>";
httptext = httptext + "</div>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(menuJS);
httpServer->sendContent_P(webFooter);
httpServer->sendContent("");
httpServer->client().stop();
}
/*****************************************************************************/
/* Notbetrieb - die Seite mit dem einen Knopf */
/* */
/* Die Bedienung ist fuer Laien: "Notbetrieb einschalten" statt "HeatingMode */
/* auf 0". Wer hier steht, hat einen Ausfall der Kaskadensteuerung vor sich */
/* und braucht keine Protokollnamen. */
/* */
/* Der Knopf ist ein POST-Formular, kein Link: ein Klick, keine Rueckfrage - */
/* aber auch kein versehentliches Ausloesen durch Link-Vorschau, Virenscanner */
/* oder einen Browser, der die Seite aus der History vorlaedt. */
/*****************************************************************************/
/*****************************************************************************/
/* Die Saetze der Seite stehen EINMAL hier */
/* */
/* Sie werden an zwei Stellen gebraucht: beim Aufbau der Seite (C++) und beim */
/* Nachfuehren alle zwei Sekunden (JavaScript). Als Makro fuegt der Praepro- */
/* zessor denselben Text in beide Literale ein - zwei Fassungen desselben */
/* Satzes koennten sonst auseinanderlaufen, und ausgerechnet die Notfallseite */
/* wuerde je nach Zeitpunkt etwas anderes sagen. */
/* */
/* Mit Umlauten seit 3.13.0 (webHeader traegt jetzt ein charset). Diese Seite */
/* liest im Ernstfall jemand aus der Familie, nicht der Entwickler - "Der */
/* Notbetrieb ist nur im Modus Heizen moeglich" ist zwar verstaendlich, aber */
/* eine Notfallseite sollte nicht aussehen, als sei sie halbfertig. Die */
/* uebrigen Seiten (Home, Settings, Firmware) bleiben unveraendert. */
/*****************************************************************************/
#define NB_TXT_NUR_HEIZEN "Der Notbetrieb ist nur im Modus Heizen möglich"
#define NB_TXT_HEIZEN_HINWEIS "Bitte den Heiz-/Kühlschalter im Haus auf Heizen stellen. Der Knopf gibt sich danach von selbst frei, das dauert bis zu 10 Sekunden."
#define NB_TXT_WERTE_FEHLEN "Der Steuerung fehlen Werte, die sie zum Umschalten braucht. Das passiert, wenn das Gerät neu gestartet ist, während der Server im Keller aus war."
#define NB_TXT_PLAN_B "Bitte nach der ausgedruckten Anleitung am Bedienfeld der Wärmepumpe weitermachen."
/*****************************************************************************/
/* Die Meldung, wenn die Hydraulik nicht umschaltet (3.15.0) */
/* */
/* Sie tritt an die Stelle des generischen "Hat nicht geklappt" - und zwar */
/* nur bei diesem einen Abbruchgrund. Der Wortlaut ist vom Owner vorgegeben */
/* (2026-08-26): Wer vor der Seite steht, soll wissen, WO der Schalter haengt */
/* und was zu tun ist. Ein Verweis auf die Anleitung am Bedienfeld waere hier */
/* falsch - die Wärmepumpe ist gar nicht angefasst worden. */
/* */
/* Der zweite Satz ist der wichtigere Teil des Wegs zurueck: Nach ROT steht */
/* der Knopf sofort wieder da (die Seite blendet ihn nur bei "laeuft" und */
/* GRUEN aus). Wer den Schalter von Hand legt und wiederkommt, drueckt */
/* erneut - der Lesevorgang meldet dann OFF, und die Folge laeuft durch. */
/*****************************************************************************/
#define NB_TXT_HYDRAULIK "Die Umschaltung der Hydraulik ist fehlgeschlagen, bitte den Switch im Waschraum von Hand auf AUS schalten"
#define NB_TXT_HYDRAULIK_DANACH "Danach diesen Knopf noch einmal drücken. An der Wärmepumpe ist nichts verstellt worden."
/*****************************************************************************/
/* Die Meldungen des Vorderhausschritts (3.21.0) */
/* */
/* Beide Saetze sind vom Owner vorgegeben: der erste am 2026-09-12, der */
/* Zusatz am 2026-09-13 (E3). Sie stehen in einem ORANGEN Hinweisfeld unter */
/* dem normalen GRUEN (Owner-Entscheid 2026-09-13): Die Waermepumpen sind im */
/* Notbetrieb, das ist gruen - nur das Vorderhaus braucht Aufmerksamkeit, */
/* und Orange heisst auf dieser Seite schon "hier ist etwas zu tun" (Sperre, */
/* Verbindung). Intern ist der Lauf ROT mit eigenem Grund: So kommt der */
/* Knopf zurueck, zu dem der Zusatz einlaedt. */
/* */
/* Die "Anleitungen zum Mischer" in den Notbetriebsunterlagen schreibt der */
/* Owner nach dem Rollout (Entscheid 2026-09-13) - bis dahin verweist der */
/* Satz ins Leere. Arbeitsplan-KNX-Vorderhaus.md. */
/* */
/* Der dritte Satz gilt waehrend des Rueckfalls der Regel A: Der Mischer */
/* fuhr beim Druck schon, und die Endstellung kann bis zu vier Minuten */
/* dauern. Ohne ihn stuende dort "bis zu anderthalb Minuten", und wer */
/* laenger wartet als angekuendigt, glaubt an einen Fehler. */
/*****************************************************************************/
#define NB_TXT_VORDERHAUS "Die Wärmepumpen laufen im Notbetrieb, nur das Vorderhaus ließ sich nicht umstellen."
#define NB_TXT_VORDERHAUS_DANACH "Der Mischer im Vorderhaus bleibt so eingestellt, wie die Steuerung es zuletzt vorgegeben hat. Ein zweiter Druck auf diesen Knopf kann eventuell die Einstellungen vornehmen. Wird es im Vorderhaus zu kalt oder zu warm, lies bitte die Anleitungen zum Mischer in den Unterlagen zum Notbetrieb."
#define NB_TXT_VORDERHAUS_FAEHRT "Die Wärmepumpen laufen bereits im Notbetrieb. Der Mischer im Vorderhaus fährt noch – das dauert bis zu vier Minuten."
/*****************************************************************************/
/* Der Hinweis, wenn das Vorderhaus entfallen ist (3.22.0) */
/* */
/* Seit 3.22.0 steht der Schritt in beiden Folgen, faellig aber nur bei */
/* Heizbetrieb (notbetrieb_vorderhaus_faellig()). Druckt jemand im Sommer */
/* den Knopf an Stufe 2, bleibt das Vorderhaus bewusst unberuehrt - dann */
/* steht dieser Satz unter dem GRUEN. Blassgelb wie die Kurvenwarnung: eine */
/* Auskunft, kein Handlungsbedarf. Orange bleibt dem Fehlschlag vorbehalten. */
/*****************************************************************************/
#define NB_TXT_VORDERHAUS_ENTFALLEN "Die Anlage steht nicht auf Heizen, deshalb hat der Knopf das Vorderhaus nicht umgestellt. Mischer und Pumpe im Vorderhaus bleiben so eingestellt, wie die Steuerung es zuletzt vorgegeben hat."
/*****************************************************************************/
/* Die Kurvenwarnung - ein Hinweis, keine Sperre */
/* */
/* Die Regel steht in notbetrieb.h: Eine Heizkurve faellt mit steigender */
/* Aussentemperatur. Sind "VL kalt" und "VL warm" vertauscht, liegen die */
/* Werte trotzdem alle im erlaubten Bereich - kein Bereichstest schlaegt an. */
/* Deshalb dieser Hinweis, und deshalb NUR ein Hinweis: Der Knopf bleibt */
/* bedienbar, weil ein Notbetrieb mit verdrehter Kurve immer noch besser ist */
/* als keiner. Die Sperrfarbe (orange) bleibt der echten Sperre vorbehalten. */
/*****************************************************************************/
#define NB_TXT_KURVE_VORLAUF "Die Heizkurve sieht vertauscht aus: Der Vorlauf bei Kälte müsste höher sein als der bei Wärme. Der Knopf funktioniert trotzdem - bitte die vier Werte in der Hausteuerung nachsehen."
#define NB_TXT_KURVE_AUSSEN "Die beiden Außentemperaturen der Heizkurve passen nicht zusammen: Die kalte müsste unter der warmen liegen. Der Knopf funktioniert trotzdem - bitte die vier Werte in der Hausteuerung nachsehen."
// Alle zwei Sekunden den Kurzstatus holen und daraus Klartext machen. Die
// Antwort ist "Zustand;Schritt;Schritte;fehlendMaske;Sperre" plus die vier
// hinten angehaengten Felder (Lage, Dauertext, Kurvenwarnung, Abbruchgrund) -
// so kurz wie moeglich, weil das Geraet nebenher die Waermepumpe abfragt.
//
// Die Seite fuehrt Knopf UND Sperrhinweis nach, nicht nur das Ergebnis: Steht
// die Anlage auf Kuehlen und jemand legt den KNX-Schalter um, gibt sich der
// Knopf von selbst frei. Ein Neuladen von Hand waere hier eine Falle - TOP101
// folgt dem Schalter erst nach bis zu 7,7 s (gemessen 2026-08-16), wer sofort
// neu laedt, saehe die Sperre ein zweites Mal.
static const char notbetriebJS[] PROGMEM =
"<script>"
// Der GRUEN-Text steht einmal: Er gilt beim Erfolg UND ueber dem Hinweis
// zum Vorderhaus (3.21.0) - dort sind die Waermepumpen ebenso umgestellt.
// Der Wortlaut ist vom Familienrat vorgegeben (2026-08-29), siehe unten.
"var nbGruen='<h3>GRÜN</h3><p>Der Notbetrieb ist aktiviert.</p><p>Die Temperatur lässt sich am Display im Waschraum in kleinen Schritten einstellen.</p><p>Sobald die Steuerung wieder aktiv ist, kehrt die Wärmepumpe in den Normalbetrieb zurück.</p>';"
"function nbFehlt(m){var l='';for(var i=0;i<nbNamen.length;i++){if(m&(1<<i))l+='<li>'+nbNamen[i]+'</li>';}return l;}"
"function nbSperrtext(sp,m){"
"if(sp==2)return '<h3>" NB_TXT_NUR_HEIZEN "</h3><p>" NB_TXT_HEIZEN_HINWEIS "</p>';"
"return '<h3>Nicht bereit</h3><p>" NB_TXT_WERTE_FEHLEN "</p><p>Fehlt:</p><ul>'+nbFehlt(m)+'</ul><p>" NB_TXT_PLAN_B "</p>';}"
"function nbState(){fetch('/notbetrieb/status').then(r=>r.text()).then(t=>{"
"var p=t.trim().split(';');var z=parseInt(p[0]);var s=parseInt(p[1]);var n=parseInt(p[2]);"
"var m=parseInt(p[3]);var sp=parseInt(p[4]);"
// Index 7: Plausibilitaet der Kurve (0 = ok, 1 = Vorlauf, 2 = Aussenpunkte).
// Index 8: Warum der letzte Lauf abgebrochen wurde (3 = Hydraulik).
//
// Beide haengen HINTEN an, und das ist keine Kosmetik: Die Indizes 5 und 6
// (Lage und Dauer der Verbindung) liest die STARTSEITE ueber dieselbe
// Route (verbindungJS). Ein Einschub in der Mitte haette sie verschoben.
"var kw=parseInt(p[7]);"
"var ag=parseInt(p[8]);"
// Index 9 (3.21.0): 1 = der Vorderhausschritt wartet im Rueckfall auf die
// Endstellung des Mischers. Fehlt das Feld, ist parseInt NaN, also nicht 1.
"var vh=parseInt(p[9]);"
// Index 10 (3.22.0): 1 = GRUEN, das Vorderhaus ist mangels Heizbetrieb
// bewusst entfallen. Fehlt das Feld, ist parseInt NaN, also nicht 1.
"var ve=parseInt(p[10]);"
// Felder 5 und 6: Lage der Verbindung zur Hausteuerung und die Dauer als
// fertiger Text. true = auch "verbunden" anzeigen, siehe verbindungJS.
"vbSetzen(parseInt(p[5]),p[6],true);"
"var e=document.getElementById('nbstat');var f=document.getElementById('nbform');"
"var g=document.getElementById('nbsperre');var k=document.getElementById('nbwarn');"
// "bis zu anderthalb Minuten" deckt beide Rollen ab: Der Heizen-Lauf
// braucht seit 3.21.0 88 s (elf Schritte), der Warmwasser-Lauf seit 3.22.0
// 56 s (sieben). Die
// Angabe steht bewusst ueber der laengeren der beiden - wer laenger wartet
// als angekuendigt, glaubt an einen Fehler, wo keiner ist. Aus demselben
// Grund steht im Rueckfall des Vorderhausschritts (vh) der eigene Satz:
// Dort koennen es bis zu vier Minuten werden.
"if(z==1){e.className='w3-panel w3-yellow';e.innerHTML='<h3>Konfiguration Notbetrieb läuft</h3><p>Schritt '+s+' von '+n+'. '"
"+(vh==1?'" NB_TXT_VORDERHAUS_FAEHRT "':'Bitte warten, das dauert bis zu anderthalb Minuten.')+'</p>';}"
// Der Wortlaut bei GRUEN ist vom Familienrat vorgegeben (2026-08-29).
// Der frueher hier stehende KNX-Hinweis ist bewusst raus: Wer im Notbetrieb
// vor der Seite steht, soll nur zwei Dinge wissen - wo die Temperatur
// nachzustellen ist und dass die Anlage von selbst zurueckkehrt.
//
// Der Hinweis hat auch seinen Anlass verloren: Seit dem 2026-08-29 steht
// die Kompressorfreigabe dauerhaft an (Owner-Entscheid, KNX-Kanal bleibt
// und wird nur noch fuer Wartung geoeffnet). "GRUEN, aber 0 Hz mangels
// Freigabe" ist damit der Wartungsfall und kein Regelfall mehr - er steht
// in Ablauf-Notbetrieb.md und Analyse-Relais-statt-KNX.md Abschnitt 13.
// Ist das Vorderhaus entfallen (ve, 3.22.0), steht unter dem GRUEN der
// Hinweis in Blassgelb - wie beim Vorderhaus-Fehlschlag traegt das
// aeussere Feld dann keine eigene Farbe.
"else if(z==2&&ve==1){e.className='';e.innerHTML='<div class=\"w3-panel w3-green\">'+nbGruen+'</div>"
"<div class=\"w3-panel w3-pale-yellow\"><h3>Vorderhaus unverändert</h3><p>" NB_TXT_VORDERHAUS_ENTFALLEN "</p></div>';}"
"else if(z==2){e.className='w3-panel w3-green';e.innerHTML=nbGruen;}"
// Das Vorderhaus liess sich nicht umstellen (Grund 4): Die Waermepumpen
// laufen, also das normale GRUEN - darunter der Hinweis in Orange. Das
// aeussere Feld traegt dann keine eigene Farbe, die beiden inneren schon.
"else if(z==3&&ag==4){e.className='';e.innerHTML='<div class=\"w3-panel w3-green\">'+nbGruen+'</div>"
"<div class=\"w3-panel w3-orange\"><h3>Vorderhaus nicht umgestellt</h3><p>" NB_TXT_VORDERHAUS "</p><p>" NB_TXT_VORDERHAUS_DANACH "</p></div>';}"
// Bei ROT entscheidet der Abbruchgrund, was zu tun ist: Bleibt die
// Hydraulik auf 2-stufig, fuehrt der Weg ueber den Schalter im Waschraum
// und NICHT ueber das Bedienfeld der Waermepumpe - dort ist nichts
// verstellt worden, weil der Schritt ganz vorn steht.
"else if(z==3){e.className='w3-panel w3-red';e.innerHTML=(ag==3)"
"?'<h3>ROT</h3><p>" NB_TXT_HYDRAULIK "</p><p>" NB_TXT_HYDRAULIK_DANACH "</p>'"
":'<h3>ROT</h3><p>Hat nicht geklappt. " NB_TXT_PLAN_B "</p>';}"
"else{e.className='';e.innerHTML='';}"
// Waehrend ein Lauf unterwegs ist und nach GRUEN steht weder Knopf noch
// Sperrhinweis - dort gibt es nichts zu entscheiden. Nach ROT kommt beides
// zurueck: War die Betriebsart der Grund, steht der Grund jetzt darunter.
"var zeigen=(z!=1&&z!=2);"
"if(!zeigen){f.style.display='none';g.style.display='none';}"
"else if(sp==0){g.style.display='none';f.style.display='block';}"
"else{f.style.display='none';g.style.display='block';g.innerHTML=nbSperrtext(sp,m);}"
// Der Hinweis haengt nicht an der Sperre: Er gilt auch dann, wenn der Knopf
// frei ist - das ist sogar sein wichtigster Fall.
"if(zeigen&&kw>0){k.style.display='block';"
"k.innerHTML='<h3>Kurve prüfen</h3><p>'+(kw==1?'" NB_TXT_KURVE_VORLAUF "':'" NB_TXT_KURVE_AUSSEN "')+'</p>';}"
"else{k.style.display='none';}"
"}).catch(()=>{}).finally(()=>{setTimeout(nbState,2000)})}"
"document.addEventListener('DOMContentLoaded',nbState);"
"</script>";
void handleNotbetrieb(WebServerClass *httpServer)
{
httpServer->setContentLength(CONTENT_LENGTH_UNKNOWN);
httpServer->send(200, "text/html");
httpServer->sendContent_P(webHeader);
httpServer->sendContent_P(webBodyStart);
httpServer->sendContent_P(menuJS);
// Die Namen der gehaltenen Werte fuer das Nachfuehren: Die Statusroute
// schickt nur eine Bitmaske, damit sie kurz bleibt. Ohne diese Liste koennte
// die Seite den fehlenden Wert nicht beim Namen nennen.
String httptext = "<script>var nbNamen=[";
const unsigned wertanzahl = notbetrieb_wert_anzahl(notbetriebRolle);
for (unsigned i = 0; i < wertanzahl; i++)
{
if (i > 0)
httptext += ",";
httptext += "'";
httptext += notbetrieb_wert_name(notbetriebRolle, i);
httptext += "'";
}
httptext += "];</script>";
httpServer->sendContent(httptext);
// verbindungJS VOR notbetriebJS: nbState() ruft vbSetzen() auf. Die
// Reihenfolge ist hier egal, weil beides Funktionsdeklarationen sind und
// erst nach DOMContentLoaded laeuft - sie steht trotzdem so da, damit beim
// Lesen keine Frage offen bleibt.
httpServer->sendContent_P(verbindungJS);
httpServer->sendContent_P(notbetriebJS);
httpServer->sendContent("</head><body>");
httptext = "<div class='w3-sidebar w3-bar-block w3-card w3-animate-left' style='display:none' id='leftMenu'>";
httpServer->sendContent(httptext);
httpServer->sendContent_P(sidebar);
httpServer->sendContent("<hr></div>");
const bool wasser = (notbetriebRolle == NOTBETRIEB_WASSER);
const NotbetriebSperre sperre = notbetrieb_sperre();
httptext = "<div class='w3-container'>";
httptext += wasser ? "<h2>Notbetrieb: Warmwasser</h2>" : "<h2>Notbetrieb: Heizen</h2>";
httptext += wasser
? "<p>Dieser Knopf stellt die Wärmepumpe auf reinen Warmwasserbetrieb "
"und schaltet sie ein. Sie arbeitet danach ohne die Hausteuerung weiter.</p>"
: "<p>Dieser Knopf stellt die Wärmepumpe auf ihre eigene Heizkurve "
"und schaltet sie ein. Sie heizt danach ohne die Hausteuerung weiter.</p>";
// Die Verbindungszeile steht GANZ OBEN, vor dem Knopf: Sie ist die Auskunft,
// aus der sich die Entscheidung ergibt, ob der Knopf ueberhaupt gebraucht
// wird. Auf dieser Seite steht sie auch im Normalfall da (zeigeOk = true) -
// ein ruhiges "verbunden" verhindert die haeufigere Fehlentscheidung.
verbindungszeile(httptext, true);
httpServer->sendContent(httptext);