#include "App.h" #include "Config.h" #include "Config_Text.h" #include "Log.h" #include #include #include #include #include #include #include #include #include namespace { const char *uiFailName(FailReason reason); 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"; } uint32_t pulseFromDuty(float hz, float dutyPct) { return hz > 0.0f ? static_cast(lroundf(dutyPct * 10000000.0f / hz)) : 0U; } float dutyFromPulse(uint32_t hz, uint32_t pulseNs) { return static_cast(static_cast(hz) * pulseNs / 10000000.0); } void formatTarget(uint32_t hz, uint32_t pulseNs, char *out, size_t size) { char frequency[16], pulse[12]; Display::formatPwmFrequency(hz, frequency, sizeof(frequency)); Display::formatPulse(pulseNs, pulse, sizeof(pulse)); snprintf(out, size, UiText::TEST_FORMAT, frequency, pulse); } void formatMeasured(float hz, uint32_t pulseNs, char *out, size_t size) { char frequency[12], pulse[12]; Display::formatFrequency(hz, frequency, sizeof(frequency)); Display::formatPulse(pulseNs, pulse, sizeof(pulse), true); snprintf(out, size, "F:%s, P:%s", frequency, pulse); } void formatTestTarget(uint32_t hz, uint32_t pulseNs, char *out, size_t size) { char target[32]; formatTarget(hz, pulseNs, target, sizeof(target)); snprintf(out, size, "TEST: %s", target); } void formatFailure(FailReason reason, uint32_t hz, uint32_t pulseNs, char *out, size_t size) { (void)reason; char target[32]; formatTarget(hz, pulseNs, target, sizeof(target)); snprintf(out, size, "FAIL AT %s", target); } 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); } uint8_t lastValidMaxPulseIndex(uint32_t frequencyHz) { uint8_t last = static_cast(countOf(MAX_PULSE_OPTIONS_NS) - 1U); while (last && static_cast(MAX_PULSE_OPTIONS_NS[last]) * frequencyHz >= 1000000000ULL) --last; return last; } uint8_t firstMaxPulseIndexAtLeast(uint32_t pulseNs, uint8_t last) { for (uint8_t i = 0; i <= last; ++i) if (MAX_PULSE_OPTIONS_NS[i] >= pulseNs) return i; return last; } uint8_t lastMinPulseIndexAtMost(uint32_t pulseNs) { for (size_t i = countOf(MIN_PULSE_OPTIONS_NS); i > 0; --i) if (MIN_PULSE_OPTIONS_NS[i - 1U] <= pulseNs) return static_cast(i - 1U); return 0; } uint32_t plannedPulseCaptureHz(uint32_t frequencyHz, float dutyPct) { if (!frequencyHz || dutyPct <= 0.0f || dutyPct >= 100.0f) return 0; return MCPWM_CAPTURE_RESOLUTION_HZ; } uint32_t plannedCaptureHz(uint32_t frequencyHz, float dutyPct) { // One 32-bit S3 MCPWM capture timer measures period and pulse at 80 MHz. return plannedPulseCaptureHz(frequencyHz, dutyPct); } bool pulsePointHasResolution(uint32_t hz, uint32_t pulseNs, float accuracyPct) { uint32_t actualHz = 0, actualPulseNs = 0; uint8_t bits = 0; if (TARGET_IS_C3) { IntegerPwmConfig config = {}; if (!choosePwmConfig(hz, pulseNs, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, config)) return false; actualHz = config.actualHz; actualPulseNs = config.actualPulseNs; bits = config.bits; } else { if (!hz || MCPWM_RESOLUTION_HZ % hz) return false; const uint32_t periodTicks = MCPWM_RESOLUTION_HZ / hz; if (periodTicks < 2U || periodTicks > MCPWM_MAX_PERIOD_TICKS) return false; uint32_t activeTicks = static_cast( (static_cast(pulseNs) * MCPWM_RESOLUTION_HZ + 500000000ULL) / 1000000000ULL); if (!activeTicks || activeTicks >= periodTicks) return false; actualHz = hz; actualPulseNs = static_cast( (static_cast(activeTicks) * 1000000000ULL + MCPWM_RESOLUTION_HZ / 2U) / MCPWM_RESOLUTION_HZ); bits = 1; for (uint32_t ticks = periodTicks; ticks > 1U; ticks >>= 1U) ++bits; } if (!periodWithin(actualHz, hz, accuracyPct) || !periodWithin(actualPulseNs, pulseNs, accuracyPct)) return false; const float dutyPct = dutyFromPulse(actualHz, actualPulseNs); const uint32_t captureHz = plannedCaptureHz(actualHz, dutyPct); const uint32_t pulseCaptureHz = plannedPulseCaptureHz(actualHz, dutyPct); return captureHz && pulseCaptureHz && validateResolution(actualHz, dutyPct, accuracyPct, captureHz, pulseCaptureHz, bits, MEASUREMENT_AVERAGING_PERIODS) == FailReason::NONE; } uint32_t minimumPulseForAccuracy(uint32_t frequencyHz, float accuracyPct) { for (uint32_t pulseNs : TEST_PULSE_WIDTHS_NS) if (pulsePointHasResolution(frequencyHz, pulseNs, accuracyPct)) return pulseNs; return UINT32_MAX; } uint8_t firstMinPulseIndexAtLeast(uint32_t pulseNs, uint8_t last) { for (uint8_t i = 0; i <= last; ++i) if (MIN_PULSE_OPTIONS_NS[i] >= pulseNs) return i; return last; } uint8_t cycleIndex(uint8_t value, uint8_t first, uint8_t last, int direction) { if (first >= last) return first; if (direction > 0) return value >= last ? first : static_cast(value + 1U); return value <= first ? last : static_cast(value - 1U); } } App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), measurement_(receiver_) {} void App::begin() { Serial.begin(SERIAL_BAUD); #if ARDUINO_USB_CDC_ON_BOOT Serial.setTxTimeoutMs(SERIAL_TX_TIMEOUT_MS); #endif 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"); } sanitizeRange(); 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() ? "MCPWM 80MHz" : "GPIO cycle counter"); 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) { if (static_cast(now - localMeasurementDeadlineMs_) >= 0) { Log::event("MEASURE", "local stage watchdog expired"); measurement_.forceFail(FailReason::LOST_EDGE); } const MeasureState ms = measurement_.update(); if (ms == MeasureState::FAIL) { pwm_.stop(); printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); finish(false, measurement_.reason(), true); } else if (ms == MeasureState::PASS) { pwm_.stop(); printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); stagePassed(); } else if (measurement_.takeProgressUpdate()) { StageStats live = {}; if (measurement_.statsSnapshot(live)) showStageResult(live); } } 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() { if (settings_.role > static_cast(Role::SLAVE)) settings_.role = static_cast(Role::SOLO); settings_.frequencyIndex %= countOf(PWM_FREQUENCY_OPTIONS_HZ); settings_.maxPulseIndex %= countOf(MAX_PULSE_OPTIONS_NS); settings_.minPulseIndex %= countOf(MIN_PULSE_OPTIONS_NS); settings_.accuracyIndex %= countOf(ACCURACY_OPTIONS_PCT); settings_.timeIndex %= countOf(TEST_TIME_OPTIONS_MS); const uint32_t hz = PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex]; const uint8_t lastValid = lastValidMaxPulseIndex(hz); if (settings_.maxPulseIndex > lastValid) settings_.maxPulseIndex = lastValid; const uint8_t lastMin = lastMinPulseIndexAtMost(MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]); if (settings_.minPulseIndex > lastMin) settings_.minPulseIndex = lastMin; const uint8_t firstMin = firstMinPulseIndexAtLeast( minimumPulseForAccuracy(hz, ACCURACY_OPTIONS_PCT[settings_.accuracyIndex]), lastMin); if (settings_.minPulseIndex < firstMin) settings_.minPulseIndex = firstMin; } 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(); if (menuItem_ == 1) { const uint8_t last = lastValidMaxPulseIndex( PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex]); const uint8_t first = firstMaxPulseIndexAtLeast( MIN_PULSE_OPTIONS_NS[settings_.minPulseIndex], last); settings_.maxPulseIndex = cycleIndex(settings_.maxPulseIndex, first, last, d); } else if (menuItem_ == 2) { const uint8_t last = lastMinPulseIndexAtMost( MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]); const uint8_t first = firstMinPulseIndexAtLeast( minimumPulseForAccuracy(PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex], ACCURACY_OPTIONS_PCT[settings_.accuracyIndex]), last); settings_.minPulseIndex = cycleIndex(settings_.minPulseIndex, first, last, d); } else { uint8_t *value = nullptr; size_t count = 0; switch (menuItem_) { case 0: value = &settings_.frequencyIndex; count = countOf(PWM_FREQUENCY_OPTIONS_HZ); break; case 3: value = &settings_.accuracyIndex; count = countOf(ACCURACY_OPTIONS_PCT); break; case 4: value = &settings_.timeIndex; count = countOf(TEST_TIME_OPTIONS_MS); break; default: return; } *value = cycleIndex(*value, 0, static_cast(count - 1U), d); } sanitizeRange(); params_ = store_.params(settings_); Log::printf("ACTION", "menu item=%u changed direction=%+d frequency=%u max-pulse=%u min-pulse=%u accuracy=%u time=%u", menuItem_, d, settings_.frequencyIndex, settings_.maxPulseIndex, settings_.minPulseIndex, settings_.accuracyIndex, settings_.timeIndex); 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::formatPwmFrequency(params_.frequencyHz, value, sizeof(value)); label = UiText::MENU_FREQUENCY; break; case 1: Display::formatPulse(params_.maxPulseNs, value, sizeof(value)); label = UiText::MENU_MAX_PULSE; break; case 2: Display::formatPulse(params_.minPulseNs, value, sizeof(value)); label = UiText::MENU_MIN_PULSE; break; case 3: snprintf(value, sizeof(value), "+/-%g%%", params_.accuracyPct); label = UiText::MENU_ACCURACY; break; case 4: snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f); label = UiText::MENU_TEST_TIME; break; default: return; } 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_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs); stageIndex_ = 0; requestedHz_ = params_.frequencyHz; requestedPulseNs_ = 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) { Log::printf("CONFIG", "mode=%s frequency=%luHz pulse=%lu..%luns accuracy=%.2f%% time=%lums TX=%s RX=AUTO stages=%lu", roleName(static_cast(settings_.role)), params_.frequencyHz, params_.maxPulseNs, params_.minPulseNs, params_.accuracyPct, params_.testTimeMs, PWM_ACTIVE_LEVEL == HIGH ? "HIGH" : "LOW", 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_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs); requestedHz_ = params_.frequencyHz; requestedPulseNs_ = 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_ = params_.frequencyHz; requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, 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 pulse=%luns", GPIO_PWM, requestedHz_, requestedPulseNs_); if (!pwm_.start(requestedHz_, requestedPulseNs_, actual_)) { Log::printf("PWM", "START FAILED GPIO=%u requested=%luHz pulse=%luns; PWM setup failed", GPIO_PWM, requestedHz_, requestedPulseNs_); finish(false, FailReason::RESOLUTION); return false; } if (!periodWithin(actual_.actualHz, requestedHz_, params_.accuracyPct) || !periodWithin(actual_.actualPulseNs, requestedPulseNs_, params_.accuracyPct)) { Log::printf("PWM", "requested point cannot be generated within tolerance: requested=%luHz/%luns actual=%luHz/%luns tolerance=%.2f%%", requestedHz_, requestedPulseNs_, actual_.actualHz, actual_.actualPulseNs, params_.accuracyPct); finish(false, FailReason::RESOLUTION); return false; } const uint32_t plannedRxHz = receiver_.plannedTickHz(actual_.actualHz, actual_.actualDutyPct); const uint32_t plannedPulseRxHz = receiver_.plannedPulseTickHz( actual_.actualHz, actual_.actualDutyPct); const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct, plannedRxHz, plannedPulseRxHz, actual_.bits, MEASUREMENT_AVERAGING_PERIODS); if (resolution != FailReason::NONE) { Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u period-capture=%luHz pulse-capture=%luHz tolerance=%.3f%%", actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz, plannedPulseRxHz, effectiveTolerancePct(params_.accuracyPct)); finish(false, resolution); return false; } Log::printf("PWM", "stage=%lu/%lu requested=%luHz/%luns actual=%luHz/%luns duty=%.3f%% bits=%u STARTED", stageIndex_ + 1, stageCount_, requestedHz_, requestedPulseNs_, actual_.actualHz, actual_.actualPulseNs, 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%% period-capture=%luHz pulse-capture=%luHz settle=%u cycles window=%lums; every pulse validated", hz, duty, effectiveTolerancePct(params_.accuracyPct), receiver_.plannedTickHz(static_cast(hz + 0.5f), duty), receiver_.plannedPulseTickHz(static_cast(hz + 0.5f), duty), PWM_SETTLE_CYCLES, params_.testTimeMs); const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, MEASUREMENT_AVERAGING_PERIODS, PWM_SETTLE_CYCLES); const uint32_t nominalMs = stageWallTimeMs(params_.testTimeMs, static_cast(hz + 0.5f)); const uint64_t watchdogMs = static_cast(nominalMs) * 2ULL + 2000ULL; localMeasurementDeadlineMs_ = millis() + static_cast( watchdogMs > UINT32_MAX ? UINT32_MAX : watchdogMs); Log::printf("MEASURE", "receiver start %s, continuous edge capture", ok ? "OK" : "FAILED"); return ok; } void App::stagePassed() { Log::printf("TEST", "stage %lu/%lu PASS; PWM stopping", stageIndex_ + 1, stageCount_); pwm_.stop(); // With PWM already quiet it is safe to stop capture before clearing its // queue for the next pulse width. Never reset a FreeRTOS queue concurrently // with the capture ISR. if (static_cast(settings_.role) == Role::SOLO) receiver_.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_ = params_.frequencyHz; requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, 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_ = params_.frequencyHz; requestedPulseNs_ = 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.requestedPulseNs = requestedPulseNs_; p.actualHz = actual_.actualHz; p.actualPulseNs = actual_.actualPulseNs; 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_ = params_.frequencyHz; requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, 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; requestedPulseNs_ = r.packet.requestedPulseNs; 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; if (r.packet.requestedPulseNs) requestedPulseNs_ = r.packet.requestedPulseNs; actual_.actualHz = r.packet.actualHz ? r.packet.actualHz : requestedHz_; actual_.actualPulseNs = r.packet.actualPulseNs ? r.packet.actualPulseNs : requestedPulseNs_; actual_.actualDutyPct = dutyFromPulse(actual_.actualHz, actual_.actualPulseNs); 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_.actualPulseNs = r.packet.actualPulseNs; actual_.actualDutyPct = dutyFromPulse(actual_.actualHz, actual_.actualPulseNs); 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) { // Master sends ACK only after stopping its PWM. Disable capture for // every completed stage, so the next start can clear its queue without // racing the ISR (not only after the final stage). receiver_.stop(); 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) { if (static_cast(millis() - localMeasurementDeadlineMs_) >= 0) { Log::event("MEASURE", "Slave local stage watchdog expired"); measurement_.forceFail(FailReason::LOST_EDGE); } const MeasureState ms = measurement_.update(); if (measurement_.takeProgressUpdate()) { 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); } 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.actualPulseNs) packet.actualPulseNs = requestedPulseNs_; 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[32], one[64]; formatTarget(requestedHz_, requestedPulseNs_, target, sizeof(target)); snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target); display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_, roleCorner(Role::SLAVE)); armSlave(true); return; } // The result has already been acknowledged before a normal measurement // failure reaches here. Re-arm ESP-NOW immediately so a quick retry from // Master is not hidden behind the former two-second delay; preserve the // failure screen while listening. if (static_cast(settings_.role) == Role::SLAVE) { armSlave(true); return; } 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 target[32]; formatTarget(requestedHz_, requestedPulseNs_, target, sizeof(target)); snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target); 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 { // Never enter blocking light sleep while the settings screen is open. A // wake-up press is deliberately consumed by the button state machine, which // is useful in IDLE but makes menu navigation appear frozen. return initialized_ && (state_ == AppState::IDLE || 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; preparing light sleep"); Serial.flush(); delay(2); } 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) { 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) { leaveIdlePowerSave(true); delay(1); return; } const esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); const bool buttonWake = digitalRead(GPIO_BUTTON_START) == BUTTON_ACTIVE_LEVEL || digitalRead(GPIO_BUTTON_MODE) == BUTTON_ACTIVE_LEVEL; if (buttonWake) { startButton_.suppressUntilRelease(); modeButton_.suppressUntilRelease(); } // GPIO wake worked, so disarm all level sources before peripherals and the // button state machines are brought back up. gpio_wakeup_disable(static_cast(GPIO_BUTTON_START)); gpio_wakeup_disable(static_cast(GPIO_BUTTON_MODE)); gpio_wakeup_disable(static_cast(GPIO_RX)); // Native USB and I2C can retain stale driver state across light sleep even // though their clocks have stopped. A full end/begin cycle prevents the // several-second button stalls and restores Serial output after wake. setActivePerformance(false); Serial.end(); delay(2); Serial.begin(SERIAL_BAUD); #if ARDUINO_USB_CDC_ON_BOOT Serial.setTxTimeoutMs(SERIAL_TX_TIMEOUT_MS); #endif Wire.end(); Wire.begin(GPIO_SDA, GPIO_SCL); Wire.setClock(400000); Wire.setTimeOut(30); // This also restores ESP-NOW when Slave stopped it before sleeping. leaveIdlePowerSave(true); Log::printf("POWER", "light sleep wake cause=%u button=%s; peripherals restored", static_cast(cause), buttonWake ? "YES" : "NO"); if (buttonWake) Log::event("POWER", "wake button 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"; 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 Hz, pulse %lu..%lu ns, accuracy %.2f%%, %lums, RX AUTO\n", params_.frequencyHz, params_.maxPulseNs, params_.minPulseNs, params_.accuracyPct, params_.testTimeMs); stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs); Serial.printf("Pulse widths descending (%lu): ", stageCount_); for (uint32_t i = 0; i < stageCount_; ++i) Serial.printf("%lu%s", pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, i), i + 1 == stageCount_ ? " ns\n" : ","); Serial.printf("ALL nominal: %llu us | RX=%s\n", actualNominalTotalUs(), receiver_.highRateBackend() ? "MCPWM 80MHz" : "GPIO cycle counter"); } 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 uint32_t measuredPulseNs = static_cast(lround( static_cast(s.activeSum) * 1000000000.0 / (static_cast(receiver_.pulseTickHz()) * s.periods))); 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/%luns %s periods=%lu measured=%s/%luns skipped=%lu%s%s", requestedText, requestedPulseNs_, status, s.periods, measuredText, measuredPulseNs, 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]; formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); if (s.reason != FailReason::NONE) { formatFailure(s.reason, requestedHz_, requestedPulseNs_, one, sizeof(one)); if (s.reason == FailReason::PERIOD_OUT && s.badFrequency > 0.0f) { char frequency[12]; Display::formatFrequency(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 pulse[12]; Display::formatPulse(pulseFromDuty(s.badFrequency, s.badDuty), pulse, sizeof(pulse), true); snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, pulse); } 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; } 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 uint32_t measuredPulseNs = static_cast(lround( static_cast(s.activeSum) * 1000000000.0 / (static_cast(receiver_.pulseTickHz()) * s.periods))); formatMeasured(measuredHz, measuredPulseNs, two, sizeof(two)); 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 one[64], two[64]; formatTestTarget(packet.requestedHz, packet.requestedPulseNs, one, sizeof(one)); if (reason == FailReason::NONE) { if (packet.measuredHzX10) { formatMeasured(packet.measuredHzX10 / 10.0f, packet.measuredPulseNs, two, sizeof(two)); } else snprintf(two, sizeof(two), "%s", UiText::NO_MEASUREMENT); } else if (reason == FailReason::PERIOD_OUT && packet.measuredHzX10) { char frequency[12]; Display::formatFrequency(packet.measuredHzX10 / 10.0f, frequency, sizeof(frequency)); snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency); } else if (reason == FailReason::DUTY_OUT && packet.measuredPulseNs) { char pulse[12]; Display::formatPulse(packet.measuredPulseNs, pulse, sizeof(pulse), true); snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, pulse); } else { snprintf(two, sizeof(two), "%s", uiFailName(reason)); } if (reason != FailReason::NONE) formatFailure(reason, packet.requestedHz, packet.requestedPulseNs, one, sizeof(one)); 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; packet.measuredHzX10 = static_cast(lroundf(measuredHz * 10.0f)); packet.measuredPulseNs = badPeriod ? pulseFromDuty(measuredHz, stats.badDuty) : static_cast(lround(static_cast(stats.activeSum) * 1000000000.0 / (static_cast(receiver_.pulseTickHz()) * stats.periods))); } void App::showStageProgress() { char one[64]; formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, 0), overallProgressTotal(stageCount_)); }