#include "App.h" #include "Config.h" #include "Log.h" #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", "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"; } } 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"); printConfiguration(); showIdle(); } void App::update() { 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 (!initialized_) { if (!startButton_.pressed() || !modeButton_.pressed()) finishInitialization(false); else if (now - bootCheckStartedMs_ >= FACTORY_RESET_HOLD_MS) finishInitialization(true); return; } 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_); 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(); } return; } if (state_ == AppState::MENU) { if (modeEvent == ButtonEvent::SHORT) { menuItem_ = (menuItem_ + 1U) % 7U; 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(); 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); finish(false, measurement_.reason()); } else if (ms == MeasureState::PASS) { printStageStats(measurement_.stats(), actual_.actualHz); stagePassed(); } } else if (state_ == AppState::MASTER_DISCOVER || state_ == AppState::MASTER_WAIT_READY || state_ == AppState::MASTER_WAIT_RESULT) { handleRadio(); updateMaster(); } else { handleRadio(); updateSlave(); } } void App::showIdle() { char one[24]; snprintf(one, sizeof(one), "MODE: %s", roleName(static_cast(settings_.role))); display_.show(one, "START=RUN"); } void App::sanitizeRange() { settings_.startIndex %= countOf(START_FREQ_OPTIONS_HZ); settings_.endIndex %= countOf(END_FREQ_OPTIONS_HZ); if (END_FREQ_OPTIONS_HZ[settings_.endIndex] <= START_FREQ_OPTIONS_HZ[settings_.startIndex]) { size_t i = 0; while (i < countOf(END_FREQ_OPTIONS_HZ) && END_FREQ_OPTIONS_HZ[i] <= START_FREQ_OPTIONS_HZ[settings_.startIndex]) ++i; if (i == countOf(END_FREQ_OPTIONS_HZ)) { settings_.startIndex = 0; i = countOf(END_FREQ_OPTIONS_HZ) - 1; } settings_.endIndex = i; } } void App::changeMenu(int d) { 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_.stepIndex; count = countOf(STEP_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; case 5: value = &settings_.repeatIndex; count = countOf(REPEAT_OPTIONS); 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[22], two[22], all[12]; Display::formatDuration(actualNominalTotalUs(), all, sizeof(all)); switch (menuItem_) { case 0: snprintf(one, sizeof(one), "START FREQ"); Display::formatFrequency(params_.startHz, two, sizeof(two)); break; case 1: snprintf(one, sizeof(one), "END FREQ"); Display::formatFrequency(params_.endHz, two, sizeof(two)); break; case 2: snprintf(one, sizeof(one), "FREQ STEP"); Display::formatFrequency(params_.stepHz, two, sizeof(two)); break; case 3: snprintf(one, sizeof(one), "ACCURACY"); snprintf(two, sizeof(two), "+/-%g%%", params_.accuracyPct); break; case 4: snprintf(one, sizeof(one), "TEST TIME"); snprintf(two, sizeof(two), "%.1fs", params_.testTimeMs / 1000.0f); break; case 5: snprintf(one, sizeof(one), "REPEATS"); snprintf(two, sizeof(two), "%ux", params_.repeats); break; default: snprintf(one, sizeof(one), "PWM DUTY"); snprintf(two, sizeof(two), "%u%%", params_.dutyPct); break; } const size_t used = strlen(two); snprintf(two + used, sizeof(two) - used, " ALL %s", all); display_.show(one, two); } void App::startTest() { params_ = store_.params(settings_); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); stageIndex_ = 0; pendingReason_ = FailReason::NONE; 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], stepText[12]; Display::formatFrequency(params_.startHz, startText, sizeof(startText)); Display::formatFrequency(params_.endHz, endText, sizeof(endText)); Display::formatFrequency(params_.stepHz, stepText, sizeof(stepText)); Log::printf("CONFIG", "mode=%s range=%s..%s step=%s accuracy=%.2f%% time=%lums repeats=%u duty=%u%% stages=%lu", roleName(static_cast(settings_.role)), startText, endText, stepText, params_.accuracyPct, params_.testTimeMs, params_.repeats, 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("SLAVE READY", "WAIT MASTER"); } } bool App::prepareStage() { requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, stageIndex_); 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 FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct, receiver_.tickHz(), 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, receiver_.tickHz(), 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); char f[12], one[24], two[24]; Display::formatFrequency(actual_.actualHz, f, sizeof(f)); snprintf(one, sizeof(one), "F %s D %.1f%%", f, actual_.actualDutyPct); snprintf(two, sizeof(two), "%lu/%lu RUN", stageIndex_ + 1, stageCount_); display_.show(one, two); 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%% settle=%u cycles window=%lums x%u; per-pulse logging suspended", hz, duty, params_.accuracyPct, PWM_SETTLE_CYCLES, params_.testTimeMs, params_.repeats); const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, params_.repeats, 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, params_.stepHz, stageIndex_); 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; havePeer_ = false; radio_.flush(); pendingPacket_ = makePacket(MessageType::DISCOVER); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = millis(); deadlineMs_ = millis() + LINK_DISCOVERY_TIMEOUT_MS; retries_ = 0; state_ = AppState::MASTER_DISCOVER; Log::printf("ESP-NOW", "discovery started session=%08lX", session_); display_.show("MASTER SEARCH", "WAIT SLAVE"); } ProtocolPacket App::makePacket(MessageType type) const { ProtocolPacket p = {}; p.type = static_cast(type); p.session = session_; p.stage = stageIndex_; p.sequence = sequence_; p.requestedHz = requestedHz_; p.actualHz = actual_.actualHz; p.actualDutyX100 = static_cast(actual_.actualDutyPct * 100.0f + 0.5f); p.testTimeMs = params_.testTimeMs; p.repeats = params_.repeats; 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 = radio_.sendTo(peer_, pendingPacket_); lastSendMs_ = millis(); if (!ok) Log::printf("ESP-NOW", "sendCurrent %s FAILED", messageName(type)); } 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 (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 && type == MessageType::DISCOVER) { memcpy(peer_, r.mac, 6); havePeer_ = true; session_ = r.packet.session; stageIndex_ = 0; sequence_ = r.packet.sequence; ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; radio_.sendTo(peer_, ack); state_ = AppState::SLAVE_WAIT_START; display_.show("SLAVE LINKED", "WAIT PREPARE"); continue; } if (state_ == AppState::MASTER_DISCOVER && type == MessageType::DISCOVER_ACK && r.packet.session == session_) { memcpy(peer_, r.mac, 6); havePeer_ = true; requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, 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 (state_ == AppState::SLAVE_WAIT_START && type == MessageType::DISCOVER && r.packet.session == session_ && !memcmp(peer_, r.mac, 6)) { ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; radio_.sendTo(peer_, 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; radio_.sendTo(peer_, ack); continue; // idempotent ACK for a retried old result } if (!havePeer_ || memcmp(peer_, r.mac, 6) || !packetForCurrent(r.packet)) continue; if (type == MessageType::ABORT) { finish(false, FailReason::ABORTED); continue; } if (state_ == AppState::MASTER_WAIT_READY && type == MessageType::READY) { if (!prepareStage()) continue; sendCurrent(MessageType::START_STAGE); state_ = AppState::MASTER_WAIT_RESULT; retries_ = 0; deadlineMs_ = millis() + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS + (1000UL * PWM_SETTLE_CYCLES / actual_.actualHz) + 20; } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::RESULT) { ProtocolPacket ack = makePacket(MessageType::ACK); ack.sequence = r.packet.sequence; radio_.sendTo(peer_, ack); pwm_.stop(); if (!r.packet.passed) finish(false, static_cast(r.packet.reason)); else stagePassed(); } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::PREPARE) { params_.testTimeMs = r.packet.testTimeMs; params_.repeats = r.packet.repeats; params_.accuracyPct = r.packet.accuracyX100 / 100.0f; requestedHz_ = r.packet.requestedHz; ProtocolPacket ready = makePacket(MessageType::READY); ready.sequence = r.packet.sequence; radio_.sendTo(peer_, ready); } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) { actual_.actualHz = r.packet.actualHz; actual_.actualDutyPct = r.packet.actualDutyX100 / 100.0f; if (!startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); continue; } state_ = AppState::SLAVE_MEASURE; deadlineMs_ = millis() + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS; } else if (state_ == AppState::SLAVE_WAIT_ACK && type == MessageType::ACK && r.packet.sequence == pendingPacket_.sequence) { if (pendingPacket_.passed) { ++stageIndex_; state_ = AppState::SLAVE_WAIT_START; display_.show("SLAVE READY", "WAIT PREPARE"); } else finish(false, static_cast(pendingPacket_.reason)); } } } void App::updateMaster() { const uint32_t now = millis(); if (state_ == AppState::MASTER_DISCOVER) { if (now >= deadlineMs_) { finish(false, FailReason::LINK_LOST); return; } if (now - lastSendMs_ >= LINK_RETRY_INTERVAL_MS) { Log::printf("ESP-NOW", "DISCOVER retry=%u", retries_ + 1); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = now; ++retries_; } 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); radio_.sendTo(peer_, pendingPacket_); ++retries_; deadlineMs_ = now + (state_ == AppState::MASTER_WAIT_RESULT ? params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS : LINK_REPLY_TIMEOUT_MS); } void App::updateSlave() { if (state_ == AppState::SLAVE_MEASURE) { const MeasureState ms = measurement_.update(); if (ms != MeasureState::PASS && ms != MeasureState::FAIL) return; printStageStats(measurement_.stats(), actual_.actualHz); pendingPacket_ = makePacket(MessageType::RESULT); pendingPacket_.passed = ms == MeasureState::PASS && measurement_.reason() == FailReason::NONE; pendingPacket_.reason = static_cast(measurement_.reason()); pendingPacket_.periods = measurement_.stats().periods; pendingPacket_.minPeriodTicks = measurement_.stats().minPeriod; pendingPacket_.maxPeriodTicks = measurement_.stats().maxPeriod; pendingPacket_.sequence = ++sequence_; radio_.sendTo(peer_, 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_); radio_.sendTo(peer_, pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } } } void App::sendAbort() { if (havePeer_) sendCurrent(MessageType::ABORT); } void App::abortTest() { Log::event("ACTION", "abort requested: sending ABORT, stopping receiver and PWM"); sendAbort(); measurement_.abort(); finish(false, FailReason::ABORTED); } void App::finish(bool pass, FailReason reason) { Log::printf("TEST", "finishing result=%s reason=%s", pass ? "PASS" : "FAIL", failName(reason)); pwm_.stop(); receiver_.stop(); if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end(); state_ = AppState::FINISHED; pendingReason_ = reason; char one[24]; if (pass) display_.show("PASS", "REPEAT"); else { char frequency[12]; Display::formatFrequency(requestedHz_, frequency, sizeof(frequency)); snprintf(one, sizeof(one), "FAIL %s", frequency); display_.show(one, failName(reason)); } } 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 step %lu Hz, accuracy %.2f%%, %lums x%u, duty %u%%\n", params_.startHz, params_.endHz, params_.stepHz, params_.accuracyPct, params_.testTimeMs, params_.repeats, params_.dutyPct); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); Serial.printf("Frequencies (%lu): ", stageCount_); for (uint32_t i = 0; i < stageCount_; ++i) Serial.printf("%lu%s", frequencyAt(params_.startHz, params_.endHz, params_.stepHz, 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)); }