-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtelegram.cpp
More file actions
1989 lines (1856 loc) · 105 KB
/
Copy pathtelegram.cpp
File metadata and controls
1989 lines (1856 loc) · 105 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
// telegram.cpp 🤖💬 Модуль Telegram бота на базе FastBot2
// 🌱💧 Система капельного полива с 8 каналами датчиков влажности
#include "telegram.h"
#include "objects.h"
#include "users.h" // 👥 модель пользователей и их хранение
#include "botutil.h" // 🧰 isNumeric/getValue/IntWith2Zero
#include "bot_transport.h" // 📡 sendReconnectMessage
#include "reports.h" // 📊 fileToGraf/fileToGrafPeriod/rm
#include "log.h"
#include <EEPROM.h>
#include "valves.h"
#include "version.h" // 🏷️ FW_VERSION — версия прошивки в стартовом сообщении
#include "faults.h" // 🩺 аварийный режим при неисправном оборудовании
#include <Update.h> // 📤 OTA-запись во flash (для распаковки .gz на лету)
#include <esp_task_wdt.h> // 🐕 сброс watchdog во время долгой распаковки
#include "uzlib.h" // 🗜️ потоковая распаковка gzip (vendored, zlib-лицензия)
// ============================================================
// 🎬 Коды состояний диалога (FSM). Диапазоны = база + индекс канала (0..Dlg::Last).
// ============================================================
namespace Dlg {
constexpr int None = 0; // нет активного диалога
constexpr int Restart = 1; // подтверждение перезагрузки
constexpr int WorkNight = 1001; // вкл/выкл работу ночью
constexpr int WorkRain = 1002; // вкл/выкл работу под дождём
constexpr int DeltaHum = 1003; // ввод дельты влажности
constexpr int DeltaCalib = 1004; // ввод дельты калибровки
constexpr int DelFolder = 1005; // ввод года для удаления
constexpr int BoostPump = 1006; // ввод порога насоса давления
constexpr int ClogThreshold = 1007; // ввод порога контроля фильтра
constexpr int TgTimeout = 1008; // ввод таймаута связи с Telegram
constexpr int AutoCalNoDly = 1009; // вкл/выкл авто-калибровку без задержки
// Калибровка датчика (база + индекс канала)
constexpr int CalibStart = 1100; // старт
constexpr int CalibWet = 1110; // замер «вода»
constexpr int CalibDry = 1120; // замер «сухо»
constexpr int CalibFinish = 1130; // завершение
constexpr int Rename = 1200; // переименование датчика (база + канал)
constexpr int ModeSet = 1300; // режим клапана (база + канал)
constexpr int ModeSetAll = 1399; // режим всех клапанов
constexpr int Border = 1400; // порог влажности (база + канал)
constexpr int CalibManual = 1800; // ручная калибровка мин,макс (база + канал)
constexpr int Reset = 2000; // сброс настроек
constexpr int SearchStart = 3000; // поиск датчика: старт
constexpr int SearchWet = 3010; // поиск датчика: замер «вода»
constexpr int SearchDetect = 3020; // поиск датчика: определение
constexpr int ClearFlow = 4000; // стереть данные расхода
constexpr int SendFile = 5000; // отправка CSV по дате
constexpr int GraphDate = 5100; // график за дату
constexpr int GraphPeriod = 5200; // график за период
constexpr int Last = NUM_CHANNELS - 1; // верхняя граница диапазона: база + Last
}
// ============================================================
// 🔄 Флаг необходимости сохранения конфигурации на SD-карту
// ============================================================
bool needUpdate = false;
// 🔄 Проверить и сбросить флаг необходимости обновления конфигурации
bool telegramNeedUpdate() {
bool nu = needUpdate;
needUpdate = false;
return nu;
}
// 🔧 Идёт ли у кого-нибудь диалог калибровки или поиска датчика.
// В этих режимах датчик держат в воде / на воздухе — показания заведомо
// нерабочие, поэтому авто-расширение границ калибровки должно быть отключено,
// иначе оно «выучит» калибровочные экстремумы как рабочий диапазон.
bool calibrationInProgress() {
for (uint8_t i = 0; i < userCount; i++) {
int a = users[i].action;
// авто-калибровка: старт → вода → сухо → завершение (1100..1137)
if (a >= Dlg::CalibStart && a <= Dlg::CalibFinish + Dlg::Last) return true;
// ручная калибровка (1800..1807)
if (a >= Dlg::CalibManual && a <= Dlg::CalibManual + Dlg::Last) return true;
// поиск датчика: замер в воде и определение (3000..3020)
if (a >= Dlg::SearchStart && a <= Dlg::SearchDetect) return true;
}
return false;
}
// 🛡️ Индекс канала, разобранный из команды вида "/BordersSet_N" (пользовательский
// текст или callback), может быть любым числом — кнопки в меню всегда шлют 0..7,
// но ничто не мешает зарегистрированному пользователю набрать команду вручную с
// произвольным N. Без проверки такой N уходит в Dlg::Base + ind и приводит к
// выходу за границы myConfig.chanel[]/HumiditySensors (порча Config, крэш).
static bool validChannelIndex(int ind, const String& userID) {
if (ind < 0 || ind >= NUM_CHANNELS) {
sendReconnectMessage(F("❌ Неверный номер канала"), userID);
return false;
}
return true;
}
// 🔘 Проверка нажатия reply-кнопки по её ТЕКСТОВОЙ части (без ведущего эмодзи).
// Метка reply-кнопки возвращается Telegram как текст сообщения, но эмодзи с
// вариационным селектором (например ➡️ = U+27A1 + U+FE0F) в round-trip может
// потерять селектор — тогда точное сравнение `text == "➡️ ДАЛЕЕ"` не срабатывает.
// Отбрасываем всё до первого пробела (эмодзи-префикс) и сравниваем остаток —
// это устойчиво и к «съехавшему» эмодзи, и к ловушкам подстроки (ДА ⊂ ДАЛЕЕ).
static bool btnIs(const String& text, const char* word) {
int sp = text.indexOf(' ');
String core = (sp >= 0) ? text.substring(sp + 1) : text;
core.trim();
return core == word;
}
// 🌱 Подпись канала «N (Название культуры)» — чтобы в диалогах калибровки было
// видно, какой именно датчик настраивается. Индекс уже проверен вызывающим.
static String chanLabel(int ind) {
return String(ind + 1) + " (" + String(myConfig.chanel[ind].title) + ")";
}
// 📤 Тип присланного файла прошивки
enum class OtaKind : uint8_t { None, Bin, Gz };
// 📤 Распознать файл прошивки по имени.
// Точное сравнение имён неудобно: Windows при повторном скачивании добавляет
// « (2)» ПЕРЕД расширением, и получается «DripIrrigation.ino.bin (2).gz» или
// «DripIrrigation.ino (2).bin». Поэтому смотрим на префикс (чтобы не прошить
// случайный документ) и на расширение (оно и задаёт способ обновления).
// Регистр игнорируем — некоторые клиенты меняют его при пересылке.
static OtaKind otaKindOf(const String& fileName) {
String n = fileName;
n.toLowerCase();
if (!n.startsWith("dripirrigation")) return OtaKind::None;
if (n.endsWith(".gz")) return OtaKind::Gz; // сжатый образ — распакуем сами
if (n.endsWith(".bin")) return OtaKind::Bin; // обычный образ
return OtaKind::None;
}
// ============================================================
// 🎹 Показ меню с учётом контекста (callback-кнопка или текст)
// ============================================================
// По нажатию inline-кнопки редактируем существующее сообщение (меню
// «переключается» на месте); в текстовом контексте (ответ на диалог Да/Нет,
// ввод числа, команда, набранная вручную) редактировать нечего — отправляем
// новое сообщение. Раньше все меню делали editText безусловно, из-за чего
// после текстового ответа меню молча не показывалось.
static void showMenu(fb::Update& u, const String& userID, const String& text, fb::InlineKeyboard& menu) {
if (u.isQuery()) {
fb::TextEdit t;
t.mode = fb::Message::Mode::HTML;
t.text = text;
t.chatID = u.query().message().chat().id();
t.messageID = u.query().message().id();
t.setKeyboard(&menu);
bot.editText(t);
} else {
fb::Message m;
m.text = text;
m.chatID = userID;
m.setModeHTML();
m.setKeyboard(&menu);
bot.sendMessage(m);
}
}
// 🏠 Главное меню (наполнение зависит от роли; используется при старте и на /Start)
static void buildMainMenu(fb::InlineKeyboard& menu, bool admin) {
if (admin) {
menu.addButton("🔄 Перезагрузка", "/Restart", fb::KeyStyle::Danger).newRow()
.addButton("👥 Пользователи", "/Users", fb::KeyStyle::Primary).newRow();
}
menu.addButton("⚙️ Управление", "/Control", fb::KeyStyle::Primary).newRow()
.addButton("📊 Статус", "/status", fb::KeyStyle::Primary).newRow()
.addButton("📈 Отчёты", "/Reports", fb::KeyStyle::Primary);
if (admin) {
menu.newRow().addButton("🔧 Настройка", "/Configure", fb::KeyStyle::Primary);
}
}
// ============================================================
// 🗜️📤 OTA из сжатого .gz (распаковка gzip на лету во flash)
// ============================================================
// FastBot2 на ESP32 пишет файл во flash как есть, без распаковки — поэтому .gz
// сам по себе прошить нельзя. Здесь мы скачиваем .gz, потоково распаковываем
// (uzlib) и пишем в Update. Скачивание тяжёлое и делает вложенный HTTP-запрос,
// поэтому его НЕЛЬЗЯ запускать прямо в колбэке newMsg (он вызывается из
// bot.tick()) — колбэк лишь запоминает file_id, а работу делает telegramGzOtaTick()
// из loop(), как и штатный OTA библиотеки откладывается на следующий tick.
static bool g_gzOtaPending = false; // запланирована распаковка+прошивка .gz
static String g_gzOtaId; // file_id принятого архива
static String g_gzOtaUser; // кому слать уведомления
#define GZ_TMP_PATH "/update.gz" // куда кладём архив на время обновления
#define GZ_OTA_ATTEMPTS 3 // попыток скачать архив (сеть/память могут подвести)
// 🔌 Источник сжатых данных для uzlib — читаем с SD-карты.
// Распаковка идёт НЕ из сети, а из файла: сетевой поток отдаёт байты рывками,
// и распаковщик ломается на паузах канала. Сначала архив целиком скачивается
// на карту штатным буферизованным writeTo() библиотеки, и только потом
// распаковывается — чтение с SD стабильно и не зависит от связи.
static File g_gzFile;
static uint8_t g_gzInBuf[512];
// uzlib в pull-модели дёргает этот колбэк за следующей порцией входа.
// Возвращает первый байт и отдаёт остаток буфера через source/source_limit.
static int gzReadCb(struct uzlib_uncomp* d) {
int n = g_gzFile.read(g_gzInBuf, sizeof(g_gzInBuf));
if (n <= 0) return -1; // конец файла
d->source = g_gzInBuf + 1;
d->source_limit = g_gzInBuf + n;
return g_gzInBuf[0];
}
// 📥 Этап 1: скачать архив с серверов Telegram на SD. Возвращает размер (0 — сбой).
// Скачивание — самое хрупкое место: на TLS-хендшейк нужен крупный непрерывный
// кусок памяти, а после долгой работы куча фрагментирована, и попытка может
// сорваться (у библиотеки это видно как «OTA Fetch Error»). Поэтому пробуем
// несколько раз с паузой — за это время память обычно освобождается.
static size_t gzOtaDownload() {
for (uint8_t attempt = 1; attempt <= GZ_OTA_ATTEMPTS; attempt++) {
fb::Fetcher fetch = bot.downloadFile(g_gzOtaId.c_str());
if (!fetch) {
LOG_W("OTA .gz: попытка %d — сервер не отдал файл (свободно %u Б)",
attempt, (unsigned)ESP.getFreeHeap());
delay(2000);
esp_task_wdt_reset();
continue;
}
fetch.setTimeout(20000); // терпимее к паузам мобильного канала
fetch.setBlockSize(1024); // блок чтения крупнее дефолтных 128 Б
SD.remove(GZ_TMP_PATH);
File f = SD.open(GZ_TMP_PATH, FILE_WRITE);
if (!f) { LOG_E("OTA .gz: не создать %s на SD", GZ_TMP_PATH); return 0; }
size_t written = fetch.writeTo(f); // штатный путь библиотеки с ожиданием данных
f.close();
esp_task_wdt_reset();
LOG_W("OTA .gz: попытка %d — скачано %u байт", attempt, (unsigned)written);
if (written) return written;
delay(2000); // связь моргнула — пробуем ещё раз
}
return 0;
}
// 🗜️ Этап 2: распаковать архив с SD прямо во flash. true — образ принят.
// Безопасно: Update пишет в НЕАКТИВНЫЙ раздел и переключает загрузку только
// при валидном образе — сбой распаковки даёт отказ OTA, а не «кирпич».
static bool gzOtaFlash(String& diag) {
g_gzFile = SD.open(GZ_TMP_PATH, FILE_READ);
if (!g_gzFile) { diag = F("не открыть архив на SD"); return false; }
uint8_t* dict = (uint8_t*)malloc(32768); // окно LZ (полный deflate)
if (!dict) { g_gzFile.close(); diag = F("нет памяти под словарь"); return false; }
uint8_t out[2048];
struct uzlib_uncomp d;
memset(&d, 0, sizeof(d));
uzlib_uncompress_init(&d, dict, 32768);
d.source = NULL;
d.source_limit = NULL;
d.source_read_cb = gzReadCb;
bool ok = false;
int res = -1;
size_t total = 0;
int hdr = uzlib_gzip_parse_header(&d);
if (hdr != TINF_OK) {
diag = String(F("плохой gzip-заголовок (")) + hdr + ")";
} else if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) {
diag = String(F("Update.begin: ")) + Update.errorString();
} else {
do {
d.dest = d.dest_start = out;
d.dest_limit = out + sizeof(out);
res = uzlib_uncompress_chksum(&d); // распаковка + сверка CRC32 в конце
if (res != TINF_OK && res != TINF_DONE) break;
size_t got = d.dest - out;
if (got) {
if (Update.write(out, got) != got) { diag = String(F("Update.write: ")) + Update.errorString(); res = -100; break; }
total += got;
}
esp_task_wdt_reset(); // 🐕 не даём watchdog сработать
yield();
} while (res == TINF_OK);
if (res == TINF_DONE) {
ok = Update.end(true);
if (!ok) diag = String(F("Update.end: ")) + Update.errorString();
} else if (!diag.length()) {
// -3 данные, -4 CRC не сошлась, -5 словарь
diag = String(F("распаковка прервана, код ")) + res;
}
if (!ok) Update.abort();
}
diag += String(F(", распаковано ")) + total + F(" Б");
LOG_W("OTA .gz: res=%d, распаковано %u, итог=%s", res, (unsigned)total, ok ? "OK" : "FAIL");
g_gzFile.close();
free(dict);
return ok;
}
// ⏱️ Тик обработки отложенного .gz-OTA — вызывать в loop() (вне колбэка tick).
void telegramGzOtaTick() {
if (!g_gzOtaPending) return;
g_gzOtaPending = false;
sendReconnectMessage(F("📥 Скачиваю архив на SD-карту..."), g_gzOtaUser);
size_t dl = gzOtaDownload();
if (!dl) {
SD.remove(GZ_TMP_PATH);
sendReconnectMessage(F("❌ Не удалось скачать архив (связь или нехватка памяти).\n"
"Попробуйте ещё раз, а если повторится — перезагрузите систему "
"и повторите отправку."), g_gzOtaUser);
return;
}
sendReconnectMessage("🗜️ Скачано " + String(dl) + " Б, распаковываю и прошиваю...", g_gzOtaUser);
String diag;
bool ok = gzOtaFlash(diag);
SD.remove(GZ_TMP_PATH); // архив больше не нужен в любом случае
if (ok) {
sendReconnectMessage(F("✅ Обновление применено, перезагрузка..."), g_gzOtaUser);
// 📮 Обязательно ДО рестарта: иначе Telegram отдаст сообщение с архивом
// снова, и устройство будет обновляться по кругу. Сохраняем позицию
// очереди в EEPROM и подтверждаем её серверу запросом getUpdates.
telegramSaveUpdateOffset();
bot.tickManual();
delay(500);
ESP.restart();
} else {
// 🔍 Диагностика в чат: логи в рабочей прошивке отключены, иначе причину не узнать
sendReconnectMessage("❌ Не удалось применить .gz: " + diag +
"\nПришлите обычный DripIrrigation.ino.bin", g_gzOtaUser);
}
}
// 📊 rm()/fileToGraf()/fileToGrafPeriod() вынесены в reports.cpp
// 🔮 Предварительное объявление обработчика сообщений (FastBot2 callback)
void newMsg(fb::Update& u);
void loadUsers();
// ============================================================
// 🚀 Инициализация Telegram бота (FastBot2)
// ============================================================
void botInit() {
// 🔐 Токен берём из botToken — он уже разрешён в systemInit
// (EEPROM → secrets.h → ввод через портал)
if (!botTokenValid(botToken)) {
LOG_E("Токен бота не задан — бот не запущен (полив работает автономно)");
return;
}
bot.setToken(botToken);
// ⏭️ Пропускаем накопившиеся сообщения.
// Важно: skipUpdates(-1) очередь НЕ чистит — по API Telegram отрицательный
// offset возвращает ПОСЛЕДНИЙ апдейт. После перезагрузки это ровно то
// сообщение, что перезагрузку и вызвало (файл прошивки) — и обновление
// зацикливалось. Поэтому продолжаем с сохранённой позиции очереди.
uint32_t savedOffset = 0;
EEPROM.begin(4096);
EEPROM.get(EEPROM_UPD_ID_ADDR, savedOffset);
EEPROM.end();
if (savedOffset && savedOffset != 0xFFFFFFFFul) {
bot.skipUpdates((int32_t)savedOffset);
LOG_I("Очередь Telegram: продолжаем с update_id %u", savedOffset);
} else {
bot.skipUpdates(); // 🆕 первый запуск — стандартное поведение
}
// 📎 Подключаем обработчик входящих обновлений (FastBot2 стиль)
bot.attachUpdate(newMsg);
bot.setPollMode(fb::Poll::Long, 60000);
// 🔌 Подключаем контроль связи с Telegram (детект разрыва/восстановления)
botMonitorAttach();
// 📥 Загружаем зарегистрированных пользователей из EEPROM
loadUsers();
LOG_I("Telegram-бот запущен (long-poll 60 с)");
}
// ============================================================
// 👥 Классы для управления пользователями и действиями
// ============================================================
// 👥 Модель пользователей (User, массив, find/add/removeUser, save/loadUsersData)
// вынесена в users.h / users.cpp. Здесь остаётся только презентация бота.
// 📡 Транспорт (sendReconnectMessage/sendStatus/dropCDCard/
// connectCDCard) вынесён в bot_transport.cpp.
// ============================================================
// 🎬 Установить текущее действие пользователя (конечный автомат)
// ============================================================
void actionSet(String userID, int action) {
User* u = findUser(userID);
if (u != nullptr) u->action = action;
}
// ============================================================
// 📥 Загрузка списка пользователей из EEPROM при старте
// ============================================================
// 📮 Последний обработанный update_id (обновляется в newMsg)
static uint32_t g_lastUpdateId = 0;
// 📮 Сохранить позицию очереди Telegram перед перезагрузкой.
// Telegram считает апдейт доставленным только после запроса со следующим
// offset. При перезагрузке сразу после команды (особенно OTA) подтверждения
// не происходит, и сообщение приходит снова — обновление зацикливается.
// Сохранённая позиция восстанавливается в botInit.
void telegramSaveUpdateOffset() {
if (!g_lastUpdateId) return;
uint32_t next = g_lastUpdateId + 1;
EEPROM.begin(4096);
EEPROM.put(EEPROM_UPD_ID_ADDR, next);
EEPROM.commit();
EEPROM.end();
LOG_I("Очередь Telegram: сохранена позиция %u", next);
}
// 🆘 Аварийное меню: единственное действие — перезагрузка. Показывается вместо
// обычного интерфейса, пока какая-то плата не отвечает.
static void showFaultMenu(fb::Update& u, const String& userID) {
fb::InlineKeyboard menu;
menu.addButton("🔄 Перезагрузка", "/Restart", fb::KeyStyle::Danger);
showMenu(u, userID, hwFaultReport(), menu);
}
// 📨 Разослать отчёт об авариях всем пользователям (один раз на поломку)
void hwReportToChat() {
if (!hwHasFault() || hwReportSent()) return;
String report = hwFaultReport();
for (uint8_t i = 0; i < userCount; i++) {
fb::InlineKeyboard menu;
menu.addButton("🔄 Перезагрузка", "/Restart", fb::KeyStyle::Danger);
fb::Message msg;
msg.text = report;
msg.chatID = users[i].userID;
msg.setModeHTML();
msg.setKeyboard(&menu);
bot.sendMessage(msg);
}
hwMarkReportSent();
LOG_W("Отчёт об авариях отправлен в чат");
}
void loadUsers() {
loadUsersData(); // 📥 данные грузим из EEPROM (модуль users)
LOG_D("Рассылка стартового меню: %d польз.", userCount);
// 🆘 Железо не в порядке — вместо обычного приветствия шлём отчёт об авариях
// с единственной кнопкой перезагрузки.
if (hwHasFault()) {
hwReportToChat();
return;
}
// 🎹 Каждому загруженному пользователю — приветствие с версией прошивки и меню.
// Версия в старте позволяет убедиться, что OTA действительно применилась.
for (uint8_t i = 0; i < userCount; i++) {
User* u = &users[i];
sendReconnectMessage("🚀 Система запущена! Прошивка v" FW_VERSION, u->userID);
fb::InlineKeyboard menu;
buildMainMenu(menu, u->role < 2);
fb::Message msg;
msg.text = "🚀 <b>Запуск</b>";
msg.chatID = u->userID;
msg.setModeHTML();
msg.setKeyboard(&menu);
bot.sendMessage(msg);
}
}
// ============================================================
// 🔍 Массив для операции поиска датчика (временные значения АЦП)
// ============================================================
int search[NUM_CHANNELS] = { 0 };
// ============================================================
// 🤖 ГЛАВНЫЙ ОБРАБОТЧИК СООБЩЕНИЙ TELEGRAM (FastBot2)
// ============================================================
void newMsg(fb::Update& u) {
// 📮 Запоминаем позицию в очереди: пригодится, если обработка команды
// закончится перезагрузкой (OTA, «Перезагрузка») — см. telegramSaveUpdateOffset
g_lastUpdateId = u.id();
// 📥 Получаем объект сообщения из обновления
fb::MessageRead msg = u.message();
// 📝 Извлекаем данные из сообщения (FastBot2 API)
String chatID = msg.chat().id().toString(); // 💬 ID чата
String userID = msg.from().id().toString(); // 👤 ID пользователя
String username = msg.from().username().toString(); // 🏷️ Имя пользователя
String text = msg.text().toString(); // 💬 Текст сообщения
// 🔘 Проверяем, является ли это callback query (нажатие inline-кнопки)
String data = "";
if (u.isQuery()) {
fb::QueryRead q = u.query();
data = q.data().toString();
userID = q.from().id().toString();
chatID = q.message().chat().id().toString();
}
// 🖨️ Отладочный вывод в Serial
LOG_D("Msg %s (%s): '%s' data='%s' [%s]", userID.c_str(), username.c_str(),
text.c_str(), data.c_str(), u.isQuery() ? "query" : "message");
// ============================================================
// 🆕 ПЕРВИЧНАЯ РЕГИСТРАЦИЯ: если нет пользователей — первый вводит кодовое слово
// ============================================================
if (userCount == 0) {
if (text == String(tstr)) {
// ✅ Кодовое слово совпало — регистрируем как владельца (роль 0)
fb::Message reply;
reply.text = "👑 Привет, владелец системы!";
reply.chatID = chatID;
reply.reply.messageID = msg.id().toInt(); // 📎 Ответ на конкретное сообщение
bot.sendMessage(reply);
addUser(userID, 0);
saveUsers();
} else {
fb::Message reply;
reply.text = "🔐 Первый запрос должен быть с ключевым словом, полученным при настройке WiFi";
reply.chatID = chatID;
reply.reply.messageID = msg.id().toInt();
bot.sendMessage(reply);
}
return;
}
// ============================================================
// 👤 ПРОВЕРКА РЕГИСТРАЦИИ ПОЛЬЗОВАТЕЛЯ
// ============================================================
if (findUser(userID) != nullptr) {
User* check_user = findUser(userID);
String command = "";
// 🔘 Если это callback query — берём data, иначе текст сообщения
if (u.isQuery()) {
command = data;
// ✅ Автоматически отвечаем на callback query (FastBot2 autoQuery по умолчанию)
} else {
command = text;
}
// ============================================================
// 🎬 ОБРАБОТКА АКТИВНЫХ ДЕЙСТВИЙ (конечный автомат диалогов)
// ============================================================
{
User* act = check_user; // 🎬 состояние диалога хранится в самом пользователе
// 🔙 Сброс действия при получении команды
if (command.startsWith("/")) {
act->action = Dlg::None;
}
// 🔧 Калибровка: этап 3 — завершение (1130–1137)
else if (act->action >= Dlg::CalibFinish && act->action <= Dlg::CalibFinish + Dlg::Last) {
int ind = act->action - Dlg::CalibFinish;
if (btnIs(text, "ЗАВЕРШИТЬ")) {
if (abs(hs.getHigh(ind) - hs.getLow(ind)) < 100) {
// kbRem=true — убираем reply-клавиатуру «ЗАВЕРШИТЬ/ОТМЕНА», иначе она
// остаётся внизу экрана после выхода из калибровки
sendReconnectMessage("❌ Ошибка калибровки датчик № " + chanLabel(ind) + " — слишком малое значение!\nОтменяем...", userID, true);
hs.setLowHighValue(ind, myConfig.chanel[ind].minVal, myConfig.chanel[ind].maxVal);
command = F("/Calibrate");
} else {
sendReconnectMessage("✅ Калибровка завершена! Датчик № " + chanLabel(ind) + " полностью функционален!", userID, true);
myConfig.chanel[ind].maxVal = hs.getHigh(ind);
myConfig.chanel[ind].minVal = hs.getLow(ind);
needUpdate = true;
}
act->action = Dlg::None;
command = F("/Calibrate");
} else {
sendReconnectMessage(F("❌ Калибровка отменена!"), userID, true);
hs.setLowHighValue(ind, myConfig.chanel[ind].minVal, myConfig.chanel[ind].maxVal);
command = F("/Calibrate");
act->action = Dlg::None;
}
}
// 🔧 Калибровка: этап 2 — сухое значение (1120–1127)
else if (act->action >= Dlg::CalibDry && act->action <= Dlg::CalibDry + Dlg::Last) {
int ind = act->action - Dlg::CalibDry;
if (btnIs(text, "ДАЛЕЕ")) {
hs.setAll();
int val = hs.setHigh(ind);
LOG_D("Сухое значение, датчик %d: %d", ind, val);
sendReconnectMessage("🌵 Установите датчик № " + chanLabel(ind) + " в почву и нажмите завершить!", userID);
fb::Keyboard kb;
kb.addButton("✅ ЗАВЕРШИТЬ").addButton("❌ ОТМЕНА");
fb::Message m;
m.text = "<Калибровка>";
m.chatID = userID;
m.setKeyboard(&kb);
bot.sendMessage(m);
act->action = Dlg::CalibFinish + ind;
return;
} else {
sendReconnectMessage(F("❌ Калибровка отменена!"), userID, true);
hs.setLowHighValue(ind, myConfig.chanel[ind].minVal, myConfig.chanel[ind].maxVal);
command = F("/Calibrate");
act->action = Dlg::None;
}
}
// 🔧 Калибровка: этап 1 — влажное значение (1110–1117)
else if (act->action >= Dlg::CalibWet && act->action <= Dlg::CalibWet + Dlg::Last) {
int ind = act->action - Dlg::CalibWet;
if (btnIs(text, "ДАЛЕЕ")) {
hs.setAll();
int val = hs.setLow(ind);
LOG_D("Влажное значение, датчик %d: %d", ind, val);
act->action = Dlg::CalibDry + ind;
sendReconnectMessage("🌵 Достаньте датчик № " + chanLabel(ind) + " из воды, протрите и нажмите далее!", userID);
fb::Keyboard kb;
kb.addButton("➡️ ДАЛЕЕ").addButton("❌ ОТМЕНА");
fb::Message m;
m.text = "<Калибровка>";
m.chatID = userID;
m.setKeyboard(&kb);
bot.sendMessage(m);
return;
} else {
sendReconnectMessage(F("❌ Калибровка отменена!"), userID, true);
hs.setLowHighValue(ind, myConfig.chanel[ind].minVal, myConfig.chanel[ind].maxVal);
command = F("/Calibrate");
act->action = Dlg::None;
}
}
// 🔧 Калибровка: этап 0 — старт (1100–1107)
else if (act->action >= Dlg::CalibStart && act->action <= Dlg::CalibStart + Dlg::Last) {
if (btnIs(text, "СТАРТ")) {
int ind = act->action - Dlg::CalibStart;
sendReconnectMessage("💧 Положите датчик № " + chanLabel(ind) + " в воду и нажмите далее!", userID);
fb::Keyboard kb;
kb.addButton("➡️ ДАЛЕЕ").addButton("❌ ОТМЕНА");
fb::Message m;
m.text = "<Калибровка>";
m.chatID = userID;
m.setKeyboard(&kb);
bot.sendMessage(m);
act->action = Dlg::CalibWet + ind;
return;
} else {
sendReconnectMessage(F("❌ Калибровка отменена!"), userID, true);
command = F("/Calibrate");
act->action = Dlg::None;
}
}
// 🔍 Поиск датчика: этап 0 — старт (3000)
else if (act->action == Dlg::SearchStart) {
if (btnIs(text, "СТАРТ")) {
sendReconnectMessage("💧 Положите датчик в воду и нажмите далее!", userID);
fb::Keyboard kb;
kb.addButton("➡️ ДАЛЕЕ").addButton("❌ ОТМЕНА");
fb::Message m;
m.text = "<Поиск>";
m.chatID = userID;
m.setKeyboard(&kb);
bot.sendMessage(m);
act->action = Dlg::SearchWet;
return;
} else {
sendReconnectMessage(F("❌ Поиск отменён!"), userID, true);
command = F("/control");
act->action = Dlg::None;
}
}
// 🔍 Поиск датчика: этап 1 — влажное значение (3010)
else if (act->action == Dlg::SearchWet) {
if (btnIs(text, "ДАЛЕЕ")) {
hs.setAll();
for (int i = 0; i < NUM_CHANNELS; i++) {
search[i] = hs.getCurrent(i);
}
sendReconnectMessage("🌵 Достаньте датчик из воды, протрите и нажмите далее!", userID);
fb::Keyboard kb;
kb.addButton("➡️ ДАЛЕЕ").addButton("❌ ОТМЕНА");
fb::Message m;
m.text = "<Поиск>";
m.chatID = userID;
m.setKeyboard(&kb);
bot.sendMessage(m);
act->action = Dlg::SearchDetect;
return;
} else {
sendReconnectMessage(F("❌ Поиск отменён!"), userID, true);
command = F("/control");
act->action = Dlg::None;
}
}
// 🔍 Поиск датчика: этап 2 — определение (3020)
else if (act->action == Dlg::SearchDetect) {
if (btnIs(text, "ДАЛЕЕ")) {
hs.setAll();
int ind = -1;
for (int i = 0; i < NUM_CHANNELS; i++) {
if (abs(search[i] - hs.getCurrent(i)) > 300) {
ind = i;
break;
}
}
if (ind < 0) {
sendReconnectMessage(F("❌ Не удалось определить датчик! Повторите операцию."), userID, true);
} else {
sendReconnectMessage("🔍 Предположительно ваш датчик № " + String(ind + 1) + " (" + myConfig.chanel[ind].title + ")!", userID, true);
}
} else {
sendReconnectMessage(F("❌ Поиск отменён!"), userID, true);
}
command = F("/control");
act->action = Dlg::None;
}
// 🎯 Установка порога влажности (1400–1407)
else if (act->action >= Dlg::Border && act->action <= Dlg::Border + Dlg::Last) {
int ind = act->action - Dlg::Border;
String input = String(text);
if (isNumeric(input)) {
int num = input.toInt();
if (num >= 0 && num <= 100) {
act->action = Dlg::None;
myConfig.chanel[ind].border = num;
needUpdate = true;
sendReconnectMessage("✅ Порог клапана № " + String(ind + 1) + " установлен: " + String(num) + " %", userID);
command = "/Borders";
} else {
sendReconnectMessage(F("❌ Ожидалось значение (от 0 до 100) % !"), userID);
return;
}
}
}
// 📝 Переименование датчика (1200–1207)
else if (act->action >= Dlg::Rename && act->action <= Dlg::Rename + Dlg::Last) {
int ind = act->action - Dlg::Rename;
// 🛡️ text — произвольный ввод из Telegram (до 4096 байт), а title — буфер
// фиксированного размера char[90]: strcpy без ограничения переполнил бы его
// и повредил соседние поля Config. Также убираем запятую/перевод строки
// (ломают формат CSV-логов, reports.cpp разбирает их по запятой) и
// < > & (title потом вставляется в HTML-режимные сообщения бота — сырые
// угловые скобки/амперсанд ломают разбор HTML на стороне Telegram).
String safe = text;
safe.replace(",", " ");
safe.replace("\n", " ");
safe.replace("\r", " ");
safe.replace("<", "‹");
safe.replace(">", "›");
safe.replace("&", "и");
strncpy(myConfig.chanel[ind].title, safe.c_str(), sizeof(myConfig.chanel[ind].title) - 1);
myConfig.chanel[ind].title[sizeof(myConfig.chanel[ind].title) - 1] = '\0';
command = F("/Namings");
act->action = Dlg::None;
needUpdate = true;
sendReconnectMessage("✅ Датчик № " + String(ind + 1) + " переименован: " + safe, userID);
}
// 🚰 Установка режима работы клапана (1300–1307)
else if (act->action >= Dlg::ModeSet && act->action <= Dlg::ModeSet + Dlg::Last) {
int ind = act->action - Dlg::ModeSet;
if (btnIs(text, "ВКЛ.")) {
sendReconnectMessage(F("✅ Клапан включён!"), userID, true);
myConfig.chanel[ind].mode = Mode::AlwaysOn;
valveOpen(ind);
} else if (btnIs(text, "ВЫКЛ.")) {
sendReconnectMessage(F("⛔ Клапан выключен!"), userID, true);
myConfig.chanel[ind].mode = Mode::AlwaysOff;
valveClose(ind);
} else if (btnIs(text, "АВТО")) {
sendReconnectMessage(F("🤖 Клапан в автоматическом режиме!"), userID, true);
myConfig.chanel[ind].mode = Mode::Auto;
} else if (btnIs(text, "А.П.")) {
sendReconnectMessage(F("🏠 Клапан в автоматическом режиме для парника!"), userID, true);
myConfig.chanel[ind].mode = Mode::Greenhouse;
}
needUpdate = true;
act->action = Dlg::None;
command = F("/OperationMode");
}
// 🚰 Установка режима для ВСЕХ клапанов (1399)
else if (act->action == Dlg::ModeSetAll) {
uint8_t md = Mode::Auto;
if (btnIs(text, "ВКЛ.")) {
sendReconnectMessage(F("✅ Клапаны включены!"), userID, true);
md = Mode::AlwaysOn;
} else if (btnIs(text, "ВЫКЛ.")) {
sendReconnectMessage(F("⛔ Клапаны выключены!"), userID, true);
md = Mode::AlwaysOff;
} else if (btnIs(text, "АВТО")) {
sendReconnectMessage(F("🤖 Клапаны в автоматическом режиме!"), userID, true);
md = Mode::Auto;
} else if (btnIs(text, "А.П.")) {
sendReconnectMessage(F("🏠 Клапаны в автоматическом режиме для парника!"), userID, true);
md = Mode::Greenhouse;
}
for (int l = 0; l < NUM_CHANNELS; l++) {
myConfig.chanel[l].mode = md;
if (md == Mode::AlwaysOn) {
valveOpen(l);
}
if (md == Mode::AlwaysOff) {
valveClose(l);
}
}
needUpdate = true;
act->action = Dlg::None;
command = F("/OperationMode");
}
// 🔧 Ручная калибровка (1800–1807)
else if (act->action >= Dlg::CalibManual && act->action <= Dlg::CalibManual + Dlg::Last) {
int ind = act->action - Dlg::CalibManual;
String minvs = getValue(text, ',', 0);
String maxvs = getValue(text, ',', 1);
if (isNumeric(minvs) && isNumeric(maxvs)) {
int minv = minvs.toInt();
int maxv = maxvs.toInt();
if (minv >= 0 && minv <= 4096 && maxv >= 0 && maxv <= 4096) {
act->action = Dlg::None;
hs.setLowHighValue(ind, minv, maxv);
command = "/CalibrateManual";
myConfig.chanel[ind].maxVal = hs.getHigh(ind);
myConfig.chanel[ind].minVal = hs.getLow(ind);
needUpdate = true;
sendReconnectMessage("✅ Калибровка датчика № " + chanLabel(ind) + " сохранена (мин " + String(minv) + ", макс " + String(maxv) + ")", userID);
} else {
sendReconnectMessage(F("❌ Ожидалось значение (от 0 до 4096)!"), userID);
return;
}
} else {
sendReconnectMessage(F("❌ Ожидалось значение в формате (целое,целое)!"), userID);
return;
}
}
// 🔄 Сброс настроек (2000)
else if (act->action == Dlg::Reset) {
act->action = Dlg::None;
if (btnIs(text, "ДА")) {
myConfig.deltaCalibration = 15;
myConfig.deltaHum = 5;
myConfig.runOnNight = false;
myConfig.runOnRain = true;
myConfig.boostPumpValves = 9; // 💪 насос давления по умолчанию выключен
myConfig.clogThresholdPercent = 50; // 🧽 порог контроля фильтра
myConfig.flowMonitorEnabled = true; // 🧽 контроль фильтра включён
myConfig.tgTimeoutSec = TG_TIMEOUT_DEFAULT; // 🔌 таймаут связи Telegram
myConfig.autoCalNoDelay = false; // 🔧 авто-калибровка с подтверждением (безопасный режим)
myConfig.cleanFlowPerValve = 0.0; // 🧽 сброс эталона фильтра (на клапан)
myConfig.cleanFlowDrain = 0.0; // 🧽 сброс эталона фильтра (дренаж)
hs.setBorder(myConfig.deltaCalibration);
for (int i = 0; i < NUM_CHANNELS; i++) {
myConfig.chanel[i].border = 60;
myConfig.chanel[i].maxVal = 1024;
myConfig.chanel[i].minVal = 1024;
myConfig.chanel[i].mode = Mode::Auto;
strcpy(myConfig.chanel[i].title, String("🌱 Растение").c_str());
// 🔧 Синхронизируем датчики с конфигом: раньше сбрасывался только
// myConfig, а объект hs держал в RAM СТАРЫЕ границы — проценты
// считались по ним до перезагрузки, а авто-калибровка могла записать
// эти старые границы обратно в конфиг, отменив сброс.
hs.setLowHighValue(i, myConfig.chanel[i].minVal, myConfig.chanel[i].maxVal);
}
LOG_W("Сброс настроек к значениям по умолчанию (выполнил %s)", username.c_str());
sendReconnectMessage(F("✅ Сброс выполнен!"), userID, true);
} else {
sendReconnectMessage(F("❌ Сброс отменён!"), userID, true);
}
command = "/configure";
needUpdate = true;
}
// 🔄 Перезагрузка (1)
else if (act->action == Dlg::Restart) {
act->action = Dlg::None;
if (btnIs(text, "ДА")) {
LOG_W("Запрошена перезагрузка устройства (%s)", username.c_str());
res = 1;
sendReconnectMessage(F("🔄 Перезагрузка начата!"), userID, true);
return;
} else {
sendReconnectMessage(F("❌ Перезагрузка отменена!"), userID, true);
return;
}
}
// Стереь все данные по проливу
else if (act->action == Dlg::ClearFlow) {
act->action = Dlg::None;
if (btnIs(text, "ДА")) {
clearDataFlow();
sendReconnectMessage(F("🔄 Данные стерты!"), userID, true);
return;
} else {
sendReconnectMessage(F("❌ Отмена!"), userID, true);
return;
}
}
// ⚡ Авто-калибровка без задержки (1009)
else if (act->action == Dlg::AutoCalNoDly) {
act->action = Dlg::None;
if (btnIs(text, "ДА")) {
myConfig.autoCalNoDelay = true;
sendReconnectMessage(F("⚡ Авто-калибровка без задержки включена — граница сдвигается сразу по первому замеру за ней (выбросы АЦП не фильтруются)."), userID, true);
} else {
myConfig.autoCalNoDelay = false;
sendReconnectMessage("🛡️ Авто-калибровка с подтверждением — граница сдвигается, только если превышение держится " + String(HUM_AUTOCAL_CONFIRM) + " замера подряд (" + String(HUM_AUTOCAL_CONFIRM) + " мин).", userID, true);
}
command = "/configure";
needUpdate = true;
}
// 🌙 Работа ночью (1001)
else if (act->action == Dlg::WorkNight) {
act->action = Dlg::None;
if (btnIs(text, "ДА")) {
myConfig.runOnNight = true;
sendReconnectMessage(F("🌙 Работа ночью включена!"), userID, true);
} else {
myConfig.runOnNight = false;
sendReconnectMessage(F("🌙 Работа ночью выключена!"), userID, true);
}
command = "/configure";
needUpdate = true;
}
// 🌧️ Работа под дождём (1002)
else if (act->action == Dlg::WorkRain) {
act->action = Dlg::None;
if (btnIs(text, "ДА")) {
myConfig.runOnRain = true;
sendReconnectMessage(F("🌧️ Работа под дождём включена!"), userID);
} else {
myConfig.runOnRain = false;
sendReconnectMessage(F("🌧️ Работа под дождём выключена!"), userID);
}
command = "/configure";
needUpdate = true;
}
// 📁 Отправка файла по дате (5000)
else if (act->action == Dlg::SendFile) {
String input = String(text);
String sd = getValue(input, '.', 0);
String sm = getValue(input, '.', 1);
String sy = getValue(input, '.', 2);
String fn = String("/") + sy + String("/") + sm + String("/") + sd + String(".csv");
if (SD.exists(fn)) {
File file = SD.open(fn, FILE_READ);
if (!file) {
sendReconnectMessage(F("❌ Файл не открывается"), userID);
LOG_W("Не удалось открыть файл");
} else {
sendReconnectMessage(F("📁 Файл открывается, ждите ..."), userID);
fn.replace("/", "_");
fb::File fmsg(fn, fb::File::Type::document, file);
fmsg.chatID = chatID;
bot.sendFile(fmsg);
}
file.close();
return;
} else {
sendReconnectMessage(F("❌ Файл не найден"), userID);
command = "/reports";
}
act->action = Dlg::None;
}
// 📊 График за период (5200)
else if (act->action == Dlg::GraphPeriod) {
String input = String(text);
if (isNumeric(input)) {
int num = input.toInt();
if (num >= 1 && num <= 60) {
act->action = Dlg::None;
fileToGrafPeriod(num, userID);
command = "/Graphics";
} else {
sendReconnectMessage(F("❌ Ожидалось значение (от 0 до 60)!"), userID);
return;
}