-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWebConfigServer.cpp
More file actions
988 lines (901 loc) · 43.8 KB
/
Copy pathWebConfigServer.cpp
File metadata and controls
988 lines (901 loc) · 43.8 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
#include "WebConfigServer.h"
#include <ArduinoJson.h>
#include <ESPmDNS.h>
#include <LittleFS.h>
#include <WebServer.h>
#include <WiFi.h>
#include <cmath>
#include <cstring>
#include "BleScanner.h"
#include "CameraCapture.h"
#include "Config.h"
#include "DeviceCsv.h"
#include "DeviceStore.h"
#include "SettingsStore.h"
#include "TimeSync.h"
namespace {
WebServer g_server(80);
bool g_active = false;
// 今回どちらの方式でつながっているか。
// Sta ... 家庭のWi-Fiに参加する(推奨。理由はstart()を参照)
// Ap ... 本機自身がアクセスポイントになる
enum class Mode { Ap, Sta };
Mode g_mode = Mode::Ap;
uint32_t g_requestCount = 0;
uint32_t g_lastHeartbeatMillis = 0;
// ---- 動作状況の出力 ----
//
// Web設定は「Wi-Fiにつながるか」「相手まで届くか」「ページを返せるか」の
// 3段階からできていて、どこで失敗しても本体の画面からは同じ「ページが
// 開かない」に見えます。どの段階で止まっているかを、パソコンのシリアル
// モニタで見分けるための出力です。
#define LOG(fmt, ...) Serial.printf("[WebCfg] " fmt "\n", ##__VA_ARGS__)
#define LOGW(fmt, ...) Serial.printf("[WebCfg] WARN: " fmt "\n", ##__VA_ARGS__)
const char *wlStatusName(wl_status_t s) {
switch (s) {
case WL_NO_SHIELD: return "NO_SHIELD";
case WL_IDLE_STATUS: return "IDLE";
case WL_NO_SSID_AVAIL: return "NO_SSID_AVAIL (SSID not found)";
case WL_SCAN_COMPLETED: return "SCAN_COMPLETED";
case WL_CONNECTED: return "CONNECTED";
case WL_CONNECT_FAILED: return "CONNECT_FAILED (wrong password?)";
case WL_CONNECTION_LOST: return "CONNECTION_LOST";
case WL_DISCONNECTED: return "DISCONNECTED";
default: return "UNKNOWN";
}
}
// 接続先の情報を出力します。
//
// とくに重要なのがネットマスクとゲートウェイです。スマートフォンと本機が
// 別々のネットワークに入ってしまう(ゲスト用SSID、メッシュ中継機、
// 分離設定のルータなど)のがいちばん多い失敗で、どちらも「つながっている」
// ように見えるのに互いに届きません。
void logNetworkDetails() {
LOG(" IP : %s", WiFi.localIP().toString().c_str());
LOG(" Netmask : %s", WiFi.subnetMask().toString().c_str());
LOG(" Gateway : %s", WiFi.gatewayIP().toString().c_str());
LOG(" DNS : %s", WiFi.dnsIP().toString().c_str());
LOG(" MAC : %s", WiFi.macAddress().c_str());
LOG(" BSSID : %s ch=%d RSSI=%d dBm", WiFi.BSSIDstr().c_str(), WiFi.channel(), WiFi.RSSI());
LOG(" -> the phone's IP must be on this same subnet for the page to load");
}
void logRequest(const char *what) {
g_requestCount++;
LOG("HTTP request #%lu from %s : %s", (unsigned long)g_requestCount,
g_server.client().remoteIP().toString().c_str(), what);
}
// ---- JSONを組み立てるための小さな補助 ----
void putFloatOrNull(JsonObject o, const char *key, float v) {
if (isnan(v)) o[key] = nullptr;
else o[key] = v;
}
std::vector<String> jsonStringArray(JsonArrayConst arr) {
std::vector<String> out;
for (JsonVariantConst v : arr) out.push_back(String(v.as<const char *>()));
return out;
}
// ---- ブラウザからの要求に応える処理 ----
void handleApiScan() {
logRequest("GET /api/scan");
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (const auto &kv : BleScanner::allReadings()) {
const SensorReading &r = kv.second;
JsonObject o = arr.add<JsonObject>();
o["mac"] = kv.first;
o["rssi"] = r.rssi;
o["model"] = String(r.modelChar);
o["modelLabel"] = BleScanner::modelLabelFor(r.modelChar);
putFloatOrNull(o, "temperature", r.temperature);
// タイルと同じく整数に丸めて渡します。小数第1位は確度の外側です。
o["humidity"] = lroundf(r.humidity);
o["battery"] = r.battery;
o["hasCo2"] = r.hasCo2;
if (r.hasCo2) o["co2"] = r.co2;
o["hasIaq"] = r.hasIaq;
if (r.hasIaq) {
o["iaq"] = r.iaq;
// どちらの空気質を送ってきているかは、送信側の設定で決まります。
// 値の意味が違うので、検出一覧でも見分けられるようにしています。
o["iaqStatic"] = r.iaqIsStatic;
}
o["online"] = r.online;
o["registered"] = DeviceStore::isRegistered(kv.first);
}
String body;
serializeJson(doc, body);
g_server.send(200, "application/json", body);
}
void handleApiDevicesGet() {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (const auto &d : DeviceStore::devices()) {
JsonObject o = arr.add<JsonObject>();
o["mac"] = d.mac;
o["name"] = d.name;
o["model"] = d.model;
JsonArray tags = o["tags"].to<JsonArray>();
for (const auto &t : d.tags) tags.add(t);
}
String body;
serializeJson(doc, body);
g_server.send(200, "application/json", body);
}
void handleApiTags() {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (const auto &t : DeviceStore::allTags()) arr.add(t);
String body;
serializeJson(doc, body);
g_server.send(200, "application/json", body);
}
void handleApiSettingsGet() {
JsonDocument doc;
doc["wifiSsid"] = SettingsStore::wifiSsid();
doc["hasWifiCredentials"] = SettingsStore::hasWifiCredentials();
doc["sdFlushIntervalMinutes"] = SettingsStore::sdFlushIntervalMinutes();
// BLEの受信を続けられるのは、家庭のWi-Fiに参加している場合だけです。
// 本機がアクセスポイントになっている場合は受信を止めているので、
// 「検出」の一覧は止まった状態のものです。それをページ側に伝えます。
doc["bleLive"] = (g_active && g_mode == Mode::Sta);
JsonArray opts = doc["sdFlushOptions"].to<JsonArray>();
const int kOptions[] = SD_FLUSH_INTERVAL_MIN_OPTIONS;
for (int v : kOptions) opts.add(v);
String body;
serializeJson(doc, body);
g_server.send(200, "application/json", body);
}
bool parseBody(JsonDocument &doc) {
String body = g_server.arg("plain");
DeserializationError err = deserializeJson(doc, body);
return !err;
}
void sendOk(bool ok) {
g_server.send(ok ? 200 : 400, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
}
void handleApiDeviceAdd() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
String mac = doc["mac"].as<const char *>();
String name = doc["name"].as<const char *>();
String model = doc["model"] | "";
bool ok = DeviceStore::addDevice(mac, name, model);
if (ok && doc["tags"].is<JsonArray>()) {
DeviceStore::setTags(mac, jsonStringArray(doc["tags"].as<JsonArray>()));
}
sendOk(ok);
}
void handleApiDeviceRename() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
sendOk(DeviceStore::renameDevice(doc["mac"].as<const char *>(), doc["name"].as<const char *>()));
}
void handleApiDeviceTags() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
sendOk(DeviceStore::setTags(doc["mac"].as<const char *>(), jsonStringArray(doc["tags"].as<JsonArray>())));
}
void handleApiDeviceDelete() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
sendOk(DeviceStore::removeDevice(doc["mac"].as<const char *>()));
}
void handleApiDeviceReorder() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
sendOk(DeviceStore::reorder(jsonStringArray(doc["order"].as<JsonArray>())));
}
void handleApiSettingsWifi() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
SettingsStore::setWifiCredentials(doc["ssid"].as<const char *>(), doc["password"].as<const char *>());
sendOk(true);
}
// 一覧をCSVファイルとして送ります。
// ブラウザ側にはファイルとして保存されます。本体側のボタンからの書き出しと
// 違って、microSDカードが無くても使えます。
void handleApiDevicesCsvGet() {
logRequest("GET /api/devices/csv (download)");
String csv = DeviceCsv::buildCsv();
g_server.sendHeader("Content-Disposition", "attachment; filename=\"switchbot_devices.csv\"");
g_server.send(200, "text/csv; charset=utf-8", csv);
LOG(" sent CSV, %u bytes", unsigned(csv.length()));
}
#if DIAG_CAMERA_PROBE
// カメラで取り込んだ絵を、画像ファイルとして送ります。
//
// 明るさの数値だけでは、それが妥当なのか判断できません。実際の絵を見れば、
// 露出が合っているか、そもそも像を結んでいるかが分かります。
//
// 形式はBMPの24ビットカラーです。Windowsでそのまま開けます。
//
// 受け取っている絵はRGB565(1画素2バイト)なので、1画素ずつ赤・緑・青の
// 3バイトに広げて送ります。BMPは青・緑・赤の順に並べる決まりです。
void handleApiCameraBmp() {
logRequest("GET /api/camera.bmp");
if (!CameraCapture::hasFrame()) {
g_server.send(404, "text/plain", "no frame");
return;
}
const int w = CameraCapture::frameWidth();
const int h = CameraCapture::frameHeight();
const uint32_t headerBytes = 14 + 40;
const uint32_t rowBytes = uint32_t(w) * 3; // 幅が4の倍数なので余白は不要
const uint32_t pixelBytes = rowBytes * h;
const uint32_t fileBytes = headerBytes + pixelBytes;
uint8_t header[14 + 40];
memset(header, 0, sizeof(header));
header[0] = 'B';
header[1] = 'M';
memcpy(&header[2], &fileBytes, 4);
memcpy(&header[10], &headerBytes, 4);
uint32_t infoSize = 40;
memcpy(&header[14], &infoSize, 4);
int32_t width = w;
int32_t height = h; // 正の値はBMPでは「下の行から並ぶ」という意味です
memcpy(&header[18], &width, 4);
memcpy(&header[22], &height, 4);
uint16_t planes = 1, bits = 24;
memcpy(&header[26], &planes, 2);
memcpy(&header[28], &bits, 2);
memcpy(&header[34], &pixelBytes, 4);
g_server.sendHeader("Content-Disposition", "attachment; filename=\"camera.bmp\"");
g_server.setContentLength(fileBytes);
g_server.send(200, "image/bmp", "");
g_server.sendContent((const char *)header, sizeof(header));
// BMPは下の行から並べる決まりなので、逆順に送ります。
// 1行ずつ送るのは、2MBを丸ごと積み直す余裕が無いためです。
const uint8_t *frame = CameraCapture::frameData();
const size_t frameLen = CameraCapture::frameLength();
static uint8_t row[CAMERA_WIDTH * 3];
for (int y = h - 1; y >= 0; y--) {
size_t src = size_t(y) * w * 2;
for (int x = 0; x < w; x++) {
// 届いていない行は黒で埋めます。無いものを絵に見せないためです。
uint16_t px = 0;
if (src + 1 < frameLen) px = uint16_t(frame[src]) | (uint16_t(frame[src + 1]) << 8);
src += 2;
// 5ビット・6ビットを8ビットに広げます。上位ビットを下位にも複製すると、
// 最大値がきちんと255になります。
uint8_t r = uint8_t(((px >> 11) & 0x1F) * 255 / 31);
uint8_t g = uint8_t(((px >> 5) & 0x3F) * 255 / 63);
uint8_t b = uint8_t((px & 0x1F) * 255 / 31);
row[x * 3 + 0] = b;
row[x * 3 + 1] = g;
row[x * 3 + 2] = r;
}
g_server.sendContent((const char *)row, rowBytes);
}
LOG(" sent BMP, %u bytes", unsigned(fileBytes));
}
#endif
void handleApiDevicesCsvPost() {
logRequest("POST /api/devices/csv (upload)");
String csv = g_server.arg("plain");
LOG(" received %u bytes of CSV", unsigned(csv.length()));
DeviceCsv::Result res = DeviceCsv::applyCsv(csv);
JsonDocument doc;
doc["ok"] = res.ok;
doc["added"] = res.written;
doc["updated"] = res.updated;
doc["skipped"] = res.skipped;
doc["message"] = res.message;
String body;
serializeJson(doc, body);
g_server.send(200, "application/json", body);
LOG(" import result: %s", res.message.c_str());
}
void handleApiSettingsSdInterval() {
JsonDocument doc;
if (!parseBody(doc)) return sendOk(false);
SettingsStore::setSdFlushIntervalMinutes(doc["minutes"].as<int>());
sendOk(true);
}
// 設定を初期化します。
//
// 消すのは本体に保存した設定と機器の登録内容だけで、microSDカードの履歴には
// 触れません。履歴を消すかどうかは利用者が別に決めることだからです。
// 直後に再起動するのは、各機能がメモリ上に持っている内容も、空になった
// 保存内容から作り直させるためです。
void handleApiFactoryReset() {
SettingsStore::clearAll();
if (LittleFS.exists(DEVICES_JSON_PATH)) LittleFS.remove(DEVICES_JSON_PATH);
g_server.send(200, "application/json", "{\"ok\":true}");
g_server.client().flush();
delay(200);
ESP.restart();
}
// 用意していない場所への要求に応えます。
//
// スマートフォンが新しいネットワークに入ったときに自動で送る確認用の要求も
// ここに来ます。それがログに出ること自体が有用です。目的のページが開けなくても、
// 通信そのものは本機まで届いていると分かるからです。
void handleNotFound() {
logRequest((String("404 ") + g_server.uri()).c_str());
g_server.send(404, "text/plain", "not found");
}
const char kIndexHtml[] PROGMEM = R"HTMLPAGE(<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tab5 SwitchBot 設定</title>
<style>
:root {
--bg:#0b0f0e; --tile:#161d1b; --border:#232b28; --ink:#eef3f0; --muted:#8a9a94;
--accent:#45c7ba; --danger:#e2837a; --warn:#e3ad55;
/* Secondary button fill -- clearly lighter than the page so the button reads
as a button, without competing with the accent-coloured primary ones. */
--button:#44524d;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
header { padding:16px; border-bottom:1px solid var(--border); }
header h1 { margin:0; font-size:18px; }
nav { display:flex; gap:4px; padding:8px 12px; border-bottom:1px solid var(--border); overflow-x:auto; }
nav button { flex:0 0 auto; padding:8px 14px; border-radius:20px; border:1px solid var(--border); background:var(--tile); color:var(--muted); font-size:13px; }
nav button.active { background:var(--accent); border-color:var(--accent); color:var(--bg); font-weight:bold; }
nav button:hover:not(.active) { border-color:var(--accent); color:var(--ink); }
main { padding:14px; max-width:640px; margin:0 auto; }
.view { display:none; }
.view.active { display:block; }
.card { background:var(--tile); border:1px solid var(--border); border-radius:12px; padding:12px; margin-bottom:10px; }
.card-row { display:flex; align-items:center; gap:10px; }
.card-row .grow { flex:1; min-width:0; }
.mac { font-size:11px; color:var(--muted); font-family:monospace; }
.badge { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--border); color:var(--muted); }
.badge.on { color:var(--accent); border:1px solid var(--accent); background:transparent; }
input[type=text], input[type=password], input[type=number], select {
width:100%; padding:8px 10px; border-radius:8px; border:1px solid var(--border);
background:var(--bg); color:var(--ink); font-size:14px; margin-top:4px;
}
label { font-size:12px; color:var(--muted); display:block; margin-top:8px; }
button.btn { padding:8px 14px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:var(--bg); font-weight:bold; font-size:13px; }
/* Filled, not transparent: a transparent button over the dark page background
just reads as a black rectangle, which is hard to see as a button at all. */
button.btn.secondary { background:var(--button); color:var(--ink); border-color:var(--button); }
button.btn.danger { background:var(--danger); border-color:var(--danger); color:var(--bg); }
/* Feedback, which every button here was missing: nothing changed on hover, the
pointer stayed an arrow, and a tap gave no sign it had landed. Applies to
the nav tabs as well as .btn -- they are buttons too.
- cursor tells a mouse user this is clickable before they try it
- :hover lifts the fill slightly (desktop only; a phone never matches it)
- :active presses the button down a touch, which is the part that matters on
a touch screen, where there is no hover to rely on
- the transition makes both read as movement rather than a state swap, the
same 120ms the on-device buttons use */
button { cursor:pointer; transition:filter 120ms linear, transform 120ms linear, border-color 120ms linear, color 120ms linear; }
button:disabled { cursor:not-allowed; opacity:0.5; }
button:hover:not(:disabled) { filter:brightness(1.15); }
button:active:not(:disabled) { transform:scale(0.96); filter:brightness(0.9); }
/* Chrome on Android paints its own grey flash over a tapped control, which
fights the :active style above. */
button { -webkit-tap-highlight-color:transparent; }
/* Keyboard focus needs to be visible too, and the browser default outline is
nearly invisible against this palette. */
button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.small { font-size:12px; color:var(--muted); }
.tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
.tag-chip { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--bg); border:1px solid var(--border); color:var(--muted); }
.reg-row { padding:10px 0; border-bottom:1px solid var(--border); }
.reg-row:last-child { border-bottom:none; }
.drag-item { display:flex; align-items:center; gap:10px; padding:10px; margin-bottom:6px; background:var(--bg); border:1px solid var(--border); border-radius:8px; cursor:grab; }
.drag-item:active { cursor:grabbing; }
.drag-item.dragging { opacity:0.4; }
.drag-handle { color:var(--muted); }
.preview-grid { display:flex; flex-wrap:wrap; gap:6px; padding:10px; background:var(--bg); border-radius:8px; margin-top:10px; }
.preview-tile { flex:1 0 60px; max-width:90px; aspect-ratio:1; background:var(--tile); border:1px solid var(--border); border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:10px; text-align:center; padding:4px; }
.danger-zone { border-color:var(--danger); }
.msg { font-size:12px; margin-top:8px; min-height:16px; }
.msg.ok { color:var(--accent); }
.msg.err { color:var(--danger); }
</style>
</head>
<body>
<header><h1>Tab5 SwitchBot 設定</h1></header>
<nav>
<button data-view="scan" class="active">検出</button>
<button data-view="registered">登録済み</button>
<button data-view="layout">レイアウト</button>
<button data-view="wifi">Wi-Fi / SD</button>
</nav>
<main>
<div id="view-scan" class="view active">
<p class="small">周囲で受信中のSwitchBotデバイス一覧です。「登録」から名前とタグを付けて登録できます。</p>
<div id="scan-list"></div>
</div>
<div id="view-registered" class="view">
<div id="registered-list"></div>
</div>
<div id="view-layout" class="view">
<p class="small">ドラッグして本体画面でのタイル表示順を並べ替えます。</p>
<div id="layout-list"></div>
<button class="btn" id="layout-save">この順序で保存</button>
<div class="preview-grid" id="layout-preview"></div>
</div>
<div id="view-wifi" class="view">
<div class="card">
<b>ホームWi-Fi(時刻同期専用)</b>
<p class="small">この情報は定期的なNTP時刻同期のためだけに使われます。本体画面はこのWi-Fiに常時接続しません。</p>
<label>SSID</label>
<input type="text" id="wifi-ssid">
<label>パスワード</label>
<input type="password" id="wifi-password">
<div style="margin-top:10px"><button class="btn" id="wifi-save">保存</button></div>
<div class="msg" id="wifi-msg"></div>
</div>
<div class="card">
<b>microSD書き込み間隔</b>
<label>この間隔(分)ごとにRAM上の履歴データをSDへまとめて書き込みます</label>
<select id="sd-interval"></select>
<div class="msg" id="sd-msg"></div>
</div>
<div class="card">
<b>デバイス一覧のバックアップ (CSV)</b>
<p class="small">登録済みと検出済みの全デバイスをCSVで保存・復元します。ファームウェア更新や設定の初期化の前に保存しておくと、後から一括で復元できます。microSDカードは不要です。</p>
<div style="margin-top:10px">
<button class="btn" id="csv-download">CSVをダウンロード</button>
<button class="btn secondary" id="csv-show">画面に表示(コピー用)</button>
</div>
<label style="margin-top:12px">CSVファイルを選んで復元</label>
<input type="file" id="csv-file" accept=".csv,text/csv">
<p class="small">※ CSVを編集して保存し直した場合は、ファイルを選び直してください(選択した時点の内容に紐づくため、編集後は読み取れません)。</p>
<div style="margin-top:10px"><button class="btn" id="csv-upload">選んだCSVを取り込む</button></div>
<label style="margin-top:12px">またはCSVの内容を貼り付けて復元</label>
<textarea id="csv-paste" rows="6" placeholder="mac,name,model,tags,registered,..." style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; padding:8px; border-radius:8px; border:1px solid var(--border); background:var(--bg); color:var(--ink);"></textarea>
<div style="margin-top:10px"><button class="btn" id="csv-paste-import">貼り付けた内容を取り込む</button></div>
<p class="small">取り込みは追加・更新のみで、CSVに無いデバイスが消えることはありません。registered列が1の行だけが対象です。</p>
<div class="msg" id="csv-msg"></div>
</div>
<div class="card danger-zone">
<b style="color:var(--danger)">Danger Zone: 設定の初期化</b>
<p class="small">登録デバイス・タグ・Wi-Fi設定・SD書き込み間隔などをすべて消去し、本体を再起動します。<br><b>microSDカード内の記録データは消去されません</b>(消す場合はカードを取り出して手動で行ってください)。</p>
<button class="btn danger" id="factory-reset">設定を初期化する</button>
<div class="msg" id="reset-msg"></div>
</div>
</div>
</main>
<script>
const $ = (sel, root) => (root||document).querySelector(sel);
const $$ = (sel, root) => Array.from((root||document).querySelectorAll(sel));
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
const esc = s => String(s).replace(/[&<>"']/g, c => ESC_MAP[c]);
document.querySelectorAll('nav button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
btn.classList.add('active');
$('#view-' + btn.dataset.view).classList.add('active');
refreshView(btn.dataset.view);
});
});
let allTags = [];
// 家庭のWi-Fiに参加している場合はtrue。このときはBLEの受信が動いているので、
// 「検出」の一覧が実時間で更新されます。
let bleLive = false;
async function fetchJson(url, opts) {
const res = await fetch(url, opts);
return res.json();
}
async function loadTags() {
allTags = await fetchJson('/api/tags');
}
function tagListDatalist(id) {
return `<input type="text" list="${id}" placeholder="タグ (カンマ区切り)">
<datalist id="${id}">${allTags.map(t => `<option value="${t}">`).join('')}</datalist>`;
}
// ---- 検出タブ ----
async function renderScan() {
const list = $('#scan-list');
const devices = await fetchJson('/api/scan');
const notice = bleLive ? '' :
'<p class="small">※ このモードではBLEスキャンを停止しています。表示は Web設定 に入る直前までに受信した内容です。' +
'Wi-Fi設定を保存してから Web設定 に入り直すと、本体が同じネットワークに参加してBLEスキャンを続けたまま設定できます。</p>';
if (devices.length === 0) { list.innerHTML = notice + '<p class="small">まだ何も受信していません…</p>'; return; }
list.innerHTML = notice + devices.map(d => `
<div class="card">
<div class="card-row">
<div class="grow">
<div class="mac">${esc(d.mac)}</div>
<div class="small">${esc(d.modelLabel)} ・ RSSI ${d.rssi} ・ ${d.online ? '受信中' : '受信途絶'}</div>
<div class="small">${d.temperature != null ? d.temperature.toFixed(1) + '℃ / ' + d.humidity + '%' : '--'}${d.hasCo2 ? ' / CO2 ' + d.co2 + 'ppm' : ''}${d.hasIaq ? ' / 空気質' + (d.iaqStatic ? '(Static) ' : ' ') + d.iaq : ''}</div>
</div>
<span class="badge ${d.registered ? 'on' : ''}">${d.registered ? '登録済み' : '未登録'}</span>
</div>
${!d.registered ? `
<div style="margin-top:8px">
<input type="text" class="reg-name" placeholder="表示名" value="${esc(d.modelLabel)}">
${tagListDatalist('taglist-' + d.mac.replace(/:/g,''))}
<div style="margin-top:8px"><button class="btn reg-btn" data-mac="${esc(d.mac)}" data-model="${esc(d.model)}">登録</button></div>
</div>` : ''}
</div>
`).join('');
$$('.reg-btn', list).forEach(btn => {
btn.addEventListener('click', async () => {
const card = btn.closest('.card');
const name = $('.reg-name', card).value.trim() || btn.dataset.mac;
const tagsRaw = $('input[list]', card).value.trim();
const tags = tagsRaw ? tagsRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
await fetchJson('/api/devices', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mac: btn.dataset.mac, name, model: btn.dataset.model, tags }) });
await loadTags();
renderScan();
});
});
}
// ---- 登録済みタブ ----
async function renderRegistered() {
const list = $('#registered-list');
const devices = await fetchJson('/api/devices');
if (devices.length === 0) { list.innerHTML = '<p class="small">登録済みデバイスはありません。「検出」タブから登録してください。</p>'; return; }
list.innerHTML = devices.map(d => `
<div class="card" data-mac="${esc(d.mac)}">
<div class="mac">${esc(d.mac)}</div>
<label>表示名</label>
<input type="text" class="name-input" value="${esc(d.name)}">
<label>タグ</label>
${tagListDatalist('taglist-r-' + d.mac.replace(/:/g,''))}
<div class="tags">${d.tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('')}</div>
<div class="card-row" style="margin-top:10px">
<button class="btn secondary save-tags-btn">タグ追加</button>
<button class="btn danger delete-btn" style="margin-left:auto">削除</button>
</div>
<input type="hidden" class="existing-tags" value='${esc(JSON.stringify(d.tags))}'>
</div>
`).join('');
$$('.card', list).forEach(card => {
const mac = card.dataset.mac;
$('.name-input', card).addEventListener('change', async e => {
await fetchJson('/api/devices/rename', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mac, name: e.target.value }) });
});
$('.save-tags-btn', card).addEventListener('click', async () => {
const newTag = $('input[list]', card).value.trim();
if (!newTag) return;
const existing = JSON.parse($('.existing-tags', card).value);
const merged = Array.from(new Set([...existing, newTag]));
await fetchJson('/api/devices/tags', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mac, tags: merged }) });
await loadTags();
renderRegistered();
});
$('.delete-btn', card).addEventListener('click', async () => {
if (!confirm(mac + ' を削除しますか?')) return;
await fetchJson('/api/devices/delete', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mac }) });
renderRegistered();
});
});
}
// ---- レイアウトタブ ----
let layoutOrder = [];
async function renderLayout() {
const devices = await fetchJson('/api/devices');
layoutOrder = devices.map(d => ({ mac: d.mac, name: d.name }));
drawLayoutList();
drawLayoutPreview();
}
function drawLayoutList() {
const list = $('#layout-list');
list.innerHTML = layoutOrder.map((d, i) => `
<div class="drag-item" draggable="true" data-index="${i}">
<span class="drag-handle">☰</span><span>${esc(d.name)}</span>
</div>`).join('');
let dragIndex = null;
$$('.drag-item', list).forEach(item => {
item.addEventListener('dragstart', () => { dragIndex = Number(item.dataset.index); item.classList.add('dragging'); });
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', e => e.preventDefault());
item.addEventListener('drop', () => {
const dropIndex = Number(item.dataset.index);
if (dragIndex === null || dragIndex === dropIndex) return;
const moved = layoutOrder.splice(dragIndex, 1)[0];
layoutOrder.splice(dropIndex, 0, moved);
drawLayoutList();
drawLayoutPreview();
});
});
}
function drawLayoutPreview() {
$('#layout-preview').innerHTML = layoutOrder.map(d => `<div class="preview-tile">${esc(d.name)}</div>`).join('');
}
$('#layout-save').addEventListener('click', async () => {
await fetchJson('/api/devices/reorder', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ order: layoutOrder.map(d => d.mac) }) });
});
// ---- Wi-Fi / SDタブ ----
async function renderWifi() {
const s = await fetchJson('/api/settings');
$('#wifi-ssid').value = s.wifiSsid || '';
$('#sd-interval').innerHTML = s.sdFlushOptions.map(v =>
`<option value="${v}" ${v === s.sdFlushIntervalMinutes ? 'selected' : ''}>${v}分</option>`).join('');
}
$('#wifi-save').addEventListener('click', async () => {
const ssid = $('#wifi-ssid').value.trim();
const password = $('#wifi-password').value;
const r = await fetchJson('/api/settings/wifi', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ ssid, password }) });
$('#wifi-msg').textContent = r.ok
? (bleLive ? '保存しました'
: '保存しました。本体で Web設定 を一度終了して開始し直すと、このネットワークに参加してBLEスキャンを続けたまま設定できます。')
: '保存に失敗しました';
$('#wifi-msg').className = 'msg ' + (r.ok ? 'ok' : 'err');
});
$('#sd-interval').addEventListener('change', async e => {
const r = await fetchJson('/api/settings/sdinterval', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ minutes: Number(e.target.value) }) });
$('#sd-msg').textContent = r.ok ? '保存しました' : '保存に失敗しました';
$('#sd-msg').className = 'msg ' + (r.ok ? 'ok' : 'err');
});
// fetchではなく、そのままページを移動させます。
// こうするとファイル名の決定も保存先の選択もブラウザに任せられます。
$('#csv-download').addEventListener('click', () => { window.location.href = '/api/devices/csv'; });
// ページの中だけで完結します。ダウンロードもファイル選択も、保存の許可も
// 必要ありません。何が保存されるかを確かめるのにも、これがいちばん手軽です。
$('#csv-show').addEventListener('click', async () => {
const msg = $('#csv-msg');
try {
const res = await fetch('/api/devices/csv');
$('#csv-paste').value = await res.text();
msg.textContent = '現在の内容を下の欄に表示しました。コピーして保存できます。';
msg.className = 'msg ok';
} catch (e) {
msg.textContent = '取得に失敗しました: ' + e;
msg.className = 'msg err';
}
});
// 選ばれたファイルの中身を読みます。
//
// 読み方は2通りあり、ファイルの置き場所によってどちらか一方だけが失敗する
// ことがあります(クラウド上の実体の無いファイル、選択後に移動されたファイルなど)。
// 片方が駄目でももう片方を試します。
function readFileText(file) {
return file.text().catch(() => new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(fr.error);
fr.readAsText(file, 'UTF-8');
}));
}
async function sendCsv(text, msg) {
msg.textContent = '取り込み中...'; msg.className = 'msg';
const r = await fetchJson('/api/devices/csv', { method:'POST', headers:{'Content-Type':'text/csv'}, body: text });
msg.textContent = r.message || (r.ok ? '取り込みました' : '取り込みに失敗しました');
msg.className = 'msg ' + (r.ok ? 'ok' : 'err');
if (r.ok) { await loadTags(); refreshView('registered'); }
}
$('#csv-upload').addEventListener('click', async () => {
const file = $('#csv-file').files[0];
const msg = $('#csv-msg');
if (!file) { msg.textContent = 'CSVファイルを選んでください'; msg.className = 'msg err'; return; }
msg.textContent = '読み込み中...'; msg.className = 'msg';
let text;
try {
text = await readFileText(file);
} catch (e) {
// 最も多い原因は、ファイルを選んだ後に編集して保存した場合です。
// ブラウザは選んだ時点の大きさと更新日時を覚えていて、それが変わると
// 読み取りを拒否します。「書き出す→編集する→取り込む」はこの機能の
// 本来の使い方なので、その説明を最初に出します。
$('#csv-file').value = '';
msg.innerHTML = '<b>ファイルを選び直してください。</b>' +
'<br>CSVを編集・保存すると、選択済みのファイルは無効になります(選び直せば取り込めます)。' +
'<br>クラウド保存(Googleドライブ/iCloud/OneDrive等)から直接選んだ場合も読めないことがあります。' +
'その場合は端末内に保存し直すか、下の欄に内容を貼り付けてください。' +
'<br>' + esc('エラー: ' + (e && e.name ? e.name : e));
msg.className = 'msg err';
return;
}
try { await sendCsv(text, msg); }
catch (e) { msg.textContent = '取り込みに失敗しました: ' + e; msg.className = 'msg err'; }
});
// ファイル選択を使わず、貼り付けた内容をそのまま取り込みます。
// ブラウザがファイルを読めないときの、確実な方法です。
$('#csv-paste-import').addEventListener('click', async () => {
const msg = $('#csv-msg');
const text = $('#csv-paste').value;
if (!text.trim()) { msg.textContent = 'CSVの内容を貼り付けてください'; msg.className = 'msg err'; return; }
try { await sendCsv(text, msg); }
catch (e) { msg.textContent = '取り込みに失敗しました: ' + e; msg.className = 'msg err'; }
});
$('#factory-reset').addEventListener('click', async () => {
if (!confirm('本当に設定を初期化しますか?登録データ等は全て消去されます(microSD内のデータは消えません)。')) return;
if (!confirm('最終確認: 元に戻せません。実行しますか?')) return;
$('#reset-msg').textContent = '初期化中... 本体が再起動します';
$('#reset-msg').className = 'msg ok';
await fetchJson('/api/factory-reset', { method:'POST' });
});
function refreshView(view) {
if (view === 'scan') renderScan();
else if (view === 'registered') renderRegistered();
else if (view === 'layout') renderLayout();
else if (view === 'wifi') renderWifi();
}
(async function init() {
await loadTags();
try { bleLive = !!(await fetchJson('/api/settings')).bleLive; } catch (e) { bleLive = false; }
refreshView('scan');
setInterval(() => {
const list = $('#scan-list');
const focused = document.activeElement;
const editing = list && focused && list.contains(focused) && focused.tagName === 'INPUT';
if ($('#view-scan').classList.contains('active') && !editing) renderScan();
}, 4000);
})();
</script>
</body>
</html>
)HTMLPAGE";
void handleIndex() {
logRequest("GET / (main page)");
g_server.send_P(200, "text/html", kIndexHtml);
LOG(" sent index page, %u bytes", unsigned(strlen_P(kIndexHtml)));
}
// 要求先と処理の対応を登録します。1度だけ実行します。
// この登録はサーバを止めても消えないので、Web設定を開き直すたびに登録すると
// 同じものが二重三重に積み上がってしまいます。
void registerRoutesOnce() {
static bool registered = false;
if (registered) return;
registered = true;
g_server.on("/", HTTP_GET, handleIndex);
g_server.on("/api/scan", HTTP_GET, handleApiScan);
g_server.on("/api/devices", HTTP_GET, handleApiDevicesGet);
g_server.on("/api/devices", HTTP_POST, handleApiDeviceAdd);
g_server.on("/api/devices/rename", HTTP_POST, handleApiDeviceRename);
g_server.on("/api/devices/tags", HTTP_POST, handleApiDeviceTags);
g_server.on("/api/devices/delete", HTTP_POST, handleApiDeviceDelete);
g_server.on("/api/devices/reorder", HTTP_POST, handleApiDeviceReorder);
g_server.on("/api/devices/csv", HTTP_GET, handleApiDevicesCsvGet);
g_server.on("/api/devices/csv", HTTP_POST, handleApiDevicesCsvPost);
g_server.on("/api/tags", HTTP_GET, handleApiTags);
g_server.on("/api/settings", HTTP_GET, handleApiSettingsGet);
g_server.on("/api/settings/wifi", HTTP_POST, handleApiSettingsWifi);
g_server.on("/api/settings/sdinterval", HTTP_POST, handleApiSettingsSdInterval);
#if DIAG_CAMERA_PROBE
g_server.on("/api/camera.bmp", HTTP_GET, handleApiCameraBmp);
#endif
g_server.on("/api/factory-reset", HTTP_POST, handleApiFactoryReset);
g_server.onNotFound(handleNotFound);
}
} // namespace
namespace WebConfigServer {
void begin() {
// ここですることはありません。実際の開始はstart()です。
// 他の機能と同じ形(begin()とupdate()を持つ)にそろえてあるだけです。
}
bool start(bool forceAp) {
if (g_active) return true;
if (TimeSync::isBusy()) {
LOGW("start() refused: TimeSync is mid-sync, retry in a few seconds");
return false;
}
LOG("start(forceAp=%s) -- credentials stored: %s", forceAp ? "true" : "false",
SettingsStore::hasWifiCredentials() ? "yes" : "no");
// まず、家庭のWi-Fiに参加する方法を試します。
//
// 無線を担当しているESP32-C6は、Wi-Fiの子機としての通信とBLEの同時使用は
// 安定して行えますが、自らアクセスポイントになりながらのBLEは不安定です。
// 実際、アクセスポイントとして動かすとBLEの受信中にページが開かなくなります。
// 家庭のWi-Fiに参加すれば、Web設定を開いている間もBLEの受信を続けられるので、
// 「検出」で新しい機器を見つけられます。
if (!forceAp && SettingsStore::hasWifiCredentials()) {
// BLEを止めるのは、Wi-Fiにつなぎに行くこの一瞬だけです。
// ここが最も不安定なためで、つながった後は受信を再開します。
BleScanner::pause();
WiFi.mode(WIFI_STA);
WiFi.setHostname(WEB_CONFIG_MDNS_HOSTNAME);
String ssid = SettingsStore::wifiSsid();
String pass = SettingsStore::wifiPassword();
LOG("STA: joining SSID=\"%s\" (password length %u), hostname=\"%s\"", ssid.c_str(),
unsigned(pass.length()), WEB_CONFIG_MDNS_HOSTNAME);
if (pass.length() > 0) WiFi.begin(ssid.c_str(), pass.c_str());
else WiFi.begin(ssid.c_str());
uint32_t started = millis();
wl_status_t last = WL_NO_SHIELD;
while (WiFi.status() != WL_CONNECTED &&
millis() - started < WEB_CONFIG_STA_CONNECT_TIMEOUT_MS) {
wl_status_t now = WiFi.status();
if (now != last) {
LOG("STA: status -> %d (%s) at %lums", int(now), wlStatusName(now),
(unsigned long)(millis() - started));
last = now;
}
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
g_mode = Mode::Sta;
LOG("STA: CONNECTED in %lums", (unsigned long)(millis() - started));
logNetworkDetails();
bool mdnsOk = MDNS.begin(WEB_CONFIG_MDNS_HOSTNAME);
if (mdnsOk) MDNS.addService("http", "tcp", 80);
LOG("STA: mDNS.begin(\"%s\") = %s", WEB_CONFIG_MDNS_HOSTNAME, mdnsOk ? "OK" : "FAILED");
BleScanner::resume();
registerRoutesOnce();
g_server.begin();
g_active = true;
g_requestCount = 0;
g_lastHeartbeatMillis = millis();
LOG("STA: HTTP server listening on port 80 -- browse to http://%s/",
WiFi.localIP().toString().c_str());
LOG("If no 'HTTP request' lines appear below when you load the page, the");
LOG("connection is not reaching this device at all (router client isolation,");
LOG("wrong subnet, or the phone is on a different network/VPN).");
return true;
}
LOGW("STA: connect to \"%s\" TIMED OUT after %lums (last status %d/%s) -- falling back to AP",
ssid.c_str(), (unsigned long)(millis() - started), int(WiFi.status()),
wlStatusName(WiFi.status()));
WiFi.disconnect(true);
} else {
if (forceAp) LOG("AP mode requested explicitly, skipping the STA attempt");
BleScanner::pause();
}
// ここから先は、Wi-Fiの設定がまだ無い場合(初回)か、参加に失敗した場合です。
// 本機自身がアクセスポイントになります。
// この方式ではBLEとの同時使用が不安定なので、Web設定を閉じるまで受信を
// 止めたままにします。「検出」にはWeb設定を開く前に受信した内容が並びます。
g_mode = Mode::Ap;
WiFi.mode(WIFI_AP);
bool apOk = WiFi.softAP(AP_SSID, (strlen(AP_PASSWORD) > 0) ? AP_PASSWORD : nullptr);
LOG("AP: softAP(\"%s\") = %s, IP=%s, mode=%d", AP_SSID, apOk ? "OK" : "FAILED",
WiFi.softAPIP().toString().c_str(), int(WiFi.getMode()));
registerRoutesOnce();
g_server.begin();
g_active = true;
g_requestCount = 0;
g_lastHeartbeatMillis = millis();
LOG("AP: HTTP server listening on port 80 -- join \"%s\" then browse to http://%s/", AP_SSID,
WiFi.softAPIP().toString().c_str());
return true;
}
void stop() {
if (!g_active) return;
LOG("stop(): served %lu HTTP requests this session", (unsigned long)g_requestCount);
g_server.stop();
if (g_mode == Mode::Sta) {
MDNS.end();
WiFi.disconnect(true);
} else {
WiFi.softAPdisconnect(true);
}
WiFi.mode(WIFI_OFF);
g_active = false;
BleScanner::resume();
}
bool isActive() { return g_active; }
bool isStaMode() { return g_active && g_mode == Mode::Sta; }
String ssidString() {
if (!g_active) return String();
return (g_mode == Mode::Sta) ? WiFi.SSID() : String(AP_SSID);
}
String hostnameUrl() {
if (!g_active || g_mode != Mode::Sta) return String();
return String("http://") + WEB_CONFIG_MDNS_HOSTNAME + ".local/";
}
String apIpString() {
if (!g_active) return String();
return (g_mode == Mode::Sta) ? WiFi.localIP().toString() : WiFi.softAPIP().toString();
}
void update() {
if (!g_active) return;
g_server.handleClient();
// 一定間隔で現在の状態を出力します。
//
// 応答処理が今も動いていることの確認であり、つながった後で無線が黙って
// 切れたりIPアドレスが変わったりした場合にも気付けます。
// スマートフォン側からは、どちらも「ページが開かない」としか見えません。
uint32_t now = millis();
if (now - g_lastHeartbeatMillis < 5000) return;
g_lastHeartbeatMillis = now;
if (g_mode == Mode::Sta) {
wl_status_t st = WiFi.status();
LOG("alive: mode=STA status=%d(%s) IP=%s RSSI=%d requests=%lu", int(st), wlStatusName(st),
WiFi.localIP().toString().c_str(), WiFi.RSSI(), (unsigned long)g_requestCount);
if (st != WL_CONNECTED) LOGW("Wi-Fi link is DOWN -- the page cannot load in this state");
} else {
LOG("alive: mode=AP IP=%s clients=%d requests=%lu", WiFi.softAPIP().toString().c_str(),
WiFi.softAPgetStationNum(), (unsigned long)g_requestCount);
}
}
} // namespace WebConfigServer