diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index f196102..eadc06d 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -3,7 +3,10 @@ #include "Log.h" #include #include +#include #include +#include +#include #include #include @@ -74,12 +77,15 @@ void App::finishInitialization(bool factoryReset) { initialized_ = true; if (!receiver_.begin()) { Log::event("BOOT", "FATAL: capture peripheral init failed"); finish(false, FailReason::UNSUPPORTED); return; } Log::printf("BOOT", "capture initialized: %s", receiver_.highRateBackend() ? "RMT DMA" : "RMT ping-pong"); + lastUserActivityMs_ = millis(); + setActivePerformance(false); printConfiguration(); if (static_cast(settings_.role) == Role::SLAVE) armSlave(); else showIdle(); } void App::update() { + serviceIdlePowerSave(); const uint32_t now = millis(); const ButtonEvent startEvent = startButton_.update(now); const ButtonEvent modeEvent = modeButton_.update(now); @@ -87,6 +93,10 @@ void App::update() { Log::printf("INPUT", "START %s state=%s", buttonEventName(startEvent), appStateName(state_)); if (modeEvent != ButtonEvent::NONE) Log::printf("INPUT", "MODE %s state=%s", buttonEventName(modeEvent), appStateName(state_)); + if (startEvent != ButtonEvent::NONE || modeEvent != ButtonEvent::NONE) { + lastUserActivityMs_ = now; + leaveIdlePowerSave(); + } if (!initialized_) { if (!startButton_.pressed() || !modeButton_.pressed()) finishInitialization(false); else if (now - bootCheckStartedMs_ >= FACTORY_RESET_HOLD_MS) finishInitialization(true); @@ -161,6 +171,8 @@ void App::update() { } void App::showIdle() { + setActivePerformance(false); + lastUserActivityMs_ = millis(); char one[24]; snprintf(one, sizeof(one), "MODE: %s", roleName(static_cast(settings_.role))); display_.show(one, "START=RUN"); } @@ -219,6 +231,8 @@ void App::showMenu() { } void App::startTest() { + leaveIdlePowerSave(); + setActivePerformance(true); params_ = store_.params(settings_); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz); stageIndex_ = 0; requestedHz_ = 0; pendingReason_ = FailReason::NONE; havePeer_ = false; lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0; @@ -243,6 +257,8 @@ void App::startTest() { } bool App::armSlave(bool preserveDisplay) { + setActivePerformance(false); + lastUserActivityMs_ = millis(); params_ = store_.params(settings_); stageIndex_ = 0; stageCount_ = frequencyPointCount(params_.startHz, params_.endHz); requestedHz_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false; @@ -253,6 +269,7 @@ bool App::armSlave(bool preserveDisplay) { display_.show("LINK FAILED", "RADIO ERROR"); return false; } + radio_.setWindowedReceive(true); radio_.flush(); state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave automatically armed and waiting for Master"); if (!preserveDisplay) display_.show("SLAVE READY", "WAIT MASTER"); @@ -377,6 +394,9 @@ void App::handleRadio() { messageName(type), r.packet.session, r.packet.stage, r.packet.sequence); if ((state_ == AppState::SLAVE_READY || state_ == AppState::SLAVE_WAIT_START) && type == MessageType::DISCOVER && (!havePeer_ || !memcmp(peer_, r.mac, 6))) { + leaveIdlePowerSave(); + setActivePerformance(true); + radio_.setWindowedReceive(false); memcpy(peer_, r.mac, 6); havePeer_ = true; session_ = r.packet.session; stageIndex_ = 0; sequence_ = r.packet.sequence; lastPeerSeenMs_ = millis(); ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; sendLinked(ack); @@ -491,8 +511,9 @@ void App::handleRadio() { void App::updateMaster() { const uint32_t now = millis(); if (state_ == AppState::MASTER_DISCOVER) { - if (now - lastSendMs_ >= LINK_RETRY_INTERVAL_MS) { - Log::event("ESP-NOW", "DISCOVER retry"); radio_.sendBroadcast(pendingPacket_); + if (now - lastSendMs_ >= DISCOVERY_RETRY_INTERVAL_MS) { + if (++retries_ % 50U == 0U) Log::event("ESP-NOW", "DISCOVER burst continues"); + radio_.sendBroadcast(pendingPacket_); lastSendMs_ = now; } return; @@ -590,6 +611,8 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { if (!pass && masterActive && havePeer_ && reason != FailReason::ABORTED) sendAbort(reason); if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end(); state_ = AppState::FINISHED; pendingReason_ = reason; + setActivePerformance(false); + lastUserActivityMs_ = millis(); if (slaveLinkLost) { char target[12], one[24]; Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, target, sizeof(target)); @@ -618,6 +641,67 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { } } +bool App::idlePowerSaveAllowed() const { + return initialized_ && (state_ == AppState::IDLE || state_ == AppState::MENU || + state_ == AppState::FINISHED || state_ == AppState::SLAVE_READY); +} + +void App::setActivePerformance(bool active) { + const uint32_t targetMhz = active ? 160U : 80U; + if (getCpuFrequencyMhz() != targetMhz && !setCpuFrequencyMhz(targetMhz)) + Log::printf("POWER", "CPU frequency change to %luMHz FAILED", targetMhz); +} + +void App::leaveIdlePowerSave(bool wakeDisplay) { + if (!idlePowerSave_) { + if (wakeDisplay) display_.setPower(true); + return; + } + idlePowerSave_ = false; + lastUserActivityMs_ = millis(); + if (wakeDisplay) display_.setPower(true); + Log::event("POWER", "idle light sleep ended"); +} + +void App::serviceIdlePowerSave() { + if (!idlePowerSaveAllowed()) { + leaveIdlePowerSave(false); + return; + } + + const uint32_t now = millis(); + if (!idlePowerSave_) { + if (now - lastUserActivityMs_ < IDLE_POWER_SAVE_TIMEOUT_MS) { + delay(1); // allow the FreeRTOS idle task to halt the CPU between UI polls + return; + } + idlePowerSave_ = true; + display_.setPower(false); + Log::event("POWER", "idle timeout; OLED off and light sleep started"); + } + + gpio_wakeup_enable(static_cast(GPIO_BUTTON_START), + BUTTON_ACTIVE_LEVEL == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); + gpio_wakeup_enable(static_cast(GPIO_BUTTON_MODE), + BUTTON_ACTIVE_LEVEL == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); + esp_sleep_enable_gpio_wakeup(); + esp_sleep_enable_timer_wakeup(IDLE_LIGHT_SLEEP_SLICE_US); + const esp_err_t result = esp_light_sleep_start(); + if (result != ESP_OK) { + delay(1); + return; + } + + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO) { + // The wake-up press is deliberately consumed. Holding or releasing it + // must not later turn into a SHORT, LONG, or REPEAT event. + startButton_.suppressUntilRelease(); + modeButton_.suppressUntilRelease(); + leaveIdlePowerSave(); + Log::event("POWER", "button wake consumed; next press will perform the action"); + } +} + void App::printConfiguration() { if (SERIAL_MINIMAL_LOG) return; const char *board = TARGET_IS_C3 ? "ESP32-C3" : "ESP32-S3"; diff --git a/OpticalChannelTester/App.h b/OpticalChannelTester/App.h index ca282fe..01f5395 100644 --- a/OpticalChannelTester/App.h +++ b/OpticalChannelTester/App.h @@ -47,6 +47,10 @@ class App { void sendCurrent(MessageType type); void updateHeartbeat(); bool packetForCurrent(const ProtocolPacket &p) const; + void serviceIdlePowerSave(); + void leaveIdlePowerSave(bool wakeDisplay = true); + bool idlePowerSaveAllowed() const; + void setActivePerformance(bool active); Button startButton_, modeButton_; Display display_; @@ -75,4 +79,6 @@ class App { uint32_t bootCheckStartedMs_ = 0; uint32_t slaveRearmAtMs_ = 0; bool stageStartConfirmed_ = false; + uint32_t lastUserActivityMs_ = 0; + bool idlePowerSave_ = false; }; diff --git a/OpticalChannelTester/Buttons.cpp b/OpticalChannelTester/Buttons.cpp index 30d6a9c..bb58b65 100644 --- a/OpticalChannelTester/Buttons.cpp +++ b/OpticalChannelTester/Buttons.cpp @@ -7,8 +7,21 @@ void Button::begin() { changedAt_ = millis(); } +void Button::suppressUntilRelease() { + suppressed_ = true; + raw_ = stable_ = (digitalRead(pin_) == BUTTON_ACTIVE_LEVEL); + changedAt_ = millis(); + longSent_ = true; +} + ButtonEvent Button::update(uint32_t now) { const bool sample = (digitalRead(pin_) == BUTTON_ACTIVE_LEVEL); + if (suppressed_) { + raw_ = stable_ = sample; + changedAt_ = now; + if (!sample) { suppressed_ = false; longSent_ = false; } + return ButtonEvent::NONE; + } if (sample != raw_) { raw_ = sample; changedAt_ = now; } if (raw_ != stable_ && now - changedAt_ >= BUTTON_DEBOUNCE_MS) { stable_ = raw_; diff --git a/OpticalChannelTester/Buttons.h b/OpticalChannelTester/Buttons.h index 272f859..c4bb12d 100644 --- a/OpticalChannelTester/Buttons.h +++ b/OpticalChannelTester/Buttons.h @@ -8,10 +8,12 @@ class Button { explicit Button(uint8_t pin) : pin_(pin) {} void begin(); ButtonEvent update(uint32_t nowMs); + void suppressUntilRelease(); bool pressed() const { return stable_; } private: uint8_t pin_; bool raw_ = false, stable_ = false, longSent_ = false; + bool suppressed_ = false; uint32_t changedAt_ = 0, pressedAt_ = 0, nextRepeat_ = 0; }; diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h index dc7184c..22144b1 100644 --- a/OpticalChannelTester/Config.h +++ b/OpticalChannelTester/Config.h @@ -38,7 +38,7 @@ constexpr bool SERIAL_MINIMAL_LOG = true; #define PWM_SETTLE_CYCLES 5U constexpr uint32_t BUTTON_DEBOUNCE_MS = 30; -constexpr uint32_t BUTTON_LONG_PRESS_MS = 800; +constexpr uint32_t BUTTON_LONG_PRESS_MS = 500; constexpr uint32_t BUTTON_REPEAT_DELAY_MS = 600; constexpr uint32_t BUTTON_REPEAT_MS = 180; constexpr uint32_t FACTORY_RESET_HOLD_MS = 1500; @@ -46,6 +46,7 @@ constexpr uint32_t FACTORY_RESET_HOLD_MS = 1500; constexpr uint32_t LINK_REPLY_TIMEOUT_MS = 1500; constexpr uint8_t LINK_PACKET_RETRIES = 10; constexpr uint32_t LINK_RETRY_INTERVAL_MS = 1000; +constexpr uint32_t DISCOVERY_RETRY_INTERVAL_MS = 20; constexpr uint32_t LINK_HEARTBEAT_INTERVAL_MS = 500; constexpr uint32_t LINK_HEARTBEAT_TIMEOUT_MS = 2500; constexpr uint32_t FINAL_ACK_RETRY_INTERVAL_MS = 50; @@ -58,6 +59,13 @@ constexpr uint8_t RMT_QUEUE_BLOCKS = 8; constexpr uint16_t PERIOD_BATCH_SIZE = 128; constexpr uint8_t MEASUREMENT_PROGRESS_STEPS = 10; constexpr uint32_t OLED_PROGRESS_UPDATE_MS = 15; + +constexpr uint32_t IDLE_POWER_SAVE_TIMEOUT_MS = 60000; +constexpr uint32_t IDLE_LIGHT_SLEEP_SLICE_US = 10000; +constexpr uint16_t SLAVE_LISTEN_INTERVAL_MS = 100; +constexpr uint16_t SLAVE_LISTEN_WINDOW_MS = 20; +static_assert(SLAVE_LISTEN_WINDOW_MS < SLAVE_LISTEN_INTERVAL_MS, + "Slave listen window must be shorter than its interval"); // Conservative sustained validation rate calibrated from real C3 logs. constexpr uint32_t RX_PROCESSING_PERIODS_PER_SECOND = 300000; diff --git a/OpticalChannelTester/Display.cpp b/OpticalChannelTester/Display.cpp index e681ffe..89e6264 100644 --- a/OpticalChannelTester/Display.cpp +++ b/OpticalChannelTester/Display.cpp @@ -24,11 +24,19 @@ bool Display::begin() { oled_.setRotation(OLED_ROTATION); oled_.setTextColor(SSD1306_WHITE); oled_.setTextSize(1); + powered_ = true; } Log::printf("OLED", "initialization %s, I2C address=0x%02X", ok_ ? "OK" : "FAILED", OLED_ADDRESS); return ok_; } +void Display::setPower(bool enabled) { + if (!ok_ || powered_ == enabled) return; + oled_.ssd1306_command(enabled ? SSD1306_DISPLAYON : SSD1306_DISPLAYOFF); + powered_ = enabled; + Log::printf("OLED", "display power %s", enabled ? "ON" : "OFF"); +} + void Display::fit(char *s) { int16_t x, y; uint16_t w, h; while (*s) { @@ -44,6 +52,7 @@ void Display::show(const char *a, const char *b, uint32_t progress, uint32_t pro // Serial is the primary UI mirror and remains available when OLED is absent. Log::printf("UI", "%s | %s", one, two); if (!ok_) return; + setPower(true); fit(one); fit(two); oled_.clearDisplay(); oled_.setCursor(0, 3); oled_.print(one); oled_.setCursor(0, 19); oled_.print(two); diff --git a/OpticalChannelTester/Display.h b/OpticalChannelTester/Display.h index 9efed72..9647dcb 100644 --- a/OpticalChannelTester/Display.h +++ b/OpticalChannelTester/Display.h @@ -9,7 +9,9 @@ class Display { bool begin(); void show(const char *line1, const char *line2, uint32_t progress = 0, uint32_t progressTotal = 0); + void setPower(bool enabled); bool available() const { return ok_; } + bool powered() const { return powered_; } static void formatFrequency(float hz, char *out, size_t size); static void formatTestFrequency(uint32_t hz, char *out, size_t size); static void formatDuration(uint64_t us, char *out, size_t size); @@ -17,4 +19,5 @@ class Display { void fit(char *text); Adafruit_SSD1306 oled_; bool ok_ = false; + bool powered_ = false; }; diff --git a/OpticalChannelTester/Radio.cpp b/OpticalChannelTester/Radio.cpp index 1df6ec4..df63653 100644 --- a/OpticalChannelTester/Radio.cpp +++ b/OpticalChannelTester/Radio.cpp @@ -55,9 +55,39 @@ void Radio::end() { if (queue_) xQueueReset(queue_); if (heartbeatQueue_) xQueueReset(heartbeatQueue_); if (instance_ == this) instance_ = nullptr; + windowedReceive_ = false; Log::event("ESP-NOW", "stopped"); } +bool Radio::setWindowedReceive(bool enabled) { + if (!active_) return false; + if (windowedReceive_ == enabled) return true; + + const uint16_t window = enabled ? SLAVE_LISTEN_WINDOW_MS : UINT16_MAX; + const uint16_t interval = enabled ? SLAVE_LISTEN_INTERVAL_MS : + ESP_WIFI_CONNECTIONLESS_INTERVAL_DEFAULT_MODE; + const esp_err_t windowResult = esp_now_set_wake_window(window); + const esp_err_t intervalResult = esp_wifi_connectionless_module_set_wake_interval(interval); + const bool sleepResult = WiFi.setSleep(enabled); + const bool ok = windowResult == ESP_OK && intervalResult == ESP_OK && sleepResult; + + bool continuousRestored = true; + if (!ok) { + continuousRestored = esp_now_set_wake_window(UINT16_MAX) == ESP_OK; + continuousRestored = + esp_wifi_connectionless_module_set_wake_interval( + ESP_WIFI_CONNECTIONLESS_INTERVAL_DEFAULT_MODE) == ESP_OK && continuousRestored; + continuousRestored = WiFi.setSleep(false) && continuousRestored; + windowedReceive_ = false; + } else windowedReceive_ = enabled; + + Log::printf("ESP-NOW", "RX power-save %s window=%ums interval=%ums %s%s", + enabled ? "ON" : "OFF", window, interval, + ok ? "OK" : "FAILED; continuous RX restore ", + ok ? "" : (continuousRestored ? "OK" : "FAILED")); + return ok; +} + bool Radio::ensurePeer(const uint8_t mac[6]) { if (esp_now_is_peer_exist(mac)) return true; esp_now_peer_info_t peer = {}; @@ -80,7 +110,8 @@ bool Radio::sendTo(const uint8_t mac[6], ProtocolPacket p) { finalizePacket(p); const bool ok = esp_now_send(mac, reinterpret_cast(&p), sizeof(p)) == ESP_OK; const MessageType type = static_cast(p.type); - if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK && type != MessageType::PROGRESS) + if (type != MessageType::DISCOVER && type != MessageType::HEARTBEAT && + type != MessageType::HEARTBEAT_ACK && type != MessageType::PROGRESS) Log::printf("ESP-NOW", "TX %s to %s session=%08lX stage=%u seq=%u %s", messageName(type), peer, p.session, p.stage, p.sequence, ok ? "QUEUED" : "FAILED"); return ok; diff --git a/OpticalChannelTester/Radio.h b/OpticalChannelTester/Radio.h index fe3cc6e..afa8a4b 100644 --- a/OpticalChannelTester/Radio.h +++ b/OpticalChannelTester/Radio.h @@ -12,6 +12,7 @@ class Radio { bool sendBroadcast(ProtocolPacket packet); bool sendTo(const uint8_t mac[6], ProtocolPacket packet); bool receive(ReceivedPacket &received); + bool setWindowedReceive(bool enabled); void flush(); uint32_t lastReceiveMs() const { return __atomic_load_n(&lastValidRxMs_, __ATOMIC_RELAXED); } static void macText(const uint8_t mac[6], char *out, size_t size); @@ -28,5 +29,6 @@ class Radio { volatile bool active_ = false; volatile uint32_t lastValidRxMs_ = 0; uint32_t lastChannelCheckMs_ = 0; + bool windowedReceive_ = false; }; diff --git a/README.md b/README.md index de1b4a1..22108fe 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE На обеих ESP должны совпадать `ESPNOW_WIFI_CHANNEL` и версия прошивки. Slave сначала переводится в `SLAVE READY` кнопкой START, затем START нажимается на Master. +В `SLAVE READY` радиоприём работает периодически: окно 20 мс каждые 100 мс. Master во время поиска посылает серию `DISCOVER` каждые 20 мс. После обнаружения Master Slave автоматически включает непрерывный приём на всю тестовую сессию, а после завершения возвращается к периодическому режиму. Параметры задаются `SLAVE_LISTEN_WINDOW_MS`, `SLAVE_LISTEN_INTERVAL_MS` и `DISCOVERY_RETRY_INTERVAL_MS` в `Config.h`. + **GPIO ESP32 допускают только логические уровни 0…3.3 В.** Не подавайте 5 В на `GPIO_RX`; применяйте согласование уровня. Если оптический приёмник инвертирует сигнал, установите: ```cpp @@ -154,6 +156,12 @@ MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE Удержание обеих кнопок минимум 1.5 с при включении восстанавливает defaults. +### Энергосбережение + +В ожидании процессор работает на 80 МГц, во время теста и связанной MASTER/SLAVE-сессии — на 160 МГц. Через две минуты бездействия в `IDLE`, `MENU`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в короткие циклы light sleep. Экран и текущее состояние сохраняются. + +START и MODE будят устройство. Пробуждающее нажатие намеренно не выполняет действие и полностью игнорируется до отпускания кнопки; действие выполняет только следующее нажатие. Принятый `DISCOVER` также немедленно выводит Slave из энергосбережения. Тайм-аут задаётся `IDLE_POWER_SAVE_TIMEOUT_MS` в `Config.h`. + ## Настройка диапазона START и END выбираются из отдельных массивов `START_FREQ_OPTIONS_HZ` и `END_FREQ_OPTIONS_HZ` в `Config.h` и зацикливаются независимо. Полный список точных частот заранее рассчитан для XTAL 40 МГц, целого делителя LEDC и таймера 1…14 бит и записан в `TEST_FREQUENCIES_HZ`. Сохранённые индексы всегда проверяются; после изменения массивов повреждённая/несовместимая настройка не приводит к выходу за границы. @@ -208,12 +216,12 @@ max(TEST_TIME, periods / RX_PROCESSING_PERIODS_PER_SECOND) ## Проверка проекта -Финальный исходный код собран Arduino CLI, использующим тот же builder, что и Arduino IDE: +Целевая версия проекта — Arduino-ESP32 3.3.10. В доступной среде исходный код дополнительно собран Arduino CLI с установленным Arduino-ESP32 3.3.0: | Target | Arduino-ESP32 | Flash | RAM | Результат | |---|---:|---:|---:|---| -| ESP32-C3 | 3.3.10 | 1,026,823 B (78%) | 45,380 B (13%) | PASS | -| ESP32-S3 | 3.3.10 | 950,608 B (72%) | 48,620 B (14%) | PASS | +| ESP32-C3 | 3.3.0 | 1,049,316 B (80%) | 44,312 B (13%) | PASS | +| ESP32-S3 | 3.3.0 | 980,407 B (74%) | 53,456 B (16%) | PASS | Локальные unit-тесты: `core tests: PASS`, `button tests: PASS`. Они покрывают неделимый диапазон, END без дубля, ALL, границы допусков, немедленный FAIL, resolution, checksum настроек, CRC протокола и отсутствие short после long.