#include "App.h" #include "Config.h" #include "Config_Text.h" #include "Log.h" #include #include #include #include #include #include #include #include namespace { const char *appStateName(AppState state) { static const char *names[] = {"IDLE", "MENU", "SOLO_MEASURE", "MASTER_DISCOVER", "MASTER_WAIT_READY", "MASTER_WAIT_RESULT", "MASTER_FINALIZE", "SLAVE_READY", "SLAVE_WAIT_START", "SLAVE_MEASURE", "SLAVE_WAIT_ACK", "FINISHED"}; const uint8_t index = static_cast(state); return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN"; } const char *buttonEventName(ButtonEvent event) { static const char *names[] = {"NONE", "SHORT", "LONG", "REPEAT"}; const uint8_t index = static_cast(event); return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN"; } void formatErrorDuty(float duty, char *out, size_t size) { if (fabsf(duty - roundf(duty)) < 0.05f) snprintf(out, size, "%.0f%%", duty); else snprintf(out, size, "%.1f%%", duty); } size_t utf8CharacterCount(const char *text) { size_t count = 0; while (text && *text) { const uint8_t byte = static_cast(*text++); // Continuation bytes (10xxxxxx) belong to the preceding UTF-8 character. if ((byte & 0xC0U) != 0x80U) ++count; } return count; } const char *uiRoleName(Role role) { const uint8_t index = static_cast(role); return index < sizeof(UiText::ROLE_NAMES) / sizeof(UiText::ROLE_NAMES[0]) ? UiText::ROLE_NAMES[index] : "?"; } const char *uiFailName(FailReason reason) { const uint8_t index = static_cast(reason); return index < sizeof(UiText::FAIL_NAMES) / sizeof(UiText::FAIL_NAMES[0]) ? UiText::FAIL_NAMES[index] : "UNKNOWN"; } const char *roleCorner(Role role) { static const char *markers[] = {"O", "M", "S"}; const uint8_t index = static_cast(role); return index < sizeof(markers) / sizeof(markers[0]) ? markers[index] : "?"; } void formatMenuLine(const char *label, const char *value, char *out, size_t size) { constexpr size_t OLED_TEXT_COLUMNS = 21; const size_t labelLength = utf8CharacterCount(label); const size_t valueLength = utf8CharacterCount(value); const size_t usedColumns = labelLength + valueLength; const int padding = static_cast( usedColumns < OLED_TEXT_COLUMNS ? OLED_TEXT_COLUMNS - usedColumns : 0U); snprintf(out, size, "%s%*s%s", label, padding, "", value); } uint32_t overallProgress(uint32_t stageIndex, uint8_t step) { if (step > MEASUREMENT_PROGRESS_STEPS) step = MEASUREMENT_PROGRESS_STEPS; return stageIndex * MEASUREMENT_PROGRESS_STEPS + step; } uint32_t overallProgressTotal(uint32_t stageCount) { return stageCount * MEASUREMENT_PROGRESS_STEPS; } uint32_t stageWallTimeMs(uint32_t testTimeMs, uint32_t frequencyHz) { return static_cast((nominalStageUs(frequencyHz, testTimeMs, PWM_SETTLE_CYCLES) + 999ULL) / 1000ULL); } } App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), measurement_(receiver_) {} void App::begin() { Serial.begin(SERIAL_BAUD); Log::printf("BOOT", "firmware start, Serial=%lu baud", SERIAL_BAUD); startButton_.begin(); modeButton_.begin(); pwm_.begin(); bootCheckStartedMs_ = millis(); bootResetCandidate_ = startButton_.pressed() && modeButton_.pressed(); Log::printf("BOOT", "buttons initialized, factory-reset candidate=%s", bootResetCandidate_ ? "YES" : "NO"); if (!bootResetCandidate_) finishInitialization(false); } void App::finishInitialization(bool factoryReset) { if (initialized_) return; Log::printf("BOOT", "initialization continues, factory-reset=%s", factoryReset ? "YES" : "NO"); if (factoryReset) { store_.defaults(settings_); store_.save(settings_); Log::event("BOOT", "FACTORY DEFAULTS RESTORED"); } else if (!store_.load(settings_)) { store_.save(settings_); Log::event("BOOT", "NVS invalid/missing: defaults loaded"); } params_ = store_.params(settings_); if (!display_.begin()) Log::event("BOOT", "OLED unavailable; Serial UI remains fully operational"); 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); if (startEvent != ButtonEvent::NONE) 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); return; } serviceRxPinStateLog(); if (state_ != AppState::IDLE && state_ != AppState::MENU && state_ != AppState::FINISHED && startEvent == ButtonEvent::LONG) { abortTest(); return; } if (state_ != AppState::IDLE && state_ != AppState::MENU && state_ != AppState::FINISHED && modeEvent != ButtonEvent::NONE) Log::event("ACTION", "MODE ignored while test is active"); if (state_ == AppState::IDLE || state_ == AppState::FINISHED) { if (modeEvent == ButtonEvent::SHORT) { settings_.role = (settings_.role + 1U) % 3U; const bool saved = store_.save(settings_); params_ = store_.params(settings_); if (static_cast(settings_.role) == Role::SLAVE) armSlave(); else showIdle(); Log::printf("ACTION", "role changed to %s, NVS=%s", roleName(static_cast(settings_.role)), saved ? "OK" : "FAILED"); } else if (modeEvent == ButtonEvent::LONG) { state_ = AppState::MENU; menuItem_ = 0; Log::event("ACTION", "settings menu entered"); showMenu(); } else if (startEvent == ButtonEvent::SHORT) { Log::event("ACTION", "test start requested"); startTest(); } else if (state_ == AppState::FINISHED && static_cast(settings_.role) == Role::SLAVE && now >= slaveRearmAtMs_) armSlave(pendingReason_ != FailReason::NONE); return; } if (state_ == AppState::SLAVE_READY && modeEvent != ButtonEvent::NONE) { radio_.end(); havePeer_ = false; if (modeEvent == ButtonEvent::SHORT) { settings_.role = static_cast(Role::SOLO); const bool saved = store_.save(settings_); params_ = store_.params(settings_); state_ = AppState::IDLE; showIdle(); Log::printf("ACTION", "role changed to SOLO, NVS=%s", saved ? "OK" : "FAILED"); } else if (modeEvent == ButtonEvent::LONG) { state_ = AppState::MENU; menuItem_ = 0; showMenu(); } return; } if (state_ == AppState::MENU) { if (modeEvent == ButtonEvent::SHORT) { menuItem_ = (menuItem_ + 1U) % 5U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu(); } else if (modeEvent == ButtonEvent::LONG) { sanitizeRange(); const bool saved = store_.save(settings_); params_ = store_.params(settings_); Log::printf("ACTION", "settings menu saved and closed, NVS=%s", saved ? "OK" : "FAILED"); state_ = AppState::IDLE; printConfiguration(); if (static_cast(settings_.role) == Role::SLAVE) armSlave(); else showIdle(); } else if (startEvent == ButtonEvent::SHORT) changeMenu(+1); else if (startEvent == ButtonEvent::LONG || startEvent == ButtonEvent::REPEAT) changeMenu(-1); return; } if (state_ == AppState::SOLO_MEASURE) { const MeasureState ms = measurement_.update(); if (ms == MeasureState::FAIL) { printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); finish(false, measurement_.reason(), true); } else if (ms == MeasureState::PASS) { printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); stagePassed(); } else if (ms == MeasureState::STEP_READY) { StageStats live = {}; if (measurement_.statsSnapshot(live)) showStageResult(live); measurement_.continueAfterDisplay(); } } else if (state_ == AppState::MASTER_DISCOVER || state_ == AppState::MASTER_WAIT_READY || state_ == AppState::MASTER_WAIT_RESULT || state_ == AppState::MASTER_FINALIZE) { handleRadio(); updateMaster(); } else { handleRadio(); updateSlave(); } } void App::showIdle() { setActivePerformance(false); setStandbyOpticalOutput(); lastUserActivityMs_ = millis(); char one[64]; snprintf(one, sizeof(one), "%s%s", UiText::MODE_PREFIX, uiRoleName(static_cast(settings_.role))); display_.show(one, UiText::START_RUN); } void App::sanitizeRange() { settings_.startIndex %= countOf(START_FREQ_OPTIONS_HZ); settings_.endIndex %= countOf(END_FREQ_OPTIONS_HZ); } void App::serviceRxPinStateLog() { #ifdef RX_PIN_CHANGE_TEST const bool level = digitalRead(GPIO_RX) == HIGH; const bool outsideTest = state_ == AppState::IDLE || state_ == AppState::MENU || state_ == AppState::SLAVE_READY || state_ == AppState::FINISHED; if (rxPinStateKnown_ && level != rxPinState_ && outsideTest) Log::printf("RX TEST", "GPIO=%u state=%s (%u)", GPIO_RX, level ? "HIGH" : "LOW", level ? 1U : 0U); rxPinState_ = level; rxPinStateKnown_ = true; #endif } void App::changeMenu(int d) { sanitizeRange(); uint8_t *value = nullptr; size_t count = 0; switch (menuItem_) { case 0: value = &settings_.startIndex; count = countOf(START_FREQ_OPTIONS_HZ); break; case 1: value = &settings_.endIndex; count = countOf(END_FREQ_OPTIONS_HZ); break; case 2: value = &settings_.accuracyIndex; count = countOf(ACCURACY_OPTIONS_PCT); break; case 3: value = &settings_.timeIndex; count = countOf(TEST_TIME_OPTIONS_MS); break; default: value = &settings_.dutyIndex; count = countOf(DUTY_OPTIONS_PCT); break; } *value = static_cast((*value + count + d) % count); Log::printf("ACTION", "menu item=%u changed direction=%+d new-index=%u", menuItem_, d, *value); sanitizeRange(); params_ = store_.params(settings_); showMenu(); } void App::showMenu() { char one[64], value[24], total[64], all[12]; const char *label = nullptr; Display::formatDuration(actualNominalTotalUs(), all, sizeof(all)); switch (menuItem_) { case 0: Display::formatTestFrequency(params_.startHz, value, sizeof(value)); strncat(value, UiText::FREQUENCY_UNIT, sizeof(value) - strlen(value) - 1U); label = UiText::MENU_START_FREQUENCY; break; case 1: Display::formatTestFrequency(params_.endHz, value, sizeof(value)); strncat(value, UiText::FREQUENCY_UNIT, sizeof(value) - strlen(value) - 1U); label = UiText::MENU_END_FREQUENCY; break; case 2: snprintf(value, sizeof(value), "+/-%g%%", params_.accuracyPct); label = UiText::MENU_ACCURACY; break; case 3: snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f); label = UiText::MENU_TEST_TIME; break; default: snprintf(value, sizeof(value), "%u%%", params_.dutyPct); label = UiText::MENU_PWM_DUTY; break; } formatMenuLine(label, value, one, sizeof(one)); formatMenuLine(UiText::MENU_TOTAL_TIME, all, total, sizeof(total)); display_.show(one, total); } void App::startTest() { leaveIdlePowerSave(); pwm_.stop(); 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; if (!stageCount_) { finish(false, FailReason::UNSUPPORTED); return; } Log::printf("TEST", "starting role=%s stages=%lu", roleName(static_cast(settings_.role)), stageCount_); if (SERIAL_MINIMAL_LOG) { char startText[12], endText[12]; Display::formatFrequency(params_.startHz, startText, sizeof(startText)); Display::formatFrequency(params_.endHz, endText, sizeof(endText)); Log::printf("CONFIG", "mode=%s range=%s..%s adjacent accuracy=%.2f%% time=%lums duty=%u%% stages=%lu", roleName(static_cast(settings_.role)), startText, endText, params_.accuracyPct, params_.testTimeMs, params_.dutyPct, stageCount_); } printConfiguration(); const Role role = static_cast(settings_.role); if (role == Role::SOLO) { if (!prepareStage()) return; state_ = AppState::SOLO_MEASURE; } else if (!radio_.begin()) finish(false, FailReason::LINK_LOST); else if (role == Role::MASTER) startMasterDiscovery(); else { state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave armed and waiting for Master"); display_.show(UiText::SLAVE_READY, UiText::WAIT_MASTER); } } bool App::armSlave(bool preserveDisplay) { setActivePerformance(false); pwm_.stop(); lastUserActivityMs_ = millis(); params_ = store_.params(settings_); stageIndex_ = 0; stageCount_ = frequencyPointCount(params_.startHz, params_.endHz); requestedHz_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false; lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0; retries_ = 0; slaveRearmAtMs_ = 0; if (!radio_.begin()) { state_ = AppState::FINISHED; pendingReason_ = FailReason::LINK_LOST; slaveRearmAtMs_ = millis() + LINK_HEARTBEAT_TIMEOUT_MS; display_.show(UiText::LINK_FAILED, UiText::RADIO_ERROR, 0, 0, roleCorner(Role::SLAVE)); 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(UiText::SLAVE_READY, UiText::WAIT_MASTER); return true; } bool App::prepareStage(bool showProgress) { requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_); actual_ = {}; const uint32_t maxHz = TARGET_IS_C3 ? C3_STRICT_MAX_HZ : (receiver_.highRateBackend() ? S3_STRICT_MAX_HZ : C3_STRICT_MAX_HZ); if (requestedHz_ > maxHz) { finish(false, FailReason::UNSUPPORTED); return false; } Log::printf("PWM", "starting GPIO=%u requested=%luHz duty=%u%%", GPIO_PWM, requestedHz_, params_.dutyPct); if (!pwm_.start(requestedHz_, params_.dutyPct, actual_)) { Log::printf("PWM", "START FAILED GPIO=%u requested=%luHz; LEDC attach/write/read failed", GPIO_PWM, requestedHz_); finish(false, FailReason::RESOLUTION); return false; } const uint32_t plannedRxHz = receiver_.plannedTickHz(actual_.actualHz, actual_.actualDutyPct); const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct, plannedRxHz, actual_.bits); if (resolution != FailReason::NONE) { Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u RXclock=%luHz tolerance=%.3f%%", actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz, effectiveTolerancePct(params_.accuracyPct)); finish(false, resolution); return false; } Log::printf("PWM", "stage=%lu/%lu requested=%luHz actual=%luHz duty=%.2f%% bits=%u STARTED", stageIndex_ + 1, stageCount_, requestedHz_, actual_.actualHz, actual_.actualDutyPct, actual_.bits); if (showProgress) showStageProgress(); if (static_cast(settings_.role) == Role::SOLO && !startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); return false; } return true; } bool App::startLocalMeasurement(float hz, float duty) { Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% RX=%luHz settle=%u cycles window=%lums; per-pulse logging suspended", hz, duty, effectiveTolerancePct(params_.accuracyPct), receiver_.plannedTickHz(static_cast(hz + 0.5f), duty), PWM_SETTLE_CYCLES, params_.testTimeMs); const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, PWM_SETTLE_CYCLES); Log::printf("MEASURE", "receiver start %s, RMT chunk=%u symbols", ok ? "OK" : "FAILED", receiver_.receiveChunkSymbols()); return ok; } void App::stagePassed() { Log::printf("TEST", "stage %lu/%lu PASS; PWM stopping", stageIndex_ + 1, stageCount_); pwm_.stop(); if (++stageIndex_ >= stageCount_) { finish(true, FailReason::NONE); return; } if (static_cast(settings_.role) == Role::SOLO) { if (prepareStage()) state_ = AppState::SOLO_MEASURE; } else if (static_cast(settings_.role) == Role::MASTER) { requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_); actual_ = {}; stageStartConfirmed_ = false; pendingPacket_ = makePacket(MessageType::PREPARE); sendCurrent(MessageType::PREPARE); state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } } void App::startMasterDiscovery() { session_ = esp_random(); if (!session_) session_ = 1; sequence_ = 1; stageIndex_ = 0; requestedHz_ = 0; havePeer_ = false; radio_.flush(); opticalWakeActive_ = true; lastOpticalWakeToggleMs_ = millis(); pwm_.active(); pendingPacket_ = makePacket(MessageType::DISCOVER); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = millis(); retries_ = 0; state_ = AppState::MASTER_DISCOVER; Log::printf("ESP-NOW", "discovery started session=%08lX", session_); display_.show(UiText::MASTER_SEARCH, UiText::HOLD_START_STOP); } ProtocolPacket App::makePacket(MessageType type) const { ProtocolPacket p = {}; p.type = static_cast(type); p.session = session_; p.stage = stageIndex_; p.stageCount = static_cast(stageCount_); p.sequence = sequence_; p.requestedHz = requestedHz_; p.actualHz = actual_.actualHz; const float packetDuty = actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct; p.actualDutyX100 = static_cast(packetDuty * 100.0f + 0.5f); p.testTimeMs = params_.testTimeMs; p.accuracyX100 = static_cast(params_.accuracyPct * 100.0f + 0.5f); p.settleCycles = PWM_SETTLE_CYCLES; return p; } void App::sendCurrent(MessageType type) { ++sequence_; pendingPacket_ = makePacket(type); const bool ok = sendLinked(pendingPacket_); lastSendMs_ = millis(); if (!ok) Log::printf("ESP-NOW", "sendCurrent %s FAILED", messageName(type)); } bool App::sendLinked(ProtocolPacket packet) { return havePeer_ ? radio_.sendTo(peer_, packet) : radio_.sendBroadcast(packet); } void App::updateHeartbeat() { if (!havePeer_ || state_ == AppState::FINISHED) return; const uint32_t now = millis(); const uint32_t radioRxMs = radio_.lastReceiveMs(); if (radioRxMs && now - radioRxMs < now - lastPeerSeenMs_) lastPeerSeenMs_ = radioRxMs; if (now - lastPeerSeenMs_ >= LINK_HEARTBEAT_TIMEOUT_MS) { Log::event("ESP-NOW", "peer heartbeat timeout"); finish(false, FailReason::LINK_LOST); return; } if (static_cast(settings_.role) == Role::MASTER && now - lastHeartbeatMs_ >= LINK_HEARTBEAT_INTERVAL_MS) { ProtocolPacket heartbeat = makePacket(MessageType::HEARTBEAT); heartbeat.sequence = sequence_; sendLinked(heartbeat); lastHeartbeatMs_ = now; } } bool App::packetForCurrent(const ProtocolPacket &p) const { return p.session == session_ && p.stage == stageIndex_; } void App::handleRadio() { ReceivedPacket r; while (radio_.receive(r)) { const MessageType type = static_cast(r.packet.type); if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK && type != MessageType::PROGRESS && state_ != AppState::SLAVE_MEASURE) Log::printf("ESP-NOW", "RX %s session=%08lX stage=%u seq=%u", 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(); pwm_.stop(); 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); state_ = AppState::SLAVE_WAIT_START; display_.show(UiText::MASTER_SEEN, UiText::ACK_SENT); continue; } if (state_ == AppState::MASTER_DISCOVER && type == MessageType::DISCOVER_ACK && r.packet.session == session_) { opticalWakeActive_ = false; pwm_.stop(); memcpy(peer_, r.mac, 6); havePeer_ = true; lastPeerSeenMs_ = lastHeartbeatMs_ = millis(); requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_); sendCurrent(MessageType::PREPARE); state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; char mac[20]; Radio::macText(peer_, mac, sizeof(mac)); Log::printf("ESP-NOW", "Slave selected %s", mac); continue; } if (havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_) lastPeerSeenMs_ = millis(); if (havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_ && type == MessageType::HEARTBEAT) { continue; // Radio's priority heartbeat task has already sent the ACK. } if (type == MessageType::HEARTBEAT_ACK) continue; if (havePeer_ && !memcmp(peer_, r.mac, 6) && type == MessageType::RESULT && r.packet.session == session_ && r.packet.stage < stageIndex_) { ProtocolPacket ack = {}; ack.type = static_cast(MessageType::ACK); ack.session = session_; ack.stage = r.packet.stage; ack.sequence = r.packet.sequence; sendLinked(ack); continue; // idempotent ACK for a retried old result } const bool matchingPeer = havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_; if (matchingPeer && type == MessageType::PREPARE) { const bool expected = state_ == AppState::SLAVE_WAIT_START && r.packet.stage == stageIndex_; const bool implicitAck = state_ == AppState::SLAVE_WAIT_ACK && pendingPacket_.passed && r.packet.stage == static_cast(pendingPacket_.stage + 1U); if (expected || implicitAck) { stageIndex_ = r.packet.stage; sequence_ = r.packet.sequence; state_ = AppState::SLAVE_WAIT_START; params_.testTimeMs = r.packet.testTimeMs; params_.accuracyPct = r.packet.accuracyX100 / 100.0f; requestedHz_ = r.packet.requestedHz; stageCount_ = r.packet.stageCount; actual_ = {}; ProtocolPacket ready = makePacket(MessageType::READY); ready.sequence = r.packet.sequence; sendLinked(ready); } continue; } if (!havePeer_ || memcmp(peer_, r.mac, 6) || !packetForCurrent(r.packet)) continue; if (type == MessageType::ABORT) { const FailReason reason = r.packet.reason > static_cast(FailReason::NONE) && r.packet.reason <= static_cast(FailReason::ABORTED) ? static_cast(r.packet.reason) : FailReason::ABORTED; if (r.packet.requestedHz) requestedHz_ = r.packet.requestedHz; actual_.actualHz = r.packet.actualHz ? r.packet.actualHz : requestedHz_; actual_.actualDutyPct = r.packet.actualDutyX100 ? r.packet.actualDutyX100 / 100.0f : params_.dutyPct; measurement_.abort(); finish(false, reason); continue; } if (state_ == AppState::MASTER_WAIT_READY && type == MessageType::READY) { if (!prepareStage(false)) continue; sendCurrent(MessageType::START_STAGE); state_ = AppState::MASTER_WAIT_RESULT; stageStartConfirmed_ = false; retries_ = 0; deadlineMs_ = millis() + LINK_RETRY_INTERVAL_MS; } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::READY && r.packet.sequence == pendingPacket_.sequence) { if (!stageStartConfirmed_) showStageProgress(); stageStartConfirmed_ = true; deadlineMs_ = millis() + stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS + 20; } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::PROGRESS) { stageStartConfirmed_ = true; deadlineMs_ = millis() + stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS; showRemoteResult(r.packet); } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::RESULT) { ProtocolPacket ack = makePacket(MessageType::ACK); ack.sequence = r.packet.sequence; ack.passed = r.packet.passed && stageIndex_ + 1U >= stageCount_; sendLinked(ack); pwm_.stop(); showRemoteResult(r.packet); if (!r.packet.passed) { finish(false, static_cast(r.packet.reason), true); } else if (ack.passed) { pendingPacket_ = ack; state_ = AppState::MASTER_FINALIZE; retries_ = 0; deadlineMs_ = millis() + FINAL_ACK_RETRY_INTERVAL_MS; } else stagePassed(); } else if (state_ == AppState::MASTER_FINALIZE && type == MessageType::RESULT) { // The Slave did not receive the final ACK and repeated RESULT. sendLinked(pendingPacket_); } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) { sequence_ = r.packet.sequence; actual_.actualHz = r.packet.actualHz; actual_.actualDutyPct = r.packet.actualDutyX100 / 100.0f; if (!startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); continue; } showStageProgress(); state_ = AppState::SLAVE_MEASURE; deadlineMs_ = millis() + stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS; ProtocolPacket started = makePacket(MessageType::READY); started.sequence = r.packet.sequence; sendLinked(started); } else if (state_ == AppState::SLAVE_MEASURE && type == MessageType::START_STAGE) { // START_STAGE or its acknowledgement was lost. Do not restart the // measurement; only confirm the already running stage again. ProtocolPacket started = makePacket(MessageType::READY); started.sequence = r.packet.sequence; sendLinked(started); } else if (state_ == AppState::SLAVE_WAIT_ACK && type == MessageType::ACK && r.packet.sequence == pendingPacket_.sequence) { if (pendingPacket_.passed) { if (r.packet.passed) { radio_.end(); pendingReason_ = FailReason::NONE; if (armSlave(true)) display_.show(UiText::PASS_WORD, UiText::WAIT_MASTER); } else { stageIndex_ = static_cast(r.packet.stage) + 1U; state_ = AppState::SLAVE_WAIT_START; } } else finish(false, static_cast(pendingPacket_.reason), true); } } } void App::updateMaster() { const uint32_t now = millis(); if (state_ == AppState::MASTER_DISCOVER) { if (now - lastOpticalWakeToggleMs_ >= OPTICAL_WAKE_HALF_PERIOD_MS) { opticalWakeActive_ = !opticalWakeActive_; if (opticalWakeActive_) pwm_.active(); else pwm_.stop(); lastOpticalWakeToggleMs_ = now; } if (now - lastSendMs_ >= DISCOVERY_RETRY_INTERVAL_MS) { if (++retries_ % 50U == 0U) Log::event("ESP-NOW", "DISCOVER burst continues"); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = now; } return; } if (state_ == AppState::MASTER_FINALIZE) { if (now < deadlineMs_) return; if (retries_++ < FINAL_ACK_RETRIES) { sendLinked(pendingPacket_); deadlineMs_ = now + FINAL_ACK_RETRY_INTERVAL_MS; } else finish(true, FailReason::NONE); return; } updateHeartbeat(); if (state_ == AppState::FINISHED) return; if (now < deadlineMs_) return; if (retries_ >= LINK_PACKET_RETRIES) { finish(false, FailReason::LINK_LOST); return; } Log::printf("ESP-NOW", "%s retry=%u", messageName(static_cast(pendingPacket_.type)), retries_ + 1); sendLinked(pendingPacket_); ++retries_; deadlineMs_ = now + (state_ == AppState::MASTER_WAIT_RESULT ? (stageStartConfirmed_ ? stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS : LINK_RETRY_INTERVAL_MS) : LINK_REPLY_TIMEOUT_MS); } void App::updateSlave() { updateHeartbeat(); if (state_ == AppState::FINISHED) return; if (state_ == AppState::SLAVE_MEASURE) { const MeasureState ms = measurement_.update(); if (ms == MeasureState::STEP_READY) { StageStats live = {}; if (measurement_.statsSnapshot(live)) { ProtocolPacket progress = makePacket(MessageType::PROGRESS); progress.progressStep = measurement_.progressStep(); fillMeasuredResult(progress, live); progress.sequence = sequence_; sendLinked(progress); showStageResult(live); } measurement_.continueAfterDisplay(); return; } if (ms != MeasureState::PASS && ms != MeasureState::FAIL) { return; } printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); pendingPacket_ = makePacket(MessageType::RESULT); pendingPacket_.progressStep = ms == MeasureState::PASS ? MEASUREMENT_PROGRESS_STEPS : measurement_.progressStep(); pendingPacket_.passed = ms == MeasureState::PASS && measurement_.reason() == FailReason::NONE; pendingPacket_.reason = static_cast(measurement_.reason()); pendingPacket_.periods = measurement_.stats().periods; fillMeasuredResult(pendingPacket_, measurement_.stats()); pendingPacket_.minPeriodTicks = measurement_.stats().minPeriod; pendingPacket_.maxPeriodTicks = measurement_.stats().maxPeriod; pendingPacket_.sequence = ++sequence_; sendLinked(pendingPacket_); Log::printf("TEST", "Slave result prepared: %s reason=%s periods=%lu", pendingPacket_.passed ? "PASS" : "FAIL", failName(static_cast(pendingPacket_.reason)), pendingPacket_.periods); state_ = AppState::SLAVE_WAIT_ACK; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } else if (state_ == AppState::SLAVE_WAIT_ACK && millis() >= deadlineMs_) { if (retries_++ >= LINK_PACKET_RETRIES) finish(false, FailReason::LINK_LOST); else { Log::printf("ESP-NOW", "RESULT retry=%u", retries_); sendLinked(pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } } } void App::sendAbort(FailReason reason) { if (!havePeer_) return; ++sequence_; ProtocolPacket packet = makePacket(MessageType::ABORT); packet.reason = static_cast(reason); if (!packet.actualDutyX100) packet.actualDutyX100 = params_.dutyPct * 100U; sendLinked(packet); } void App::abortTest() { Log::event("ACTION", "abort requested: sending ABORT, stopping receiver and PWM"); sendAbort(FailReason::ABORTED); measurement_.abort(); finish(false, FailReason::ABORTED); } void App::finish(bool pass, FailReason reason, bool preserveDisplay) { Log::printf("TEST", "finishing result=%s reason=%s", pass ? "PASS" : "FAIL", failName(reason)); const AppState failedState = state_; const bool masterLinkLost = reason == FailReason::LINK_LOST && (failedState == AppState::MASTER_DISCOVER || failedState == AppState::MASTER_WAIT_READY || failedState == AppState::MASTER_WAIT_RESULT || failedState == AppState::MASTER_FINALIZE); const bool masterActive = failedState == AppState::MASTER_DISCOVER || failedState == AppState::MASTER_WAIT_READY || failedState == AppState::MASTER_WAIT_RESULT || failedState == AppState::MASTER_FINALIZE; const bool slaveLinkLost = reason == FailReason::LINK_LOST && (failedState == AppState::SLAVE_READY || failedState == AppState::SLAVE_WAIT_START || failedState == AppState::SLAVE_MEASURE || failedState == AppState::SLAVE_WAIT_ACK); pwm_.stop(); receiver_.stop(); if (masterLinkLost) { Log::event("ESP-NOW", "link lost; returning to continuous discovery"); startMasterDiscovery(); return; } if (!pass && masterActive && havePeer_ && reason != FailReason::ABORTED) sendAbort(reason); if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end(); state_ = AppState::FINISHED; pendingReason_ = reason; setStandbyOpticalOutput(); setActivePerformance(false); lastUserActivityMs_ = millis(); if (slaveLinkLost) { char target[12], one[64]; Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, target, sizeof(target)); snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct); display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_, roleCorner(Role::SLAVE)); armSlave(true); return; } if (static_cast(settings_.role) == Role::SLAVE) slaveRearmAtMs_ = millis() + 2000; if (preserveDisplay) return; char one[64]; if (pass) { const Role role = static_cast(settings_.role); snprintf(one, sizeof(one), "%s %s", uiRoleName(role), UiText::PASS_WORD); display_.show(one, role == Role::SLAVE ? UiText::WAIT_MASTER : UiText::START_AGAIN); } else if (requestedHz_) { char frequency[12]; Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, frequency, sizeof(frequency)); snprintf(one, sizeof(one), UiText::FAIL_FORMAT, frequency, actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct); display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_, roleCorner(static_cast(settings_.role))); } else { display_.show(UiText::TEST_FAILED, uiFailName(reason), 0, 0, roleCorner(static_cast(settings_.role))); } } bool App::idlePowerSaveAllowed() const { return initialized_ && (state_ == AppState::IDLE || state_ == AppState::MENU || state_ == AppState::FINISHED || state_ == AppState::SLAVE_READY); } void App::setStandbyOpticalOutput() { if (static_cast(settings_.role) == Role::SLAVE) pwm_.stop(); else pwm_.active(); } 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(); setStandbyOpticalOutput(); if (idleSleepRadioStopped_) { idleSleepRadioStopped_ = false; if (radio_.begin()) { radio_.setWindowedReceive(true); radio_.flush(); Log::event("POWER", "Slave ESP-NOW restored after external wake"); } else Log::event("POWER", "Slave ESP-NOW restore FAILED after external wake"); } 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; pwm_.stop(); if (state_ == AppState::SLAVE_READY) { radio_.end(); idleSleepRadioStopped_ = 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); if (static_cast(settings_.role) == Role::SLAVE) { // Light-sleep GPIO wake is level-triggered in ESP-IDF. Arm the level // opposite to the one sampled immediately before sleep, which makes a // transition (either edge) necessary and prevents a steady RX level from // waking Slave continuously. const bool currentRxHigh = gpio_get_level(static_cast(GPIO_RX)) != 0; gpio_wakeup_enable(static_cast(GPIO_RX), currentRxHigh ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); } else gpio_wakeup_disable(static_cast(GPIO_RX)); esp_sleep_enable_gpio_wakeup(); 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) { const bool buttonWake = digitalRead(GPIO_BUTTON_START) == BUTTON_ACTIVE_LEVEL || digitalRead(GPIO_BUTTON_MODE) == BUTTON_ACTIVE_LEVEL; if (buttonWake) { // 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"); } else if (static_cast(settings_.role) == Role::SLAVE) { leaveIdlePowerSave(); Log::event("POWER", "optical input woke Slave"); } } } void App::printConfiguration() { if (SERIAL_MINIMAL_LOG) return; const char *board = TARGET_IS_C3 ? "ESP32-C3" : "ESP32-S3"; uint8_t mac[6] = {}; esp_read_mac(mac, ESP_MAC_WIFI_STA); Serial.printf("\nOptical Channel Tester | %s | mode=%s\n", board, roleName(static_cast(settings_.role))); Serial.printf("MAC=%02X:%02X:%02X:%02X:%02X:%02X\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); Serial.printf("GPIO PWM=%u RX=%u START=%u MODE=%u SDA=%u SCL=%u\n", GPIO_PWM, GPIO_RX, GPIO_BUTTON_START, GPIO_BUTTON_MODE, GPIO_SDA, GPIO_SCL); Serial.printf("Test %lu..%lu Hz (adjacent exact frequencies), accuracy %.2f%%, %lums, duty %u%%\n", params_.startHz, params_.endHz, params_.accuracyPct, params_.testTimeMs, params_.dutyPct); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz); Serial.printf("Frequencies (%lu): ", stageCount_); for (uint32_t i = 0; i < stageCount_; ++i) Serial.printf("%lu%s", frequencyAt(params_.startHz, params_.endHz, i), i + 1 == stageCount_ ? "\n" : ","); Serial.printf("ALL nominal: %llu us | RX=%s\n", actualNominalTotalUs(), receiver_.highRateBackend() ? "RMT DMA" : "RMT ping-pong"); } uint64_t App::actualNominalTotalUs() { // ALL is only an estimate. Do not attach/detach LEDC for every frequency: // large sweeps can perform hundreds of unnecessary driver reconfigurations // immediately before the real test and leave no observable PWM on failure. return nominalTotalUs(params_, PWM_SETTLE_CYCLES); } void App::printStageStats(const StageStats &s, uint32_t hz) { if (!s.periods) return; const float measuredHz = static_cast(receiver_.tickHz()) * s.periods / s.periodSum; const float measuredDuty = 100.0f * s.activeSum / s.periodSum; char requestedText[12], measuredText[12]; Display::formatFrequency(hz, requestedText, sizeof(requestedText)); Display::formatFrequency(measuredHz, measuredText, sizeof(measuredText)); const char *status = s.reason == FailReason::NONE ? "PASS" : "FAIL"; Log::printf("RESULT", "%s %s periods=%lu measured=%s duty=%.2f%% skipped=%lu%s%s", requestedText, status, s.periods, measuredText, measuredDuty, s.droppedItems, s.reason == FailReason::NONE ? "" : " reason=", s.reason == FailReason::NONE ? "" : failName(s.reason)); } void App::showStageResult(const StageStats &s) { char one[64], two[64]; char target[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target)); if (s.reason != FailReason::NONE) { snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, actual_.actualDutyPct); if (s.reason == FailReason::PERIOD_OUT && s.badFrequency > 0.0f) { char frequency[12]; Display::formatTestFrequency(static_cast(lroundf(s.badFrequency)), frequency, sizeof(frequency)); snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency); } else if (s.reason == FailReason::DUTY_OUT && s.badFrequency > 0.0f) { char duty[10]; formatErrorDuty(s.badDuty, duty, sizeof(duty)); snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty); } else { snprintf(two, sizeof(two), "%s", uiFailName(s.reason)); } display_.show(one, two, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_), roleCorner(static_cast(settings_.role))); return; } char stage[12]; snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_); snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, actual_.actualDutyPct, stage); if (!s.periods || !s.periodSum) { display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_)); return; } const float measuredHz = static_cast(receiver_.tickHz()) * s.periods / s.periodSum; const float measuredDuty = 100.0f * s.activeSum / s.periodSum; char frequency[12]; Display::formatFrequency(measuredHz, frequency, sizeof(frequency)); snprintf(two, sizeof(two), "F:%-8s D:%4.1f%%", frequency, measuredDuty); display_.show(one, two, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_)); } void App::showRemoteResult(const ProtocolPacket &packet) { const FailReason reason = packet.reason <= static_cast(FailReason::ABORTED) ? static_cast(packet.reason) : FailReason::UNSUPPORTED; char target[12], one[64], two[64]; Display::formatTestFrequency(packet.actualHz ? packet.actualHz : packet.requestedHz, target, sizeof(target)); if (reason == FailReason::NONE) { char stage[12]; snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_); snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, packet.actualDutyX100 / 100.0f, stage); if (packet.measuredHzX10) { char measured[12]; Display::formatFrequency(packet.measuredHzX10 / 10.0f, measured, sizeof(measured)); snprintf(two, sizeof(two), "F:%-8s D:%4.1f%%", measured, packet.measuredDutyX10 / 10.0f); } else snprintf(two, sizeof(two), "%s", UiText::NO_MEASUREMENT); } else if (reason == FailReason::PERIOD_OUT && packet.measuredHzX10) { snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); char measured[12]; Display::formatTestFrequency((packet.measuredHzX10 + 5U) / 10U, measured, sizeof(measured)); snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, measured); } else if (reason == FailReason::DUTY_OUT && packet.measuredDutyX10) { snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); char duty[10]; formatErrorDuty(packet.measuredDutyX10 / 10.0f, duty, sizeof(duty)); snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty); } else { snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); snprintf(two, sizeof(two), "%s", uiFailName(reason)); } display_.show(one, two, overallProgress(stageIndex_, packet.progressStep), overallProgressTotal(stageCount_), reason == FailReason::NONE ? nullptr : roleCorner(static_cast(settings_.role))); } void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) const { packet.reason = static_cast(stats.reason); packet.periods = stats.periods; if (!stats.periods || !stats.periodSum) return; const bool badPeriod = (stats.reason == FailReason::PERIOD_OUT || stats.reason == FailReason::DUTY_OUT) && stats.badFrequency > 0.0f; const float measuredHz = badPeriod ? stats.badFrequency : static_cast(receiver_.tickHz()) * stats.periods / stats.periodSum; const float measuredDuty = badPeriod ? stats.badDuty : 100.0f * stats.activeSum / stats.periodSum; packet.measuredHzX10 = static_cast(lroundf(measuredHz * 10.0f)); packet.measuredDutyX10 = static_cast(lroundf(measuredDuty * 10.0f)); } void App::showStageProgress() { char target[12], one[64], stage[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target)); snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_); snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, actual_.actualDutyPct, stage); display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, 0), overallProgressTotal(stageCount_)); }