/****************************************************
* MASTER ESP32 for STALKER "Emission" Controller
* Перевірено під Arduino-ESP32 2.x
*
* Залежності (Library Manager):
* - RTClib by Adafruit (DS3231)
* - LiquidCrystal I2C by Frank de Brabander (або аналог)
* - Keypad by Mark Stanley, Alexander Brevig
*
* Апаратура:
* - ESP32
* - DS3231 (I2C SDA=21, SCL=22)
* - LCD1602 I2C (адреса 0x27 за замовч.)
* - 4x4 Keypad (див. піни нижче)
* - Пасивний бузер (tone-пін нижче)
****************************************************/
#include <Arduino.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_now.h>
#include <Wire.h>
#include <RTClib.h>
#include <LiquidCrystal_I2C.h>
#include <Keypad.h>
#include <esp_idf_version.h>
// ---------------------- НАЛАШТУВАННЯ ----------------------
#define LCD_ADDR 0x27
#define LCD_COLS 16
#define LCD_ROWS 2
#define BUZZER_PIN 25
// 4x4 Keypad — під свої піни
const byte ROWS = 4, COLS = 4;
byte rowPins[ROWS] = {19, 18, 5, 17};
byte colPins[COLS] = {16, 4, 15, 2};
char keys[ROWS][COLS] = {
{'1','2','3','A'},
{'4','5','6','B'},
{'7','8','9','C'},
{'*','0','#','D'}
};
// Мережа
#define NET_CH 6 // фіксований канал для всієї системи
#define MASTER_ID 0
static const uint8_t BCAST[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF};
// Ліміти/константи
#define MAX_NODES 150 // максимально підтримуємо
#define BASE_SSID_MAX 16 // довжина базового SSID (без суфіксів)
#define FULL_SSID_MAX 31 // загальний SSID ≤31
#define MAX_EVENTS 10
#define POLL_INTERVAL_MS 5000UL // як часто опитуємо вузли
#define OFFLINE_MS 30000UL // після цього вважаємо «мовчуном»
#define MSG_TTL_DEFAULT 20
#define NET_VER 1
// ---------------------- ДЕВАЙСИ ----------------------
LiquidCrystal_I2C lcd(LCD_ADDR, LCD_COLS, LCD_ROWS);
RTC_DS3231 rtc;
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
// ---------------------- СТРУКТУРИ ----------------------
#pragma pack(push,1)
enum MsgType : uint8_t {
CMD_SET_SSID=1, CMD_SLEEP=2, CMD_STATUS_REQ=3, ACK_STATUS=4, CMD_WAKE=5
};
struct MsgHdr {
uint8_t ver; // NET_VER
uint8_t type; // MsgType
uint16_t origin_id; // хто створив
uint16_t target_id; // кому адресовано (або 0 для MASTER у ACK)
uint32_t seq; // лічильник у джерела
uint8_t ttl; // time-to-live
uint8_t hop; // для відладки
uint16_t len; // довжина payload
uint32_t crc; // CRC32 header+payload (без цього поля)
};
struct PayloadSetSsid {
char ssid[FULL_SSID_MAX+1]; // \0-terminated
uint8_t channel; // має дорівнювати NET_CH
};
struct PayloadSleep {
char neutral[FULL_SSID_MAX+1]; // на який SSID перемкнутись
uint32_t sleepMs; // час «сну» (може 0 = до пробудження)
};
struct PayloadStatusReq {
uint16_t want_id_from; // 1..networkSize, щоб вузли >N не відповідали
uint16_t want_id_to;
};
struct PayloadAckStatus {
uint16_t node_id;
char ssid[FULL_SSID_MAX+1]; // поточний SSID вузла
int8_t rssi; // якщо вузол його виміряв (може 0)
};
#pragma pack(pop)
// Конфіг вузла
struct NodeConfig {
bool exists = false; // входить до мережі (1..networkSize)
bool deactivated = false; // у «вимкнених»
bool rad = false; // прапор радіації (_RAD)
bool pendingAfterEmission = false; // відкладені зміни
char baseSsid[BASE_SSID_MAX+1] = "GRV"; // базовий тег
// що застосувати після викиду:
char pendingBase[BASE_SSID_MAX+1] = "";
bool pendingRad = false;
// телеметрія:
char lastReported[FULL_SSID_MAX+1] = "";
uint32_t lastSeenMs = 0;
int8_t lastRssi = 0;
};
// Розклад
struct EmissionEvent {
bool active = false;
DateTime start;
uint32_t durationSec = 0;
};
// ---------------------- ГЛОБАЛЬНІ ----------------------
uint16_t networkSize = 10; // користувацьке обмеження 1..MAX_NODES
NodeConfig nodes[MAX_NODES+1]; // 0..MAX_NODES (0 — сам «головний»)
EmissionEvent schedule[MAX_EVENTS]; // розклад викидів
bool emissionNow = false; // чи триває викид
DateTime emissionEnd; // коли закінчиться поточний
uint32_t g_seq = 0;
uint32_t lastPoll = 0;
// ---------------------- УТИЛІТИ ----------------------
void beep(uint16_t f=2000, uint16_t ms=40) {
tone(BUZZER_PIN, f, ms);
}
uint32_t crc32_update(uint32_t crc, uint8_t data) {
crc ^= data;
for (uint8_t i = 0; i < 8; i++) crc = (crc >> 1) ^ (0xEDB88320 & (-(int)(crc & 1)));
return crc;
}
uint32_t crc32_buf(const uint8_t* p, size_t n) {
uint32_t crc = 0xFFFFFFFF;
for (size_t i=0;i<n;i++) crc = crc32_update(crc, p[i]);
return ~crc;
}
String dtToStr(const DateTime& t) {
char buf[20];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d",
t.year(), t.month(), t.day(), t.hour(), t.minute());
return String(buf);
}
void lcdCentered(uint8_t row, const String& s) {
String t = s;
if (t.length() > LCD_COLS) t = t.substring(0, LCD_COLS);
int pad = (LCD_COLS - t.length())/2;
lcd.setCursor(max(0,pad), row);
lcd.print(t);
}
void lcdMsg(const String& a, const String& b="") {
lcd.clear();
lcd.setCursor(0,0); lcd.print(a.substring(0,LCD_COLS));
lcd.setCursor(0,1); lcd.print(b.substring(0,LCD_COLS));
}
// Формування повного SSID (база + _RAD, + _EMI якщо викид)
void buildFullSsid(uint16_t id, char out[FULL_SSID_MAX+1], bool emiFlag) {
const NodeConfig& n = nodes[id];
String s = String(n.baseSsid);
if (n.rad) s += "_RAD";
if (emiFlag) s += "_EMI";
s.toCharArray(out, FULL_SSID_MAX+1);
}
// ---------------------- ESPNOW ----------------------
// Прототипи (поза будь-якими функціями!)
#if ESP_IDF_VERSION_MAJOR >= 5
void espnowOnRecv(const esp_now_recv_info_t* info, const uint8_t* data, int len);
#else
void espnowOnRecv(const uint8_t* mac, const uint8_t* data, int len);
#endif
void espnowOnSent(const uint8_t* mac, esp_now_send_status_t status);
// Ініціалізація
bool espnowInit() {
// AP+STA на одному каналі
WiFi.mode(WIFI_AP_STA);
WiFi.softAP("SHELTER", "", NET_CH, 0, 1);
esp_wifi_set_channel(NET_CH, WIFI_SECOND_CHAN_NONE);
if (esp_now_init() != ESP_OK) return false;
// Реєструємо колбеки (функції вже оголошені прототипами вище)
esp_now_register_recv_cb(espnowOnRecv);
esp_now_register_send_cb(espnowOnSent);
// Широкомовний peer (FF:FF:FF:FF:FF:FF)
esp_now_peer_info_t peer{};
memcpy(peer.peer_addr, BCAST, 6);
peer.channel = NET_CH;
peer.ifidx = WIFI_IF_STA;
peer.encrypt = false;
if (esp_now_add_peer(&peer) != ESP_OK) {
// якщо вже існує — видалити і додати знову
esp_now_del_peer(BCAST);
esp_now_add_peer(&peer);
}
return true;
}
// Відправка
bool espnowSend(const uint8_t* buf, size_t len) {
return esp_now_send(BCAST, buf, len) == ESP_OK;
}
void sendPacket(uint8_t type, uint16_t target, const void* payload, uint16_t plen, uint8_t ttl=MSG_TTL_DEFAULT) {
uint8_t pkt[sizeof(MsgHdr) + 256];
if (plen > 256) return;
MsgHdr* h = (MsgHdr*)pkt;
h->ver = NET_VER;
h->type = type;
h->origin_id = MASTER_ID;
h->target_id = target;
h->seq = ++g_seq;
h->ttl = ttl;
h->hop = 0;
h->len = plen;
memset(&h->crc, 0, sizeof(h->crc));
if (plen) memcpy(pkt + sizeof(MsgHdr), payload, plen);
h->crc = crc32_buf(pkt, sizeof(MsgHdr)-sizeof(uint32_t) + plen);
for (int i=0;i<3;i++) { espnowSend(pkt, sizeof(MsgHdr)+plen); delay(6 + random(0,8)); }
}
void cmdSetSsid(uint16_t id, const char* fullSsid) {
PayloadSetSsid p{}; strlcpy(p.ssid, fullSsid, sizeof(p.ssid)); p.channel = NET_CH;
sendPacket(CMD_SET_SSID, id, &p, sizeof(p));
}
void cmdSleep(uint16_t id, const char* neutralSsid, uint32_t sleepMs) {
PayloadSleep p{}; strlcpy(p.neutral, neutralSsid, sizeof(p.neutral)); p.sleepMs = sleepMs;
sendPacket(CMD_SLEEP, id, &p, sizeof(p));
}
void cmdWake(uint16_t id) { sendPacket(CMD_WAKE, id, nullptr, 0); }
void broadcastStatusReq(uint16_t fromId, uint16_t toId) {
PayloadStatusReq p{}; p.want_id_from = fromId; p.want_id_to = toId;
sendPacket(CMD_STATUS_REQ, 0xFFFF, &p, sizeof(p));
}
// Колбеки
#if ESP_IDF_VERSION_MAJOR >= 5 // Arduino-ESP32 3.x (IDF5)
void espnowOnRecv(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
// якщо треба — MAC джерела тут:
// const uint8_t* mac = info ? info->src_addr : nullptr;
// int rssi = (info && info->rx_ctrl) ? info->rx_ctrl->rssi : 0;
if (len < (int)sizeof(MsgHdr)) return;
MsgHdr h; memcpy(&h, data, sizeof(MsgHdr));
if (h.ver != NET_VER) return;
if (sizeof(MsgHdr)-sizeof(uint32_t) + h.len != (uint16_t)len) return;
uint32_t got = ((MsgHdr*)data)->crc;
((MsgHdr*)data)->crc = 0;
if (crc32_buf(data, len) != got) return;
const uint8_t* pl = data + sizeof(MsgHdr);
if (h.type == ACK_STATUS && h.len == sizeof(PayloadAckStatus)) {
const PayloadAckStatus* s = (const PayloadAckStatus*)pl;
uint16_t id = s->node_id;
if (id <= MAX_NODES) {
nodes[id].lastSeenMs = millis();
nodes[id].lastRssi = s->rssi; // або взяти з info->rx_ctrl->rssi
strlcpy(nodes[id].lastReported, s->ssid, sizeof(nodes[id].lastReported));
}
}
}
#else // Arduino-ESP32 2.x (IDF4)
void espnowOnRecv(const uint8_t* mac, const uint8_t* data, int len) {
if (len < (int)sizeof(MsgHdr)) return;
MsgHdr h; memcpy(&h, data, sizeof(MsgHdr));
if (h.ver != NET_VER) return;
if (sizeof(MsgHdr)-sizeof(uint32_t) + h.len != (uint16_t)len) return;
uint32_t got = ((MsgHdr*)data)->crc;
((MsgHdr*)data)->crc = 0;
if (crc32_buf(data, len) != got) return;
const uint8_t* pl = data + sizeof(MsgHdr);
if (h.type == ACK_STATUS && h.len == sizeof(PayloadAckStatus)) {
const PayloadAckStatus* s = (const PayloadAckStatus*)pl;
uint16_t id = s->node_id;
if (id <= MAX_NODES) {
nodes[id].lastSeenMs = millis();
nodes[id].lastRssi = s->rssi;
strlcpy(nodes[id].lastReported, s->ssid, sizeof(nodes[id].lastReported));
}
}
}
#endif
void espnowOnSent(const uint8_t* mac, esp_now_send_status_t status) {
// за бажанням — лог/LED
}
// ---------------------- «БІЗНЕС-ЛОГІКА» ----------------------
void applyNodeNow(uint16_t id) {
if (id > networkSize) return; // обмеження мережі
if (!nodes[id].exists) return;
if (nodes[id].deactivated) return;
char ssid[FULL_SSID_MAX+1];
buildFullSsid(id, ssid, emissionNow);
cmdSetSsid(id, ssid);
}
void applyAllNow() {
for (uint16_t id=0; id<=networkSize; ++id) applyNodeNow(id);
}
void deactivateNode(uint16_t id, const char* neutral="OFF") {
if (id > networkSize) return;
nodes[id].deactivated = true;
cmdSetSsid(id, neutral);
cmdSleep(id, neutral, 0); // 0 => «до пробудження»
}
void activateNode(uint16_t id) {
if (id > networkSize) return;
nodes[id].deactivated = false;
cmdWake(id);
applyNodeNow(id);
}
void startEmission(uint32_t durationSec) {
if (emissionNow) return;
emissionNow = true;
emissionEnd = rtc.now() + TimeSpan(durationSec);
// додати _EMI всім активним
for (uint16_t id=0; id<=networkSize; ++id) {
if (nodes[id].exists && !nodes[id].deactivated) {
char ssid[FULL_SSID_MAX+1];
buildFullSsid(id, ssid, true);
cmdSetSsid(id, ssid);
}
}
lcdMsg("VYBROS: START", String("to ") + dtToStr(emissionEnd));
beep(2500,120);
}
void stopEmissionAndApplyPending() {
if (!emissionNow) return;
emissionNow = false;
// зняти _EMI і застосувати відкладені
for (uint16_t id=0; id<=networkSize; ++id) {
if (!nodes[id].exists || nodes[id].deactivated) continue;
if (nodes[id].pendingAfterEmission) {
strlcpy(nodes[id].baseSsid, nodes[id].pendingBase, sizeof(nodes[id].baseSsid));
nodes[id].rad = nodes[id].pendingRad;
nodes[id].pendingAfterEmission = false;
}
char ssid[FULL_SSID_MAX+1];
buildFullSsid(id, ssid, false);
cmdSetSsid(id, ssid);
}
lcdMsg("VYBROS: STOP", "");
beep(1500,120);
}
void tickScheduler() {
DateTime now = rtc.now();
// якщо триває — перевірити закінчення
if (emissionNow && now >= emissionEnd) {
stopEmissionAndApplyPending();
}
// якщо не триває — чи стартує щось зі списку
if (!emissionNow) {
for (int i=0;i<MAX_EVENTS;i++) {
if (!schedule[i].active) continue;
// старт рівно в хвилину (без секунд) — просте порівняння
DateTime s = schedule[i].start;
if (now.year()==s.year() && now.month()==s.month() && now.day()==s.day() &&
now.hour()==s.hour() && now.minute()==s.minute()) {
startEmission(schedule[i].durationSec);
break;
}
}
}
}
void periodicPoll() {
uint32_t ms = millis();
if (ms - lastPoll >= POLL_INTERVAL_MS) {
lastPoll = ms;
broadcastStatusReq(1, networkSize);
}
}
// ---------------------- МЕНЮ / UX ----------------------
// Прості екрани: # = ОК/Enter, * = Назад, A/B/C/D = режимні клавіші
enum Screen {
SCR_HOME=0, SCR_TIME, SCR_SET_TIME, SCR_NET_SIZE,
SCR_NODE_SELECT, SCR_NODE_EDIT, SCR_NODE_DEACT,
SCR_SCHEDULE, SCR_SCHEDULE_ADD, SCR_SCHEDULE_LIST,
SCR_EMISSION_CTRL, SCR_STATUS
};
Screen scr = SCR_HOME;
uint16_t uiNodeSel = 1; // поточний вузол у налаштуваннях
// Ввід числа через клавіатуру (блокуюче, але просте)
long inputNumber(const String& prompt, int maxDigits) {
String buf="";
lcdMsg(prompt, "PRINT: #=OK");
while (true) {
char k = keypad.getKey();
if (!k) { yield(); continue; }
if (k>='0' && k<='9') {
if ((int)buf.length() < maxDigits) {
buf += k; beep();
lcd.setCursor(0,1);
String shown = "PRINT: " + buf;
lcd.print(String(shown + String(' ', LCD_COLS)).substring(0, LCD_COLS));
}
} else if (k=='#') { beep(); break; }
else if (k=='*') { if (buf.length()) { buf.remove(buf.length()-1); beep(); } }
}
if (buf.length()==0) return -1;
return buf.toInt();
}
String inputText(const String& prompt, int maxLen) {
String buf="";
lcdMsg(prompt, "A..D: - _ / .");
while (true) {
char k = keypad.getKey();
if (!k) { yield(); continue; }
if ((k>='0' && k<='9') || (k>='A' && k<='D')) {
if ((int)buf.length() < maxLen) {
char c = k;
if (k=='A') c='-';
else if (k=='B') c='_';
else if (k=='C') c='/';
else if (k=='D') c='.';
buf += c; beep();
lcd.setCursor(0,1);
String shown = buf;
lcd.print(String(shown + String(' ', LCD_COLS)).substring(0, LCD_COLS));
}
} else if (k=='#') { beep(); break; }
else if (k=='*') { if (buf.length()) { buf.remove(buf.length()-1); beep(); } }
}
return buf;
}
void drawHome() {
DateTime now = rtc.now();
String l0 = dtToStr(now);
String l1 = String("N=") + networkSize + (emissionNow ? " VYBROS!" : " DONE");
lcdMsg(l0, l1);
}
void drawStatus() {
// показуємо 2 рядками по 1 вузлу (прокрутка A/B)
static uint16_t idx = 0;
if (idx < 1) idx = 1;
if (idx > networkSize) idx = networkSize;
for (;;) {
NodeConfig &n = nodes[idx];
String a = String("#") + idx + (n.deactivated ? " [OFF] " : " ");
if (millis() - n.lastSeenMs > OFFLINE_MS) a += "??";
else a += "OK";
String ss = String(n.lastReported[0] ? n.lastReported : "(UNKNOW)");
lcdMsg(a, ss);
// навігація
uint32_t t0 = millis();
while (millis()-t0 < 3000) {
char k = keypad.getKey(); if (!k) { yield(); continue; }
if (k=='A' && idx>1) { idx--; beep(); break; }
if (k=='B' && idx<networkSize) { idx++; beep(); break; }
if (k=='*') { scr = SCR_HOME; beep(); return; }
}
if (scr != SCR_STATUS) return;
}
}
void menuLoop() {
char k = keypad.getKey();
if (!k) return;
if (scr == SCR_HOME) {
if (k=='A') { scr = SCR_STATUS; beep(); }
else if (k=='B') { scr = SCR_NET_SIZE; beep(); lcdMsg("MODULE NUM:", String(networkSize)); }
else if (k=='C') { scr = SCR_NODE_SELECT; beep(); lcdMsg("MODUL (#):", String(uiNodeSel)); }
else if (k=='D') { scr = SCR_EMISSION_CTRL; beep(); lcdMsg("A:START B:STOP", "C:TIMING D:SET TIME"); }
else if (k=='#') { /* нічого */ }
}
else if (scr == SCR_STATUS) {
// показ обробляється у drawStatus()
}
else if (scr == SCR_NET_SIZE) {
if (k=='#') {
long v = inputNumber("N (1..150)", 3);
if (v >= 1 && v <= MAX_NODES) {
networkSize = (uint16_t)v;
for (uint16_t i=0;i<=networkSize;i++) nodes[i].exists = true;
lcdMsg("SAVED", String("N=")+networkSize);
} else lcdMsg("ERROR", "1..150");
delay(800); scr = SCR_HOME;
}
else if (k=='*') { scr = SCR_HOME; beep(); }
}
else if (scr == SCR_NODE_SELECT) {
if (k=='#') {
long v = inputNumber("MODULE ID (0..N)", 3);
if (v >= 0 && v <= networkSize) { uiNodeSel = (uint16_t)v; scr = SCR_NODE_EDIT; }
else { lcdMsg("ERROR", "0..N"); delay(800); scr = SCR_HOME; }
} else if (k=='*') { scr = SCR_HOME; beep(); }
}
else if (scr == SCR_NODE_EDIT) {
// Меню редагування: A — базовий SSID, B — RAD on/off, C — Застосувати/Відкласти, D — Деактивація
NodeConfig &n = nodes[uiNodeSel];
lcdMsg(String("#")+uiNodeSel+" "+(n.rad?"RAD ":"—")+" "+(n.deactivated?"OFF":"ON"),
"A:SSID B:RAD C:OK D:OFF");
if (k=='A') {
String s = inputText("BASIC SSID:", BASE_SSID_MAX);
if (s.length()) strlcpy(n.baseSsid, s.c_str(), sizeof(n.baseSsid));
lcdMsg("OK", n.baseSsid); delay(600);
} else if (k=='B') {
n.rad = !n.rad; beep();
} else if (k=='C') {
// Застосувати: якщо зараз Викид — запитати «після викиду?»
if (emissionNow) {
lcdMsg("AFTER VYBROS?", "A:YES B:NO->NOW");
for (;;) {
char kk = keypad.getKey(); if (!kk) { yield(); continue; }
if (kk=='A') { // відкласти
strlcpy(n.pendingBase, n.baseSsid, sizeof(n.pendingBase));
n.pendingRad = n.rad;
n.pendingAfterEmission = true;
lcdMsg("AFTER", "END VYBROS"); delay(800);
break;
} else if (kk=='B') {
applyNodeNow(uiNodeSel);
lcdMsg("RENAME", "NOW"); delay(600);
break;
} else if (kk=='*') break;
}
} else {
applyNodeNow(uiNodeSel);
lcdMsg("RENAME", "NOW"); delay(600);
}
} else if (k=='D') {
scr = SCR_NODE_DEACT; beep();
lcdMsg("D:OFF C:ON", "*:back");
} else if (k=='*') { scr = SCR_HOME; beep(); }
}
else if (scr == SCR_NODE_DEACT) {
if (k=='D') {
deactivateNode(uiNodeSel, "OFF");
lcdMsg("MODULE OFF", String("#")+uiNodeSel); delay(700);
scr = SCR_HOME;
} else if (k=='C') {
activateNode(uiNodeSel);
lcdMsg("MODULE ON", String("#")+uiNodeSel); delay(700);
scr = SCR_HOME;
} else if (k=='*') { scr = SCR_NODE_EDIT; beep(); }
}
else if (scr == SCR_EMISSION_CTRL) {
if (k=='A') {
long m = inputNumber("Duration (min)", 4);
if (m>0) startEmission((uint32_t)m*60);
scr = SCR_HOME;
} else if (k=='B') {
stopEmissionAndApplyPending(); scr = SCR_HOME;
} else if (k=='C') {
scr = SCR_SCHEDULE; lcdMsg("A:Add B:List", "*:back");
} else if (k=='D') {
// Налаштування часу RTC: YYYYMMDDHHMM
scr = SCR_SET_TIME;
long y = inputNumber("YYYY", 4); if (y<2000) { scr=SCR_HOME; return; }
long mo= inputNumber("MM", 2); if (mo<1||mo>12) { scr=SCR_HOME; return; }
long d = inputNumber("DD", 2); if (d<1||d>31) { scr=SCR_HOME; return; }
long h = inputNumber("hh", 2); if (h<0||h>23) { scr=SCR_HOME; return; }
long mi= inputNumber("mm", 2); if (mi<0||mi>59) { scr=SCR_HOME; return; }
rtc.adjust(DateTime((int)y,(int)mo,(int)d,(int)h,(int)mi,0));
lcdMsg("RTC saved", dtToStr(rtc.now())); delay(900);
scr = SCR_HOME;
} else if (k=='*') { scr = SCR_HOME; beep(); }
}
else if (scr == SCR_SCHEDULE) {
if (k=='A') {
// Додати
int slot=-1; for (int i=0;i<MAX_EVENTS;i++) if (!schedule[i].active){ slot=i; break; }
if (slot<0) { lcdMsg("ALL Timing", ""); delay(800); scr=SCR_EMISSION_CTRL; return; }
long y = inputNumber("YYYY", 4); if (y<2000){ scr=SCR_EMISSION_CTRL; return; }
long mo= inputNumber("MM", 2); if (mo<1||mo>12){ scr=SCR_EMISSION_CTRL; return; }
long d = inputNumber("DD", 2); if (d<1||d>31){ scr=SCR_EMISSION_CTRL; return; }
long h = inputNumber("hh", 2); if (h<0||h>23){ scr=SCR_EMISSION_CTRL; return; }
long mi= inputNumber("mm", 2); if (mi<0||mi>59){ scr=SCR_EMISSION_CTRL; return; }
long dur = inputNumber("Dur(min)", 4); if (dur<=0){ scr=SCR_EMISSION_CTRL; return; }
schedule[slot].active = true;
schedule[slot].start = DateTime((int)y,(int)mo,(int)d,(int)h,(int)mi,0);
schedule[slot].durationSec = (uint32_t)dur*60;
lcdMsg("ADD slot", String(slot)+" "+dtToStr(schedule[slot].start));
delay(1000);
scr = SCR_EMISSION_CTRL;
} else if (k=='B') {
// Перегляд/видалення
scr = SCR_SCHEDULE_LIST;
int idx = 0;
for (;;) {
while (!schedule[idx].active) { idx++; if (idx>=MAX_EVENTS){ lcdMsg("Empty",""); delay(700); break; } }
if (idx>=MAX_EVENTS) { scr=SCR_EMISSION_CTRL; break; }
lcdMsg(String("#")+idx+" "+dtToStr(schedule[idx].start),
String("Dur ")+(schedule[idx].durationSec/60)+"m D:DEL");
uint32_t t0=millis();
bool exit=false;
while (millis()-t0<2500) {
char kk=keypad.getKey(); if (!kk){yield(); continue;}
if (kk=='D'){ schedule[idx].active=false; lcdMsg("Deleted", String("#")+idx); delay(600); break; }
if (kk=='A'){ if (idx>0) idx--; break; }
if (kk=='B'){ if (idx<MAX_EVENTS-1) idx++; break; }
if (kk=='*'){ exit=true; break; }
}
if (exit){ scr=SCR_EMISSION_CTRL; break; }
}
} else if (k=='*') { scr = SCR_EMISSION_CTRL; beep(); }
}
}
// ---------------------- SETUP / LOOP ----------------------
void setup() {
Serial.begin(115200);
randomSeed(esp_timer_get_time());
pinMode(BUZZER_PIN, OUTPUT);
Wire.begin(); // SDA=21, SCL=22 (за замовч.)
lcd.init(); lcd.backlight();
lcdCentered(0, "STALKER MASTER");
lcdCentered(1, "I2C LCD OK");
if (!rtc.begin()) {
lcdMsg("RTC no find", "chek I2C");
while(true){ delay(1000); }
}
if (rtc.lostPower()) {
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
// Ініт вузлів
nodes[0].exists = true; // «головний» теж у списку
strlcpy(nodes[0].baseSsid, "SHELTER", sizeof(nodes[0].baseSsid));
for (uint16_t i=1;i<=networkSize;i++) nodes[i].exists = true;
lcdMsg("WiFi/ESPNOW...", "");
if (!espnowInit()) {
lcdMsg("ESPNOW FAIL", "");
while(true){ delay(1000); }
}
lcdMsg("Done", "");
delay(700);
}
void loop() {
tickScheduler();
periodicPoll();
if (scr == SCR_HOME) drawHome();
else if (scr == SCR_STATUS) drawStatus();
menuLoop();
delay(50);
}
MISO → GPIO19
SCK → GPIO18
SS/SDA (Chip Select) → GPIO5
MOSI → GPIO23
VCC → 3.3 В, GND → GND
RST → GPIO4