-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBleScanner.cpp
More file actions
831 lines (747 loc) · 37.2 KB
/
Copy pathBleScanner.cpp
File metadata and controls
831 lines (747 loc) · 37.2 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
#include "BleScanner.h"
#include <BLEAdvertisedDevice.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEUUID.h>
#include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "Config.h"
#include "DeviceStore.h"
#include "UnitSensor.h"
#include "WebConfigServer.h"
namespace {
std::map<String, SensorReading> g_readings;
// 受信を一時停止しているかどうか。pause()/resume()で切り替えます
//(時刻合わせでWi-Fiを使う短い間などに使います)。
// 実際に受信を再開するのはupdate()です。使っているBLEのライブラリは、
// 受信が自分から止まったときに知らせてくれないので、「本来動いているべきか」
// をこの変数で覚えておきます。
bool g_paused = false;
// BLEの準備が済んでいるかどうか。
//
// DIAG_DISABLE_BLE を立てたときなど、begin()が何もしないことがあります。
// 一方でpause()/resume()/update()は他の機能から無条件に呼ばれます。
// 準備前にBLEの機能を呼ぶと異常終了するので、外から呼ばれる関数は
// すべて最初にこれを確認します。
bool g_initialized = false;
uint32_t g_lastRestartAttemptMillis = 0;
uint32_t g_restartCount = 0;
uint32_t g_lastRecoveryMillis = 0;
uint32_t g_recoveryCount = 0;
// microSDを使う間だけ受信を止めるための受け渡し用(suspendForSdAccess参照)。
// 書き込みタスクが要求を立て、主タスクのupdate()がそれを見て実際に止めます。
// こうすることで、BLEの機能を呼ぶのは常に1つのタスクだけになります。
volatile bool g_sdAccessRequested = false;
volatile bool g_stoppedForSdAccess = false;
// 主タスクの識別子。begin()(setup()から呼ばれる)で覚えます。
// これはloop()、つまりupdate()を実行しているタスクでもあります。
TaskHandle_t g_mainTaskHandle = nullptr;
// 最後にSwitchBotの発信を受け取った時刻。起動直後に「受信が途絶えた」と
// 誤って判定しないよう、begin()で現在時刻を入れておきます。
uint32_t g_lastAdvertMillis = 0;
// 受信した件数。読み出すたびに0に戻ります(takeAdvertisementCount参照)。
volatile uint32_t g_advertCount = 0;
// ---- アクティブスキャンの間欠化 ----
//
// 問い合わせを送るのは電池残量を集めるときだけにして、ふだんは送りません。
// 相手の電池を余計に消耗させないためです(Config.hのBLE_ACTIVE_SCAN_INTERVAL_MS)。
//
// 今どちらのモードかを状態として持ち、スキャンを始めるときに毎回それを
// 適用します。こうすると、microSDへの書き出しなどで止まって再開したときも、
// そのときのモードで始まります。モードを変えるためだけにスキャンを止める
// 必要がありません。
bool g_activeScanNow = false;
// 今の窓を開いた時刻と、そのうち実際に受信できていた時間の合計。
//
// 止まっていた時間を数えないのは、書き出しと重なったときに、集める機会が
// ないまま窓が終わってしまうのを避けるためです。
uint32_t g_windowActiveMillis = 0;
uint32_t g_windowTickMillis = 0;
// 窓を開いた時刻。この時刻より後に電池残量を受け取ったかどうかで、
// 機器ごとの「集まったか」を判定します。
uint32_t g_windowOpenedMillis = 0;
// 次に窓を開く時刻。
uint32_t g_nextWindowMillis = 0;
// 電池残量が未取得の機器がいるときに、窓を開き直してよくなる時刻。
uint32_t g_nextRetryMillis = 0;
// 起動直後の窓を閉じ終えたか。
//
// 最初の窓だけは、まだ一度も受信していない機器も待ちます。起動直後はどの機器も
// 未受信なので、待たなければ条件がその場で成立して窓がすぐ閉じてしまいます。
//
// 2回目以降は待ちません。圏外や電池切れで届かない機器が1台でもあると、毎回
// 上限まで粘ることになるためです。そうした機器が後から戻ってきても、受信できた
// 時点で電池残量が未取得と分かるので、10分以内に窓が開き直ります。
bool g_firstWindowDone = false;
// 製造者データから温度・湿度・CO2を取り出します。
//
// 渡されるのは先頭2バイトの製造者IDを含んだ形なので、b[0]とb[1]がIDです。
// 各項目の位置は実際の発信で確認しています。
// b[10]の下位4ビット ... 温度の小数部
// b[11] ... 温度の整数部(最上位ビットが符号)
// b[12] ... 湿度
void decodeManufacturerData(const uint8_t *b, size_t len, SensorReading &reading) {
if (len < 13) return;
float decimal = (b[10] & 0x0F) * 0.1f;
float whole = float(b[11] & 0x7F);
float sign = (b[11] & 0x80) ? 1.0f : -1.0f;
float temperature = (decimal + whole) * sign;
// SwitchBotの製造者IDで発信している機器が、すべて温湿度計とは限りません。
// ハブやカーテンなども同じIDを使い、中身の並びは異なります。それらを
// この手順で解釈すると、実機で-114.0℃といった値が出てきました。
// センサが実際に取りうる範囲を外れた値は、画面に出さずここで捨てます。
if (temperature < SWITCHBOT_TEMP_MIN_C || temperature > SWITCHBOT_TEMP_MAX_C) return;
uint8_t humidity = b[12] & 0x7F;
if (humidity > 100) return;
reading.temperature = temperature;
reading.humidity = humidity;
reading.tempHumMillis = millis();
// CO2濃度(Meter Pro CO2)は同じ製造者データに入っていますが、18バイトの
// 長いほうの発信にしか含まれません。同じ機器が温度と湿度だけの15バイトの
// 発信も行うので、両方を区別する必要があります。
// 位置は15バイト目からの2バイトで、上位バイトが先です。
//
// hasCo2は一度立てたら下ろしません。短いほうの発信は「CO2がもう無い」と
// 言っているわけではなく、単に別の種類の通知だからです。下ろしてしまうと、
// タイルの表示が行ったり来たりします。
if (len >= 18) {
uint16_t co2 = (uint16_t(b[15]) << 8) | uint16_t(b[16]);
bool validCo2 = (co2 > 0 && co2 <= SWITCHBOT_CO2_MAX_PPM);
if (validCo2) {
reading.co2 = co2;
reading.hasCo2 = true;
reading.co2Millis = millis();
}
#if DIAG_LOG_CO2_FIRST_PACKET
// 長いほうの発信を初めて受け取ったときだけ、1機器につき1行出します。
// ここに出るppmがSwitchBotのアプリの表示と違っていた場合、隣に並ぶ
// 生のデータを見れば、読み取り位置を直せます。
static String s_logged;
char macKey[8];
snprintf(macKey, sizeof(macKey), "%02X%02X|", unsigned(b[6]), unsigned(b[7]));
if (s_logged.indexOf(macKey) < 0) {
s_logged += macKey;
Serial.printf("[CO2] long manufacturer data (%u B):", unsigned(len));
for (size_t i = 0; i < len; i++) Serial.printf(" %02X", b[i]);
Serial.printf(" -> b[15..16] = %u ppm\n", unsigned(co2));
}
#endif
}
}
// サービスデータから機種と電池残量を取り出します。
//
// 呼び出し側が先頭2バイトのUUIDを取り除いてから渡すので、
// b[0]が機種を表す文字('T'ならMeter、'w'ならOutdoor Meter)、
// b[2]が電池残量(%)です。
// 電池残量が分かるのはここだけで、製造者データには入っていません。
void decodeServiceData(const uint8_t *b, size_t len, SensorReading &reading) {
if (len < 3) return;
reading.modelChar = char(b[0] & 0x7F);
uint8_t battery = b[2] & 0x7F;
if (battery > 100) return; // 電池残量ではない。前の値を残す
reading.battery = battery;
reading.batteryMillis = millis();
}
// 独自ビーコン(EnvProBeacon)の製造者データから測定値を取り出します。
// 受け取れたらtrueを返します。
//
// 渡されるのは先頭2バイトのメーカーIDを含んだ形です。並びは次のとおりで、
// 2バイトの値はいずれも下位バイトが先です。
// b[0..1] メーカーID(0xFFFF)
// b[2..3] 合言葉(BEACON_SIGNATURE_0 / _1)
// b[4] 電文の形式の版(履歴ファイルの版とは別物です)
// b[5] 機種を表す1文字
// b[6..7] 温度の100倍値(符号付き)。0x8000(INT16_MIN)はデータ無し
// b[8..9] 湿度の100倍値。0xFFFFはデータ無し
// b[10..11] 気圧の10倍値(hPa)。0xFFFFはデータ無し
// b[12..13] 空気質(IAQ または Static IAQ)。0xFFFFはデータ無し
// b[14] 下位2ビットが確からしさ(0〜3)、最上位ビットが立っていれば Static IAQ
// b[15..16] CO2相当値(ppm)。0xFFFFはデータ無し
// b[17..18] 呼気VOC相当値の100倍値(ppm)。0xFFFFはデータ無し
//
// 電池残量は送られてきません。据え置きで電源から使う機器なので、常に満充電か
// 電池そのものが無いかのどちらかで、見ても意味がないためです。
//
// 合言葉と版が合わない発信はここで捨てます。0xFFFFは誰でも使える番号なので、
// 別の人が作った機器の発信が同じ番号で届くことがあるためです。
bool decodeBeaconData(const uint8_t *b, size_t len, SensorReading &reading) {
if (len != BEACON_PAYLOAD_LEN) return false;
if (b[2] != uint8_t(BEACON_SIGNATURE_0) || b[3] != uint8_t(BEACON_SIGNATURE_1)) return false;
if (b[4] != BEACON_FORMAT_VERSION) return false;
const uint32_t now = millis();
reading.modelChar = char(b[5]);
// 何を持つ機器かは、届いた値ではなく機種で決めます。
//
// **温度と湿度を持たない機種(MH-Z19)で、これを届いた値から決めると
// タイルに「nan℃ 0%」と出ます。** 既定では「持っている」ことになっていて、
// SwitchBotの機器もGroveのセンサも持っているためです。
BleScanner::BeaconCapabilities caps;
if (BleScanner::beaconCapabilities(reading.modelChar, caps)) reading.hasTempHum = caps.tempHum;
// 温度と湿度は必ず一組で届きます。ENV PROはどちらも同じ測定で得るためです。
int16_t tempX100 = int16_t(uint16_t(b[6]) | (uint16_t(b[7]) << 8));
uint16_t humX100 = uint16_t(b[8]) | (uint16_t(b[9]) << 8);
if (tempX100 != INT16_MIN && humX100 != 0xFFFF) {
float temperature = tempX100 / 100.0f;
float humidity = humX100 / 100.0f;
if (temperature >= BEACON_TEMP_MIN_C && temperature <= BEACON_TEMP_MAX_C &&
humidity <= 100.0f) {
reading.temperature = temperature;
reading.humidity = humidity;
reading.tempHumMillis = now;
}
}
uint16_t pressX10 = uint16_t(b[10]) | (uint16_t(b[11]) << 8);
if (pressX10 != 0xFFFF) {
float hpa = pressX10 / 10.0f;
if (hpa >= BEACON_PRESSURE_MIN_HPA && hpa <= BEACON_PRESSURE_MAX_HPA) {
reading.pressureHpa = hpa;
reading.pressureMillis = now;
}
}
// 空気質は、送信側が「確からしい」と言っている間だけ受け取ります。
// 確からしさが0の値は、まだ較正が済んでいないので意味がありません。
uint16_t iaq = uint16_t(b[12]) | (uint16_t(b[13]) << 8);
uint8_t accuracy = b[14] & BEACON_IAQ_ACCURACY_MASK;
reading.iaqAccuracy = accuracy;
reading.iaqIsStatic = (b[14] & BEACON_IAQ_STATIC_FLAG) != 0;
if (iaq != 0xFFFF && iaq <= BEACON_IAQ_MAX && accuracy >= 1) {
reading.iaq = iaq;
reading.hasIaq = true;
reading.iaqMillis = now;
}
// CO2相当値です。実測ではなく、ガスセンサの値からの推定です。
// SwitchBotのCO2計と同じ枠に入れ、グラフの項目名だけを変えています。
uint16_t co2 = uint16_t(b[15]) | (uint16_t(b[16]) << 8);
if (co2 != 0xFFFF && co2 > 0 && co2 <= BEACON_CO2_MAX_PPM) {
reading.co2 = co2;
reading.hasCo2 = true;
reading.co2Millis = now;
}
uint16_t vocX100 = uint16_t(b[17]) | (uint16_t(b[18]) << 8);
if (vocX100 != 0xFFFF) {
float ppm = vocX100 / 100.0f;
if (ppm <= BEACON_VOC_MAX_PPM) {
reading.vocPpm = ppm;
reading.vocMillis = now;
}
}
// 電池残量は扱いません。タイルには「—」と出ます。
reading.hasBattery = false;
return true;
}
#if DIAG_LOG_BLE_PAYLOAD || DIAG_LOG_DEVICE_EVERY_PACKET
String toHex(const uint8_t *p, size_t len) {
String out;
char buf[4];
for (size_t i = 0; i < len; i++) {
snprintf(buf, sizeof(buf), "%02X ", p[i]);
out += buf;
}
return out;
}
// 受け取った発信の中身をそのまま出力します(解析結果もあわせて出します)。
// 読み取り位置を推測で直す前に、実際に何が届いているかを確かめるためのものです。
// 発信は毎秒何度も届くので、出力の間隔はかなり空けています。
void logPayloadOccasionally(const String &mac, int rssi, const uint8_t *p, size_t len,
const SensorReading &reading) {
static uint32_t s_lastLogMillis = 0;
if (millis() - s_lastLogMillis < DIAG_BLE_PAYLOAD_LOG_INTERVAL_MS) return;
s_lastLogMillis = millis();
Serial.printf("[ADV] %s rssi=%d raw(%u B): %s\n", mac.c_str(), rssi, unsigned(len),
toHex(p, len).c_str());
Serial.printf("[ADV] decoded: model='%c' temp=%.1f hum=%.1f batt=%u%%\n", reading.modelChar,
reading.temperature, reading.humidity, unsigned(reading.battery));
}
#endif
// 受け取った発信を、先頭から自分で読み解きます。
//
// BLEのライブラリにも解析機能はありますが、こちらの受信方法では正しく動かず、
// 無関係な機器の発信をSwitchBotのものとして返してくることがありました。
//
// 発信の中身は[長さ][種類][中身]の繰り返しという単純な構造なので、
// 自分で辿るほうが確実です。渡された長さと必ず突き合わせるので、壊れた発信や
// 長すぎる発信が来ても、途中で打ち切るだけで済みます。
void decodePayload(const uint8_t *p, size_t len, SensorReading &reading, bool &matched) {
// 念のための上限。正しい発信は、応答分を含めても31+31バイトです。
if (len > 62) len = 62;
size_t i = 0;
while (i + 1 < len) {
uint8_t fieldLen = p[i];
if (fieldLen == 0) break; // 終端の詰め物
if (i + 1 + fieldLen > len) break; // 途中で切れている。範囲外を読まずに中断
uint8_t type = p[i + 1];
const uint8_t *data = p + i + 2;
uint8_t dataLen = fieldLen - 1;
if (type == 0xFF && dataLen >= 13) { // 製造者データ
uint16_t company = uint16_t(data[0]) | (uint16_t(data[1]) << 8);
if (company == SWITCHBOT_MANUFACTURER_ID) {
decodeManufacturerData(data, dataLen, reading);
matched = true;
} else if (company == BEACON_MANUFACTURER_ID) {
// 独自ビーコンです。合言葉が合わなければ、他人の機器なので何もしません。
if (decodeBeaconData(data, dataLen, reading)) matched = true;
}
} else if (type == 0x16 && dataLen >= 2) { // サービスデータ(16ビットUUID)
uint16_t uuid = uint16_t(data[0]) | (uint16_t(data[1]) << 8);
uint8_t sdLen = dataLen - 2;
if (uuid == SWITCHBOT_SERVICE_UUID_16 && sdLen >= SWITCHBOT_SERVICE_DATA_MIN_LEN &&
sdLen <= SWITCHBOT_SERVICE_DATA_MAX_LEN) {
decodeServiceData(data + 2, sdLen, reading);
matched = true;
}
}
i += 1 + fieldLen;
}
}
class ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) override {
const uint8_t *payload = advertisedDevice.getPayload();
size_t payloadLen = advertisedDevice.getPayloadLength();
if (payload == nullptr || payloadLen == 0) return;
String mac = advertisedDevice.getAddress().toString();
auto existing = g_readings.find(mac);
bool knownDevice = (existing != g_readings.end());
SensorReading &reading = knownDevice ? existing->second : g_readings[mac];
#if DIAG_LOG_DEVICE_EVERY_PACKET
// 解析の前に、項目ごとの受信時刻を控えておきます。解析の後で見比べると、
// 「この発信で何が届いたか」が分かります。発信の種類を推測せずに済みます。
const bool logThisDevice = mac.equalsIgnoreCase(DIAG_LOG_DEVICE_MAC);
const uint32_t beforeTempHum = reading.tempHumMillis;
const uint32_t beforeBattery = reading.batteryMillis;
const uint32_t beforeCo2 = reading.co2Millis;
#endif
bool matched = false;
decodePayload(payload, payloadLen, reading, matched);
#if DIAG_LOG_DEVICE_EVERY_PACKET
if (logThisDevice) {
static uint32_t s_lastMillis = 0;
uint32_t now = millis();
// 前回の受信からの間隔。1回目は基準が無いので0にします。
unsigned gap = s_lastMillis ? unsigned(now - s_lastMillis) : 0;
s_lastMillis = now;
String got;
if (reading.tempHumMillis != beforeTempHum) got += "温湿度 ";
if (reading.batteryMillis != beforeBattery) got += "電池 ";
if (reading.co2Millis != beforeCo2) got += "CO2 ";
if (got.length() == 0) got = matched ? "(値なし) " : "(解析対象外) ";
Serial.printf("[DEV] +%5ums len=%2u rssi=%4d 更新: %s| temp=%.1f hum=%.1f batt=%u%% co2=%u\n",
gap, unsigned(payloadLen), advertisedDevice.getRSSI(), got.c_str(),
reading.temperature, reading.humidity, unsigned(reading.battery),
unsigned(reading.co2));
Serial.printf("[DEV] raw: %s\n", toHex(payload, payloadLen).c_str());
}
#endif
if (!matched) {
// 記録を消すのは、この発信が原因で新しく作られた場合だけです。
//
// SwitchBotの機器は何種類かの発信を交互に出していて、その中には測定値を
// 含まないものもあります。無条件に消してしまうと、そういう発信が届くたびに
// 温度・湿度・電池残量が失われ、次の発信で戻る、という繰り返しになり、
// タイルの表示が値と「未受信」の間で点滅してしまいます。
if (!knownDevice) g_readings.erase(mac);
return;
}
reading.rssi = int8_t(advertisedDevice.getRSSI());
reading.lastSeenMillis = millis();
reading.online = true;
g_lastAdvertMillis = reading.lastSeenMillis; // update()の受信途絶の判定に使う
g_advertCount++;
#if DIAG_LOG_BLE_PAYLOAD
logPayloadOccasionally(mac, advertisedDevice.getRSSI(), payload, payloadLen, reading);
#endif
}
};
ScanCallbacks g_scanCallbacks;
void startScan() {
// 始めるたびに今のモードを適用します。こうしておくと、microSDへの書き出しや
// 受信途絶からの復帰など、どの経路で再開してもモードが食い違いません。
BLEDevice::getScan()->setActiveScan(BLE_ACTIVE_SCAN && g_activeScanNow);
BLEDevice::getScan()->start(0, nullptr, false); // 0は「止めるまで続ける」の意味
}
void configureScan() {
BLEScan *scan = BLEDevice::getScan();
// 第2引数のtrueは「同じ機器からの発信も毎回知らせる」、
// 第3引数のfalseは「解析せず生のまま渡す」という指定です
//(ライブラリの解析は正しく動かないため。decodePayloadを参照)。
scan->setAdvertisedDeviceCallbacks(&g_scanCallbacks, true, false);
// 電池残量は、こちらから問い合わせたときの応答にしか含まれない機種が
// あります。ただし問い合わせは相手に電波を出させるので、常時ではなく
// 決まった間隔で短い時間だけ行います(下のupdateActiveScanWindow)。
scan->setActiveScan(BLE_ACTIVE_SCAN && g_activeScanNow);
scan->setInterval(BLE_SCAN_INTERVAL_MS);
scan->setWindow(BLE_SCAN_WINDOW_MS);
}
// ---- アクティブスキャンの窓 ----
// 今の窓が開いてから、登録済みの機器すべての電池残量を受け取ったか。
//
// 対象を登録済みの機器に絞るのは、近所の他人のSwitchBotも受信できてしまう
// ためです。
//
// まだ一度も受信していない機器を待つのは、起動直後の1回目の窓だけです。
// 起動直後はどの機器も未受信なので、待たなければ条件がその場で成立して窓が
// すぐ閉じてしまいます。2回目以降で待たないのは、圏外の機器が1台でもあると
// 毎回上限まで粘ることになるためです(g_firstWindowDone を参照)。
//
// 受信したうえで「未受信」に変わった機器も待ちません。電池切れなどで黙った
// 機器を待つと、同じく毎回上限まで粘ることになります。
//
// 判定はそのつど行い、開いた時点の一覧を控えたりはしません。控えると、
// 途中で黙った機器をいつまでも待つことになります。
bool batteryCollectedForAll() {
for (const auto &d : DeviceStore::devices()) {
// Grove端子のセンサはBLEの機器ではないので、いくら待っても受信しません。
if (UnitSensor::isUnitDevice(d.mac)) continue;
auto it = g_readings.find(d.mac);
if (it == g_readings.end()) {
if (!g_firstWindowDone) return false; // 起動直後だけ、届くのを待つ
continue; // 以降は、届かない機器を待たない
}
const SensorReading &r = it->second;
if (!r.online) continue; // 黙ってしまった機器は待たない
// 引き算を符号付きで見るのは、millis()が一周しても正しく判定するためです。
if (int32_t(r.batteryMillis - g_windowOpenedMillis) < 0) return false;
}
return true;
}
// 電池残量が古くなっている機器が、今つながっているか。
//
// 当てはまるのは2通りです。窓が閉じた後に初めて受信した機器(一度も取れて
// いない)と、電波が不安定で窓の間だけ黙っていた機器(前回の値のまま古い)
// です。どちらも次の定期の窓まで待つと長すぎるので、短い間隔で拾い直します。
//
// 受信できていない機器は対象外です。窓を開いても拾えないためです。
bool anyOnlineDeviceBatteryStale() {
uint32_t now = millis();
for (const auto &d : DeviceStore::devices()) {
if (UnitSensor::isUnitDevice(d.mac)) continue;
auto it = g_readings.find(d.mac);
if (it == g_readings.end()) continue;
const SensorReading &r = it->second;
if (!r.online) continue;
if (r.batteryMillis == 0) return true; // 一度も取れていない
// 引き算を符号付きで見るのは、millis()が一周しても正しく判定するためです。
if (int32_t(now - r.batteryMillis) > int32_t(BLE_ACTIVE_SCAN_STALE_MS)) return true;
}
return false;
}
// 上限まで待っても電池残量が集まらなかった機器を出力します。
//
// 上限で終わるということは、どれかの機器が応答していないということです。
// 名前が出ないと、19台の中からどれなのかを探せません。
//
// 「応答なし」は発信は届くのに問い合わせに答えなかった機器で、これが窓を
// 上限まで引き延ばした原因です。「受信なし」は電波が届いていない機器で、
// こちらは待っていません(電池切れや圏外が疑われます)。
//
// 2つを書き分けるのは、対処が違うためです。前者は電波の弱さ、後者は機器側の
// 状態を見ることになります。
void logDevicesMissingBattery() {
String waited; // 窓を引き延ばした機器
String notHeard; // 待っていないが、残量も取れていない機器
for (const auto &d : DeviceStore::devices()) {
if (UnitSensor::isUnitDevice(d.mac)) continue;
auto it = g_readings.find(d.mac);
if (it == g_readings.end()) {
if (notHeard.length() > 0) notHeard += ", ";
notHeard += d.name;
} else if (it->second.online &&
int32_t(it->second.batteryMillis - g_windowOpenedMillis) < 0) {
if (waited.length() > 0) waited += ", ";
waited += d.name;
}
}
if (waited.length() > 0) {
Serial.printf("[BLE] 問い合わせに応答しなかった機器: %s\n", waited.c_str());
}
if (notHeard.length() > 0) {
// 起動直後の窓だけは、まだ受信していない機器も待ちます。そのため
// 「待っていない」と言えるのは2回目以降です(g_firstWindowDone を参照)。
Serial.printf("[BLE] 発信が届いていない機器(%s): %s\n",
g_firstWindowDone ? "待っていません" : "この窓では待ちました",
notHeard.c_str());
}
}
// モードの切り替えを、今のスキャンに反映します。
//
// 窓は短いので、次に自然に止まる機会(microSDへの書き出しなど)を待っては
// 間に合いません。ここで一度止めて始め直します。止まっている最中であれば
// 何もしません。次に始まるときにstartScan()が今のモードを適用します。
void applyScanMode() {
if (!g_initialized || g_paused || g_stoppedForSdAccess) return;
BLEDevice::getScan()->stop();
startScan();
}
void openActiveWindow(const char *reason) {
if (g_activeScanNow) return;
g_activeScanNow = true;
g_windowOpenedMillis = millis();
g_windowTickMillis = g_windowOpenedMillis;
g_windowActiveMillis = 0;
Serial.printf("[BLE] アクティブスキャン開始 (%s)\n", reason);
applyScanMode();
}
void closeActiveWindow(const char *reason) {
if (!g_activeScanNow) return;
g_activeScanNow = false;
g_firstWindowDone = true;
g_nextWindowMillis = millis() + BLE_ACTIVE_SCAN_INTERVAL_MS;
g_nextRetryMillis = millis() + BLE_ACTIVE_SCAN_RETRY_MS;
Serial.printf("[BLE] アクティブスキャン終了 (%s, 実受信 %lums)\n", reason,
(unsigned long)g_windowActiveMillis);
applyScanMode();
}
void updateActiveScanWindow() {
#if BLE_ACTIVE_SCAN
uint32_t now = millis();
if (!g_activeScanNow) {
// Web設定の機器一覧には機種の名前を出します。その文字は電池残量と同じく
// 応答にしか入っていないので、登録作業の間はアクティブのままにします。
if (WebConfigServer::isActive()) openActiveWindow("Web設定");
else if (int32_t(now - g_nextWindowMillis) >= 0) openActiveWindow("定期");
else if (int32_t(now - g_nextRetryMillis) >= 0 && anyOnlineDeviceBatteryStale())
openActiveWindow("電池残量が古い機器あり");
return;
}
// 実際に受信できていた時間だけを数えます。microSDへの書き出しと重なって
// 止まっていた時間を数えると、集める機会がないまま窓が終わってしまいます。
if (!g_paused && !g_stoppedForSdAccess) g_windowActiveMillis += now - g_windowTickMillis;
g_windowTickMillis = now;
if (WebConfigServer::isActive()) return; // 登録作業の間は閉じない
if (batteryCollectedForAll()) {
closeActiveWindow("全機器そろった");
} else if (g_windowActiveMillis >= BLE_ACTIVE_SCAN_MAX_MS) {
// 閉じる前に出します。closeActiveWindow()がg_firstWindowDoneを立てるので、
// 後から呼ぶと「この窓で待ったかどうか」が正しく出せません。
logDevicesMissingBattery();
closeActiveWindow("上限");
}
#endif
}
size_t freeInternalBytes() {
return heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
}
// メモリが不足してきたら、BLEを作り直して取り戻します。
//
// 受信を続けているとメモリが少しずつ減っていきます。原因はこのファイルより
// 下の層(BLEのライブラリ側)にあり、ここで直せるものではありません。
// できるのは、その領域を抱えている仕組みごと一度壊して作り直すことだけです。
//
// 実行の前後でメモリ量を出力します。本当に戻っているかどうかを、
// あとから確かめられるようにするためです。
void maybeRecoverHeap() {
if (!g_initialized || g_paused) return;
size_t freeNow = freeInternalBytes();
// 合計の空きだけでは足りません。microSDのドライバもC6との通信路も、
// 連続した領域を必要とします。合計に余裕があっても、細切れになっていれば
// 確保に失敗します。どちらか一方でも不足したら作り直します。
size_t dmaLargest = heap_caps_get_largest_free_block(MALLOC_CAP_DMA);
bool lowTotal = freeNow < BLE_RECOVERY_HEAP_THRESHOLD_BYTES;
bool fragmented = dmaLargest < BLE_RECOVERY_DMA_BLOCK_MIN_BYTES;
if (!lowTotal && !fragmented) return;
uint32_t now = millis();
if (g_lastRecoveryMillis != 0 && now - g_lastRecoveryMillis < BLE_RECOVERY_MIN_INTERVAL_MS) return;
g_lastRecoveryMillis = now;
size_t psramBefore = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
Serial.printf("[BLE] %s (internal free=%u, largest DMA block=%u) -- restarting the BLE stack\n",
lowTotal ? "heap low" : "DMA memory too fragmented", unsigned(freeNow),
unsigned(dmaLargest));
BLEDevice::deinit(false); // falseは「無線側の領域は解放しない」。次のinit()のため
g_initialized = false;
delay(100);
BLEDevice::init("");
g_initialized = true;
configureScan();
g_lastAdvertMillis = millis();
g_lastRestartAttemptMillis = millis();
startScan();
g_recoveryCount++;
size_t freeAfter = freeInternalBytes();
size_t psramAfter = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
Serial.printf("[BLE] restart #%lu done: internal %u -> %u (%+ld), psram %+ld, largest DMA %u -> %u\n",
(unsigned long)g_recoveryCount, unsigned(freeNow), unsigned(freeAfter),
(long)freeAfter - (long)freeNow, (long)psramAfter - (long)psramBefore,
unsigned(dmaLargest),
unsigned(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)));
if (freeAfter <= freeNow) {
Serial.println("[BLE] WARN: restart did NOT reclaim memory -- the leak survives deinit()");
}
}
} // namespace
namespace BleScanner {
void begin() {
g_mainTaskHandle = xTaskGetCurrentTaskHandle();
#if DIAG_DISABLE_BLE
Serial.println("[BLE] DIAG_DISABLE_BLE=1: BLE stack not initialised (leak bisection build)");
return;
#endif
BLEDevice::init("");
g_initialized = true;
configureScan();
g_lastAdvertMillis = millis();
g_lastRestartAttemptMillis = millis();
// 起動直後は電池残量が何も分かっていないので、まず窓を開いた状態で始めます。
// 揃った時点で自動的にパッシブへ戻ります。
g_activeScanNow = true;
g_windowOpenedMillis = millis();
g_windowTickMillis = g_windowOpenedMillis;
g_windowActiveMillis = 0;
Serial.println("[BLE] アクティブスキャン開始 (起動)");
startScan();
}
void update() {
if (!g_initialized) return;
// まずmicroSDからの要求に応えます。カードを読み書きしている間は受信を
// 止めておく必要があるので、この後の作り直しや再開も行いません。
if (g_sdAccessRequested) {
if (!g_stoppedForSdAccess) {
BLEDevice::getScan()->stop();
g_stoppedForSdAccess = true;
}
return;
}
if (g_stoppedForSdAccess) {
g_stoppedForSdAccess = false;
if (!g_paused) {
g_lastAdvertMillis = millis(); // 止めていた間は届きようがないので数え直す
g_lastRestartAttemptMillis = millis();
startScan();
}
}
maybeRecoverHeap();
updateActiveScanWindow();
uint32_t now = millis();
for (auto &kv : g_readings) {
if (kv.second.online && now - kv.second.lastSeenMillis > DEVICE_OFFLINE_TIMEOUT_MS) {
kv.second.online = false;
}
}
// 受信が止まっていたら再開します。ライブラリが停止を知らせてくれないので、
// ここで自分で確かめています。
//
// 実行の間隔を空けているのは、loop()が10ミリ秒ほどで一周するためです。
// 無条件に試すと毎秒100回ほど再開を呼ぶことになり、BLEが応答しなくなって
// いる場合は処理しきれない要求が溜まって、本体ごと再起動してしまいます。
if (!g_paused && !BLEDevice::getScan()->isScanning() &&
now - g_lastRestartAttemptMillis >= BLE_SCAN_RESTART_MIN_INTERVAL_MS) {
g_lastRestartAttemptMillis = now;
g_restartCount++;
Serial.printf("[BLE] scan was stopped -- restarting (attempt #%lu, uptime %lus)\n",
(unsigned long)g_restartCount, (unsigned long)(now / 1000));
startScan();
}
// 受信が途絶えていないかの確認。
// 見かけ上は正常なまま、発信だけが届かなくなることがあります。
// 機器が次々「未受信」になってから気付くのでは遅いので、別に見ておきます。
if (!g_paused && now - g_lastAdvertMillis >= BLE_SCAN_SILENCE_WARN_MS) {
g_lastAdvertMillis = now; // 毎回ではなく、一定間隔で警告するように数え直す
Serial.printf("[BLE] WARN: no advertisements for %lus (uptime %lus) -- forcing a rescan\n",
(unsigned long)(BLE_SCAN_SILENCE_WARN_MS / 1000), (unsigned long)(now / 1000));
BLEDevice::getScan()->stop();
startScan();
}
}
void pause() {
g_paused = true;
if (!g_initialized) return;
BLEDevice::getScan()->stop();
}
void resume() {
g_paused = false;
if (!g_initialized) return;
// 2つの時刻を今に合わせます。停止中は届きようがないので、
// そのままだと再開直後に「途絶えている」と誤って判定してしまいます。
g_lastAdvertMillis = millis();
g_lastRestartAttemptMillis = millis();
startScan();
}
bool suspendForSdAccess(uint32_t timeoutMs) {
if (!g_initialized) return true; // BLEを使っていないので止めるものが無い
g_sdAccessRequested = true;
// グラフ用の読み出しは画面側のタスクから行われますが、それはこの要求に
// 応えるupdate()を動かしているタスクでもあります。自分の応答を自分で待つと
// 永久に止まってしまうので、その場合はここで直接止めます。
if (xTaskGetCurrentTaskHandle() == g_mainTaskHandle) {
if (!g_stoppedForSdAccess) {
BLEDevice::getScan()->stop();
g_stoppedForSdAccess = true;
}
return true;
}
uint32_t started = millis();
while (!g_stoppedForSdAccess && millis() - started < timeoutMs) {
delay(5);
}
return g_stoppedForSdAccess;
}
void releaseAfterSdAccess() { g_sdAccessRequested = false; }
uint32_t stackRestartCount() { return g_recoveryCount; }
uint32_t takeAdvertisementCount() {
uint32_t n = g_advertCount;
g_advertCount = 0;
return n;
}
int onlineDeviceCount() {
int n = 0;
for (const auto &kv : g_readings) {
if (kv.second.online) n++;
}
return n;
}
bool getReading(const String &mac, SensorReading &out) {
auto it = g_readings.find(mac);
if (it == g_readings.end()) return false;
out = it->second;
return true;
}
const std::map<String, SensorReading> &allReadings() { return g_readings; }
bool beaconCapabilities(char modelChar, BeaconCapabilities &out) {
BeaconCapabilities c;
switch (modelChar) {
case BEACON_MODEL_ENVPRO:
// BME688。温度・湿度・気圧・ガス抵抗を測り、ガス抵抗から空気質・
// CO2相当値・呼気VOC相当値を算出します。CO2は実測ではありません。
c.tempHum = true;
c.pressure = true;
c.iaq = true;
c.voc = true;
c.co2 = true;
c.co2IsEstimate = true;
break;
case BEACON_MODEL_MHZ19:
// 赤外線式のCO2センサ。CO2しか測りません。
c.co2 = true;
break;
case BEACON_MODEL_SCD30:
case BEACON_MODEL_SCD40:
// CO2に加えて温度・湿度も測ります。CO2はいずれも実測です。
c.tempHum = true;
c.co2 = true;
break;
default:
return false;
}
out = c;
return true;
}
const char *modelLabelFor(char modelChar) {
// 実際の発信で確認できているのは'w'だけです。他は公開されている資料
//(theengs/decoderなど)に基づくもので、実機での確認は済んでいません。
switch (modelChar) {
case 'T': return "Meter";
case 'i': return "Meter Plus";
case 'w': return "Outdoor Meter";
case 'r': return "Meter Pro CO2";
// ここから下は独自ビーコンです。SwitchBotの機器ではありません。
//
// SwitchBotの機種文字と重なる恐れはありますが、公開されている一覧に
// これらの文字は出てきません。万一重なった場合は、この表と各ビーコンの
// スケッチの BEACON_MODEL_CHAR を、空いている文字へ揃えて変えてください。
case BEACON_MODEL_ENVPRO: return "ENV PRO";
case BEACON_MODEL_MHZ19: return "MH-Z19B/C";
case BEACON_MODEL_SCD30: return "SCD30";
case BEACON_MODEL_SCD40: return "SCD40";
default: return "Unknown";
}
}
} // namespace BleScanner