diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index 8740c80..9814e81 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -8,10 +8,13 @@ #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", @@ -26,9 +29,40 @@ const char *buttonEventName(ButtonEvent 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); +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) { @@ -81,12 +115,94 @@ uint32_t overallProgressTotal(uint32_t stageCount) { 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(); @@ -103,11 +219,12 @@ void App::finishInitialization(bool factoryReset) { } 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() ? "RMT DMA" : "RMT ping-pong"); + Log::printf("BOOT", "capture initialized: %s", receiver_.highRateBackend() ? "MCPWM 80MHz" : "GPIO cycle counter"); lastUserActivityMs_ = millis(); setActivePerformance(false); printConfiguration(); @@ -166,7 +283,7 @@ void App::update() { } if (state_ == AppState::MENU) { if (modeEvent == ButtonEvent::SHORT) { - menuItem_ = (menuItem_ + 1U) % 4U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu(); + 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_); @@ -179,20 +296,25 @@ void App::update() { 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 (ms == MeasureState::STEP_READY) { + } else if (measurement_.takeProgressUpdate()) { 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) { @@ -212,8 +334,21 @@ void App::showIdle() { } void App::sanitizeRange() { - settings_.startIndex %= countOf(START_FREQ_OPTIONS_HZ); - settings_.endIndex %= countOf(END_FREQ_OPTIONS_HZ); + 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() { @@ -231,17 +366,36 @@ void App::serviceRxPinStateLog() { 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: return; + 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); } - *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(); + 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() { @@ -250,23 +404,26 @@ void App::showMenu() { 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; + Display::formatPwmFrequency(params_.frequencyHz, value, sizeof(value)); + label = UiText::MENU_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; + 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; - default: + 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)); @@ -277,18 +434,17 @@ 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; + 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) { - 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_); + 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); @@ -305,8 +461,8 @@ bool App::armSlave(bool preserveDisplay) { pwm_.stop(); lastUserActivityMs_ = millis(); params_ = store_.params(settings_); - stageIndex_ = 0; stageCount_ = frequencyPointCount(params_.startHz, params_.endHz); - requestedHz_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false; + 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; @@ -323,29 +479,40 @@ bool App::armSlave(bool preserveDisplay) { } bool App::prepareStage(bool showProgress) { - requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_); + 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 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_); + 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, actual_.bits, + plannedRxHz, plannedPulseRxHz, actual_.bits, MEASUREMENT_AVERAGING_PERIODS); 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, + 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 actual=%luHz duty=%.2f%% bits=%u STARTED", - stageIndex_ + 1, stageCount_, requestedHz_, actual_.actualHz, actual_.actualDutyPct, actual_.bits); + 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; @@ -354,23 +521,33 @@ bool App::prepareStage(bool showProgress) { } bool App::startLocalMeasurement(float hz, float duty) { - Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% RX=%luHz settle=%u cycles window=%lums average=%u periods; per-pulse logging suspended", + 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), - PWM_SETTLE_CYCLES, params_.testTimeMs, MEASUREMENT_AVERAGING_PERIODS); + 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); - Log::printf("MEASURE", "receiver start %s, RMT chunk=%u symbols", ok ? "OK" : "FAILED", - receiver_.receiveChunkSymbols()); + 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_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_); + requestedHz_ = params_.frequencyHz; + requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_); actual_ = {}; stageStartConfirmed_ = false; pendingPacket_ = makePacket(MessageType::PREPARE); sendCurrent(MessageType::PREPARE); @@ -380,7 +557,8 @@ void App::stagePassed() { void App::startMasterDiscovery() { session_ = esp_random(); if (!session_) session_ = 1; - sequence_ = 1; stageIndex_ = 0; requestedHz_ = 0; havePeer_ = false; radio_.flush(); + sequence_ = 1; stageIndex_ = 0; requestedHz_ = params_.frequencyHz; + requestedPulseNs_ = 0; havePeer_ = false; radio_.flush(); opticalWakeActive_ = true; lastOpticalWakeToggleMs_ = millis(); pwm_.active(); @@ -394,9 +572,8 @@ 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.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; @@ -458,7 +635,8 @@ void App::handleRadio() { opticalWakeActive_ = false; pwm_.stop(); memcpy(peer_, r.mac, 6); havePeer_ = true; lastPeerSeenMs_ = lastHeartbeatMs_ = millis(); - requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_); + 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; } @@ -484,7 +662,8 @@ void App::handleRadio() { 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; + 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); @@ -498,8 +677,10 @@ void App::handleRadio() { 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_.actualDutyPct = r.packet.actualDutyX100 ? r.packet.actualDutyX100 / 100.0f : params_.dutyPct; + 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) { @@ -535,7 +716,8 @@ void App::handleRadio() { 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; + 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; @@ -549,6 +731,10 @@ void App::handleRadio() { 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); @@ -601,8 +787,12 @@ 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 (ms == MeasureState::STEP_READY) { + if (measurement_.takeProgressUpdate()) { StageStats live = {}; if (measurement_.statsSnapshot(live)) { ProtocolPacket progress = makePacket(MessageType::PROGRESS); @@ -611,7 +801,6 @@ void App::updateSlave() { progress.sequence = sequence_; sendLinked(progress); showStageResult(live); } - measurement_.continueAfterDisplay(); return; } if (ms != MeasureState::PASS && ms != MeasureState::FAIL) { @@ -641,7 +830,7 @@ void App::sendAbort(FailReason reason) { ++sequence_; ProtocolPacket packet = makePacket(MessageType::ABORT); packet.reason = static_cast(reason); - if (!packet.actualDutyX100) packet.actualDutyX100 = params_.dutyPct * 100U; + if (!packet.actualPulseNs) packet.actualPulseNs = requestedPulseNs_; sendLinked(packet); } @@ -675,10 +864,9 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { 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); + 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); @@ -700,10 +888,9 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { 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); + 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 { @@ -713,7 +900,10 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { } bool App::idlePowerSaveAllowed() const { - return initialized_ && (state_ == AppState::IDLE || state_ == AppState::MENU || + // 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); } @@ -767,7 +957,9 @@ void App::serviceIdlePowerSave() { idleSleepRadioStopped_ = true; } display_.setPower(false); - Log::event("POWER", "idle timeout; OLED off and light sleep started"); + Log::event("POWER", "idle timeout; preparing light sleep"); + Serial.flush(); + delay(2); } gpio_wakeup_enable(static_cast(GPIO_BUTTON_START), @@ -775,10 +967,6 @@ void App::serviceIdlePowerSave() { 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); @@ -786,25 +974,46 @@ void App::serviceIdlePowerSave() { esp_sleep_enable_gpio_wakeup(); const esp_err_t result = esp_light_sleep_start(); if (result != ESP_OK) { + leaveIdlePowerSave(true); 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"); - } + 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() { @@ -815,12 +1024,16 @@ void App::printConfiguration() { 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"); + 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() { @@ -833,28 +1046,32 @@ uint64_t App::actualNominalTotalUs() { 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; + 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 %s periods=%lu measured=%s duty=%.2f%% skipped=%lu%s%s", - requestedText, status, s.periods, measuredText, measuredDuty, s.droppedItems, + 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]; - char target[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target)); + formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); if (s.reason != FailReason::NONE) { - snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, actual_.actualDutyPct); + formatFailure(s.reason, requestedHz_, requestedPulseNs_, one, sizeof(one)); if (s.reason == FailReason::PERIOD_OUT && s.badFrequency > 0.0f) { char frequency[12]; - Display::formatTestFrequency(static_cast(lroundf(s.badFrequency)), frequency, sizeof(frequency)); + 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 duty[10]; formatErrorDuty(s.badDuty, duty, sizeof(duty)); - snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty); + 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)); } @@ -862,17 +1079,16 @@ void App::showStageResult(const StageStats &s) { 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); + 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_)); } @@ -880,31 +1096,25 @@ void App::showStageResult(const StageStats &s) { 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)); + char one[64], two[64]; + formatTestTarget(packet.requestedHz, packet.requestedPulseNs, one, sizeof(one)); 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); + 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) { - 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); + 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(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); 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))); @@ -918,16 +1128,15 @@ void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) co 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)); + 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 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); + char one[64]; + formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, 0), overallProgressTotal(stageCount_)); } diff --git a/OpticalChannelTester/App.h b/OpticalChannelTester/App.h index 21f5712..9386b0a 100644 --- a/OpticalChannelTester/App.h +++ b/OpticalChannelTester/App.h @@ -66,7 +66,7 @@ class App { AppState state_ = AppState::IDLE; uint8_t menuItem_ = 0; uint32_t stageIndex_ = 0, stageCount_ = 0; - uint32_t requestedHz_ = 0; + uint32_t requestedHz_ = 0, requestedPulseNs_ = 0; ActualPwm actual_ = {}; FailReason pendingReason_ = FailReason::NONE; uint32_t session_ = 0; @@ -74,6 +74,7 @@ class App { uint8_t peer_[6] = {}; bool havePeer_ = false; uint32_t deadlineMs_ = 0, lastSendMs_ = 0; + uint32_t localMeasurementDeadlineMs_ = 0; uint32_t lastHeartbeatMs_ = 0, lastPeerSeenMs_ = 0; uint8_t retries_ = 0; ProtocolPacket pendingPacket_ = {}; diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h index 7655bba..a22ee9f 100644 --- a/OpticalChannelTester/Config.h +++ b/OpticalChannelTester/Config.h @@ -17,8 +17,8 @@ constexpr uint32_t PWM_OUTPUT_TEST_FREQUENCY_HZ = 1000; constexpr uint32_t PWM_OUTPUT_TEST_SWEEP_PERIOD_MS = 2000; constexpr uint32_t PWM_OUTPUT_TEST_UPDATE_MS = 10; -constexpr uint8_t PWM_OUTPUT_TEST_MIN_DUTY_PCT = 5; -constexpr uint8_t PWM_OUTPUT_TEST_MAX_DUTY_PCT = 95; +constexpr uint32_t PWM_OUTPUT_TEST_MIN_PULSE_NS = 1000; +constexpr uint32_t PWM_OUTPUT_TEST_MAX_PULSE_NS = 10000; #if CONFIG_IDF_TARGET_ESP32C3 constexpr bool TARGET_IS_C3 = true; @@ -66,17 +66,21 @@ constexpr uint8_t OLED_ROTATION = 0; constexpr uint8_t OLED_ADDRESS = 0x3C; constexpr uint8_t ESPNOW_WIFI_CHANNEL = 6; constexpr uint32_t SERIAL_BAUD = 115200; +// Native USB CDC may keep a stale "connected" state after light sleep. Keep +// logging non-blocking so a missing host can never delay button polling. +constexpr uint32_t SERIAL_TX_TIMEOUT_MS = 2; constexpr bool SERIAL_ACTION_LOG = true; constexpr bool SERIAL_LOG_TIMESTAMPS = true; constexpr bool SERIAL_MINIMAL_LOG = true; #define BUTTON_ACTIVE_LEVEL LOW // Raw GPIO_RX level that means the optical receiver is active. -#define RX_ACTIVE_LEVEL LOW -// PWM_SAFE_LEVEL must switch the optical transmitter fully off and is used -// during tests whenever PWM is stopped, and while the controller sleeps. -// PWM_ACTIVE_LEVEL intentionally keeps the transmitter active while the -// controller is awake and no test is in progress. +#define RX_ACTIVE_LEVEL HIGH +// PWM_ACTIVE_LEVEL is the electrical level of the active test pulse and is +// also used for the constant active output while awake outside a test. During +// the remainder of a running PWM period the output is !PWM_ACTIVE_LEVEL. +// PWM_SAFE_LEVEL is used only while PWM is stopped and during sleep; it is +// independent of the PWM inactive level and may equal PWM_ACTIVE_LEVEL. #define PWM_SAFE_LEVEL HIGH #define PWM_ACTIVE_LEVEL LOW #define PWM_SETTLE_CYCLES 5U @@ -99,14 +103,10 @@ constexpr uint32_t LINK_HEARTBEAT_TIMEOUT_MS = 2500; constexpr uint32_t FINAL_ACK_RETRY_INTERVAL_MS = 50; constexpr uint8_t FINAL_ACK_RETRIES = 2; constexpr uint8_t NO_SIGNAL_TIMEOUT_PERIODS = 8; -constexpr uint16_t RMT_MIN_RECEIVE_SYMBOLS = 48; -constexpr uint16_t RMT_MAX_RECEIVE_SYMBOLS = 512; -constexpr uint32_t RMT_TARGET_CHUNK_US = 5000; -constexpr uint8_t RMT_QUEUE_BLOCKS = 8; constexpr uint16_t PERIOD_BATCH_SIZE = 128; -// Frequency and duty are validated only by their averages over this many -// complete periods. Individual tick variation is retained for diagnostics but -// is not itself a test failure. +// Retained as the minimum statistical depth used by the hardware-resolution +// calculation and diagnostics. PASS/FAIL is evaluated for every complete +// pulse independently; accumulated values are used only for display. constexpr uint16_t MEASUREMENT_AVERAGING_PERIODS = 100; static_assert(MEASUREMENT_AVERAGING_PERIODS > 0, "Averaging window must contain at least one period"); @@ -123,38 +123,40 @@ constexpr uint32_t RX_PROCESSING_PERIODS_PER_SECOND = 300000; constexpr uint32_t C3_STRICT_MAX_HZ = 1000000; constexpr uint32_t S3_STRICT_MAX_HZ = 1000000; -// RMT stores each HIGH/LOW duration in 15 bits. Select the fastest clock that -// still fits both levels of the current PWM signal: 20, 40 or 80 MHz. -constexpr uint32_t CAPTURE_RESOLUTION_OPTIONS_HZ[] = {20000000, 40000000, 80000000}; -constexpr uint32_t RMT_MAX_LEVEL_TICKS = 32766; +// S3 MCPWM Capture uses one 32-bit 80 MHz timer for both edges. Unlike RMT, +// its width does not constrain long LOW/HIGH intervals, so capture precision +// stays at 12.5 ns for every selectable PWM frequency and pulse length. +constexpr uint32_t MCPWM_CAPTURE_RESOLUTION_HZ = 80000000; // C3 uses the 40 MHz crystal as the LEDC clock. // Keep this explicit so the resolution calculation never asks LEDC for an // impossible frequency/resolution combination. constexpr uint32_t LEDC_SOURCE_CLOCK_HZ = 40000000; constexpr uint8_t LEDC_CHANNEL = 0; constexpr uint8_t LEDC_MAX_BITS = 14; -// S3 uses the dedicated MCPWM peripheral. A 40 MHz timer clock keeps the -// longest 1 kHz period within the S3's 16-bit MCPWM counter and makes every -// frequency in TEST_FREQUENCIES_HZ exact. -constexpr uint32_t MCPWM_RESOLUTION_HZ = 40000000; +// S3 uses the dedicated MCPWM peripheral. A 20 MHz timer clock keeps the +// selectable 500 Hz period within the S3's 16-bit counter while retaining +// 50 ns pulse resolution and exact periods for every menu frequency. +constexpr uint32_t MCPWM_RESOLUTION_HZ = 20000000; constexpr uint32_t MCPWM_MAX_PERIOD_TICKS = 65535; // -------------------------- Menu value arrays ----------------------------- -// START and END deliberately have separate, independently cycling menu lists. -// Every value is exactly achievable from a 40 MHz timer clock. The test walks -// TEST_FREQUENCIES_HZ between the selected endpoints, so there is no -// separately configurable step. -constexpr uint32_t START_FREQ_OPTIONS_HZ[] = {1000, 10000, 100000}; -constexpr uint32_t END_FREQ_OPTIONS_HZ[] = {100000, 500000, 1000000}; - -// All achievable whole-number frequencies in the supported 1 kHz..1 MHz -// range, used for adjacent test stages rather than direct menu selection. -constexpr uint32_t TEST_FREQUENCIES_HZ[] = { - 1000, 2000, 5000, 10000, 25000, 50000, - 100000, 200000, 312500, 400000, 500000, 625000, 800000, 1000000 +// The test uses one selected PWM frequency and walks the pulse-width list from +// the selected maximum down to the selected minimum. Widths are stored in +// nanoseconds so sub-microsecond pulses remain representable without floats. +constexpr uint32_t PWM_FREQUENCY_OPTIONS_HZ[] = { + 500, 1000, 2000, 5000, 10000, 25000, +}; +constexpr uint32_t MAX_PULSE_OPTIONS_NS[] = { + 20000, 50000, 100000, 200000, 500000 +}; +constexpr uint32_t MIN_PULSE_OPTIONS_NS[] = { + 250, 500, 1000, 2000, 5000, 10000 +}; +constexpr uint32_t TEST_PULSE_WIDTHS_NS[] = { + 250, 500, 1000, 2000, 5000, 10000, 20000, 50000, + 100000, 200000, 500000, 1000000 }; constexpr float ACCURACY_OPTIONS_PCT[] = {1.0f, 2.0f, 5.0f, 10.0f}; constexpr uint32_t TEST_TIME_OPTIONS_MS[] = {100, 250, 500, 1000, 2000, 5000}; -constexpr uint8_t TEST_DUTY_PCT = 50; template constexpr size_t countOf(const T (&)[N]) { return N; } diff --git a/OpticalChannelTester/Config_Text.h b/OpticalChannelTester/Config_Text.h index 370cfde..12ad205 100644 --- a/OpticalChannelTester/Config_Text.h +++ b/OpticalChannelTester/Config_Text.h @@ -21,7 +21,7 @@ constexpr const char *FAIL_NAMES[] = { "НЕТ ОШИБКИ", "НЕТ СИГНАЛА", "ПЕРИОД ВНЕ ДОПУСКА", - "ЗАПОЛН. ВНЕ ДОПУСКА", + "ИМПУЛЬС ВНЕ ДОПУСКА", "ЛИШНИЙ ФРОНТ", "ИМПУЛЬСНАЯ ПОМЕХА", "ПРОПУЩЕН ФРОНТ", @@ -35,8 +35,9 @@ constexpr const char *FAIL_NAMES[] = { constexpr const char *MODE_PREFIX = "РЕЖИМ: "; constexpr const char *START_RUN = "ГОТОВ К ЗАПУСКУ"; -constexpr const char *MENU_START_FREQUENCY = "ЧАСТОТА ОТ:"; -constexpr const char *MENU_END_FREQUENCY = "ЧАСТОТА ДО:"; +constexpr const char *MENU_FREQUENCY = "ЧАСТОТА ШИМ:"; +constexpr const char *MENU_MAX_PULSE = "МАКС. ИМПУЛЬС:"; +constexpr const char *MENU_MIN_PULSE = "МИН. ИМПУЛЬС:"; constexpr const char *MENU_ACCURACY = "ТОЧНОСТЬ:"; constexpr const char *MENU_TEST_TIME = "ВРЕМЯ ВЫБОРКИ:"; constexpr const char *MENU_TOTAL_TIME = "ОБЩЕЕ ВРЕМЯ:"; @@ -54,11 +55,11 @@ constexpr const char *START_AGAIN = "ГОТОВ К ЗАПУСКУ"; constexpr const char *TEST_FAILED = "ТЕСТ НЕ ПРОЙДЕН"; constexpr const char *PASS_WORD = "ТЕСТ ПРОЙДЕН"; -constexpr const char *FAIL_FORMAT = "СБОЙ %s %.0f%%"; -constexpr const char *TEST_FORMAT = "Тест:%-6s %2.0f%% %5s"; -constexpr const char *PERIOD_OUT_FORMAT = "ОШИБКА ЧАСТОТЫ %s"; -constexpr const char *DUTY_OUT_FORMAT = "ОШИБКА ЗАПОЛН. %s"; -constexpr const char *NO_MEASUREMENT = "F:--- D:---%"; +constexpr const char *FAIL_FORMAT = "СБОЙ %s"; +constexpr const char *TEST_FORMAT = "%s, %s"; +constexpr const char *PERIOD_OUT_FORMAT = "FREQ OUT %s"; +constexpr const char *DUTY_OUT_FORMAT = "PULSE OUT %s"; +constexpr const char *NO_MEASUREMENT = "F:---, P:---"; #elif UI_LANGUAGE == UI_LANGUAGE_EN @@ -70,7 +71,7 @@ constexpr const char *FAIL_NAMES[] = { "NONE", "NO SIGNAL", "PERIOD OUT", - "DUTY OUT", + "PULSE OUT", "EXTRA EDGE", "GLITCH", "LOST EDGE", @@ -84,8 +85,9 @@ constexpr const char *FAIL_NAMES[] = { constexpr const char *MODE_PREFIX = "MODE: "; constexpr const char *START_RUN = "READY TO START"; -constexpr const char *MENU_START_FREQUENCY = "START FREQ:"; -constexpr const char *MENU_END_FREQUENCY = "END FREQ:"; +constexpr const char *MENU_FREQUENCY = "PWM FREQUENCY:"; +constexpr const char *MENU_MAX_PULSE = "MAX PULSE:"; +constexpr const char *MENU_MIN_PULSE = "MIN PULSE:"; constexpr const char *MENU_ACCURACY = "ACCURACY:"; constexpr const char *MENU_TEST_TIME = "TEST TIME:"; constexpr const char *MENU_TOTAL_TIME = "TOTAL TIME:"; @@ -103,11 +105,11 @@ constexpr const char *START_AGAIN = "READY TO START"; constexpr const char *TEST_FAILED = "TEST FAILED"; constexpr const char *PASS_WORD = "TEST PASS"; -constexpr const char *FAIL_FORMAT = "FAIL %s %.0f%%"; -constexpr const char *TEST_FORMAT = "Test:%-6s %2.0f%% %5s"; -constexpr const char *PERIOD_OUT_FORMAT = "PERIOD OUT %s"; -constexpr const char *DUTY_OUT_FORMAT = "DUTY OUT %s"; -constexpr const char *NO_MEASUREMENT = "F:--- D:---%"; +constexpr const char *FAIL_FORMAT = "FAIL %s"; +constexpr const char *TEST_FORMAT = "%s, %s"; +constexpr const char *PERIOD_OUT_FORMAT = "FREQ OUT %s"; +constexpr const char *DUTY_OUT_FORMAT = "PULSE OUT %s"; +constexpr const char *NO_MEASUREMENT = "F:---, P:---"; #else #error "UI_LANGUAGE must be UI_LANGUAGE_EN or UI_LANGUAGE_RU" diff --git a/OpticalChannelTester/Core.cpp b/OpticalChannelTester/Core.cpp index fae1b46..7e39809 100644 --- a/OpticalChannelTester/Core.cpp +++ b/OpticalChannelTester/Core.cpp @@ -10,7 +10,7 @@ const char *roleName(Role r) { } const char *failName(FailReason r) { - static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "DUTY OUT", + static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "PULSE OUT", "EXTRA EDGE", "GLITCH", "LOST EDGE", "DATA LOSS ERROR", "LINK LOST", "UNSUPPORTED", "RESOLUTION", "ABORTED"}; const uint8_t i = static_cast(r); @@ -31,19 +31,19 @@ uint32_t settingsChecksum(const Settings &s) { return hash; } -uint32_t frequencyPointCount(uint32_t startHz, uint32_t endHz) { - if (!startHz || endHz <= startHz) return 0; +uint32_t pulseWidthPointCount(uint32_t maxPulseNs, uint32_t minPulseNs) { + if (!minPulseNs || maxPulseNs < minPulseNs) return 0; uint32_t count = 0; - for (size_t i = 0; i < countOf(TEST_FREQUENCIES_HZ); ++i) - if (TEST_FREQUENCIES_HZ[i] >= startHz && TEST_FREQUENCIES_HZ[i] <= endHz) ++count; + for (size_t i = 0; i < countOf(TEST_PULSE_WIDTHS_NS); ++i) + if (TEST_PULSE_WIDTHS_NS[i] >= minPulseNs && TEST_PULSE_WIDTHS_NS[i] <= maxPulseNs) ++count; return count; } -uint32_t frequencyAt(uint32_t startHz, uint32_t endHz, uint32_t index) { - for (size_t i = 0; i < countOf(TEST_FREQUENCIES_HZ); ++i) { - const uint32_t frequency = TEST_FREQUENCIES_HZ[i]; - if (frequency < startHz || frequency > endHz) continue; - if (!index--) return frequency; +uint32_t pulseWidthAt(uint32_t maxPulseNs, uint32_t minPulseNs, uint32_t index) { + for (size_t i = countOf(TEST_PULSE_WIDTHS_NS); i > 0; --i) { + const uint32_t pulseNs = TEST_PULSE_WIDTHS_NS[i - 1U]; + if (pulseNs < minPulseNs || pulseNs > maxPulseNs) continue; + if (!index--) return pulseNs; } return 0; } @@ -58,26 +58,17 @@ uint64_t nominalStageUs(uint32_t frequencyHz, uint32_t sampleTimeMs, uint32_t se RX_PROCESSING_PERIODS_PER_SECOND - 1U) / RX_PROCESSING_PERIODS_PER_SECOND; const uint64_t samplingWallUs = processingUs > sampleUs ? processingUs : sampleUs; - uint64_t chunkSymbols = - (static_cast(frequencyHz) * RMT_TARGET_CHUNK_US + 999999ULL) / 1000000ULL; - if (chunkSymbols < RMT_MIN_RECEIVE_SYMBOLS) chunkSymbols = RMT_MIN_RECEIVE_SYMBOLS; - if (chunkSymbols > RMT_MAX_RECEIVE_SYMBOLS) chunkSymbols = RMT_MAX_RECEIVE_SYMBOLS; - const uint64_t batchWaitUs = - ((chunkSymbols * 1000000ULL + frequencyHz - 1U) / frequencyHz) * MEASUREMENT_PROGRESS_STEPS; const uint64_t settleUs = (1000000ULL * settleCycles * MEASUREMENT_PROGRESS_STEPS + frequencyHz - 1U) / frequencyHz; // Initial stage screen, nine intermediate screens and the final result. const uint64_t displayUs = static_cast(OLED_PROGRESS_UPDATE_MS) * 1000ULL * (MEASUREMENT_PROGRESS_STEPS + 1U); - return samplingWallUs + batchWaitUs + settleUs + displayUs; + return samplingWallUs + settleUs + displayUs; } uint64_t nominalTotalUs(const TestParams &p, uint32_t settleCycles) { - uint64_t total = 0; - const uint32_t count = frequencyPointCount(p.startHz, p.endHz); - for (uint32_t i = 0; i < count; ++i) - total += nominalStageUs(frequencyAt(p.startHz, p.endHz, i), p.testTimeMs, settleCycles); - return total; + return static_cast(pulseWidthPointCount(p.maxPulseNs, p.minPulseNs)) * + nominalStageUs(p.frequencyHz, p.testTimeMs, settleCycles); } bool periodWithin(float measured, float expected, float tolerance) { @@ -116,49 +107,54 @@ uint8_t chooseStablePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz, return fallback; } -bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz, - uint8_t maxBits, uint8_t dutyPct, - IntegerPwmConfig &config) { - if (!requestedHz || !sourceClockHz || !maxBits || dutyPct > 100U) return false; +bool choosePwmConfig(uint32_t requestedHz, uint32_t requestedPulseNs, + uint32_t sourceClockHz, uint8_t maxBits, + IntegerPwmConfig &config) { + if (!requestedHz || !requestedPulseNs || !sourceClockHz || !maxBits) return false; + constexpr uint32_t FRACTION_SCALE = 256U; + constexpr uint32_t MAX_DIVIDER_RAW = 1024U * FRACTION_SCALE - 1U; bool found = false; - uint32_t bestErrorHz = 0; - uint32_t bestDutyError = 0; - uint32_t bestLevels = 1; + uint32_t bestFrequencyError = UINT32_MAX; + uint32_t bestPulseError = UINT32_MAX; for (uint8_t bits = 1; bits <= maxBits && bits < 31; ++bits) { const uint32_t levels = 1UL << bits; - for (uint32_t divider = 1; divider <= 1023U; ++divider) { - const uint32_t denominator = levels * divider; - // A fixed integer divider gives identical PWM periods. Requiring an - // exact division also guarantees that the physical frequency is a - // whole number of hertz rather than a rounded value. - if (sourceClockHz % denominator) continue; - const uint32_t actualHz = sourceClockHz / denominator; - const uint32_t errorHz = actualHz > requestedHz - ? actualHz - requestedHz : requestedHz - actualHz; - const uint32_t dutyCount = (static_cast(levels) * dutyPct + 50U) / 100U; - const uint32_t representedDuty = dutyCount * 100U; - const uint32_t requestedDuty = levels * dutyPct; - const uint32_t dutyError = representedDuty > requestedDuty - ? representedDuty - requestedDuty : requestedDuty - representedDuty; + const uint64_t dividerNumerator = static_cast(sourceClockHz) * FRACTION_SCALE; + const uint64_t dividerDenominator = static_cast(requestedHz) * levels; + const uint32_t dividerFloor = static_cast(dividerNumerator / dividerDenominator); + const uint32_t candidates[] = {dividerFloor, dividerFloor + 1U}; + for (uint32_t dividerRaw : candidates) { + if (dividerRaw < FRACTION_SCALE || dividerRaw > MAX_DIVIDER_RAW) continue; + const uint64_t frequencyDenominator = static_cast(levels) * dividerRaw; + const uint32_t actualHz = static_cast( + (dividerNumerator + frequencyDenominator / 2U) / frequencyDenominator); + if (!actualHz) continue; - const bool frequencyBetter = !found || errorHz < bestErrorHz; - const bool frequencyEqual = found && errorHz == bestErrorHz; - const bool dutyBetter = frequencyEqual && - static_cast(dutyError) * bestLevels < - static_cast(bestDutyError) * levels; - const bool dutyEqual = frequencyEqual && - static_cast(dutyError) * bestLevels == - static_cast(bestDutyError) * levels; - if (!frequencyBetter && !dutyBetter && !(dutyEqual && bits > config.bits)) continue; + const uint64_t dutyNumerator = static_cast(requestedPulseNs) * + sourceClockHz * FRACTION_SCALE; + const uint64_t dutyDenominator = static_cast(dividerRaw) * 1000000000ULL; + uint32_t dutyCount = static_cast((dutyNumerator + dutyDenominator / 2U) / + dutyDenominator); + if (!dutyCount) dutyCount = 1U; + if (dutyCount >= levels) dutyCount = levels - 1U; + if (!dutyCount) continue; - config.actualHz = actualHz; - config.divider = static_cast(divider); - config.bits = bits; - bestErrorHz = errorHz; - bestDutyError = dutyError; - bestLevels = levels; + const uint32_t actualPulseNs = static_cast( + (static_cast(dutyCount) * dividerRaw * 1000000000ULL + + static_cast(sourceClockHz) * FRACTION_SCALE / 2U) / + (static_cast(sourceClockHz) * FRACTION_SCALE)); + const uint32_t frequencyError = actualHz > requestedHz ? actualHz - requestedHz : requestedHz - actualHz; + const uint32_t pulseError = actualPulseNs > requestedPulseNs + ? actualPulseNs - requestedPulseNs : requestedPulseNs - actualPulseNs; + if (found && (frequencyError > bestFrequencyError || + (frequencyError == bestFrequencyError && pulseError > bestPulseError) || + (frequencyError == bestFrequencyError && pulseError == bestPulseError && bits <= config.bits))) + continue; + + config = {actualHz, dividerRaw, dutyCount, actualPulseNs, bits}; + bestFrequencyError = frequencyError; + bestPulseError = pulseError; found = true; } } @@ -166,40 +162,70 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz, } FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct, - uint32_t captureHz, uint8_t pwmBits, + uint32_t periodCaptureHz, uint32_t pulseCaptureHz, + uint8_t pwmBits, uint16_t averagingPeriods) { - if (!frequencyHz || !captureHz || !pwmBits || !averagingPeriods) + if (!frequencyHz || !periodCaptureHz || !pulseCaptureHz || !pwmBits || !averagingPeriods) return FailReason::RESOLUTION; - const float periodTicks = static_cast(captureHz) / frequencyHz; - const float activeTicks = periodTicks * dutyPct / 100.0f; - const float inactiveTicks = periodTicks - activeTicks; - if (periodTicks < 4.0f || activeTicks < 2.0f || inactiveTicks < 2.0f) return FailReason::RESOLUTION; - const float averagedTicks = periodTicks * averagingPeriods; - const float timerPeriodError = 100.0f / averagedTicks; - const float timerDutyError = 100.0f / averagedTicks; + const float periodTicks = static_cast(periodCaptureHz) / frequencyHz; + const float activeTicks = static_cast(pulseCaptureHz) * dutyPct / + (100.0f * frequencyHz); + if (periodTicks < 4.0f || activeTicks < 2.0f) return FailReason::RESOLUTION; + const float timerPeriodError = 100.0f / periodTicks; + const float timerPulseError = 100.0f / activeTicks; // Measurement uses the duty actually programmed into LEDC. A coarse PWM // step is not itself an error when the requested value (e.g. 50%) is exactly // representable; only the selected value's actual quantization matters. const float effectiveAccuracy = effectiveTolerancePct(accuracyPct); - return (timerPeriodError > effectiveAccuracy || timerDutyError > effectiveAccuracy) + return (timerPeriodError > effectiveAccuracy || timerPulseError > effectiveAccuracy) ? FailReason::RESOLUTION : FailReason::NONE; } FailReason evaluatePeriod(const PulsePeriod &p, uint32_t tickHz, float expectedHz, float expectedDuty, float tolerance, uint8_t repeat, StageStats &s) { - if (!p.periodTicks || p.activeTicks >= p.periodTicks) return FailReason::EXTRA_EDGE; + const uint32_t pulseTickHz = p.activeTickHz ? p.activeTickHz : tickHz; + if (!p.periodTicks || !p.activeTicks || !tickHz || !pulseTickHz || expectedHz <= 0.0f) + return FailReason::EXTRA_EDGE; const float hz = static_cast(tickHz) / p.periodTicks; - const float duty = 100.0f * p.activeTicks / p.periodTicks; + const float duty = static_cast(100.0 * p.activeTicks * tickHz / + (static_cast(pulseTickHz) * p.periodTicks)); ++s.periods; s.periodSum += p.periodTicks; s.activeSum += p.activeTicks; if (p.periodTicks < s.minPeriod) s.minPeriod = p.periodTicks; if (p.periodTicks > s.maxPeriod) s.maxPeriod = p.periodTicks; if (p.activeTicks < s.minActive) s.minActive = p.activeTicks; if (p.activeTicks > s.maxActive) s.maxActive = p.activeTicks; - FailReason reason = FailReason::NONE; - if (!periodWithin(hz, expectedHz, tolerance)) reason = FailReason::PERIOD_OUT; - else if (!dutyWithin(duty, expectedDuty, tolerance)) reason = FailReason::DUTY_OUT; + // Validate every complete period independently. A single capture tick is the + // unavoidable endpoint uncertainty, so only that one tick may be corrected + // toward the expected value. It cannot hide a larger isolated distortion. + bool frequencyOk = periodWithin(hz, expectedHz, tolerance); + if (!frequencyOk) { + uint32_t correctedPeriod = p.periodTicks; + if (hz > expectedHz) ++correctedPeriod; + else if (correctedPeriod > 1U) --correctedPeriod; + frequencyOk = periodWithin(static_cast(tickHz) / correctedPeriod, + expectedHz, tolerance); + } + + const double expectedPulseTicks = static_cast(pulseTickHz) * expectedDuty / + (100.0 * expectedHz); + bool pulseOk = expectedPulseTicks > 0.0 && + fabs(static_cast(p.activeTicks) - expectedPulseTicks) * 100.0 / + expectedPulseTicks <= tolerance + 0.0001; + if (!pulseOk) { + uint32_t correctedActive = p.activeTicks; + if (correctedActive > expectedPulseTicks) { + if (correctedActive) --correctedActive; + } else { + ++correctedActive; + } + pulseOk = fabs(static_cast(correctedActive) - expectedPulseTicks) * + 100.0 / expectedPulseTicks <= tolerance + 0.0001; + } + + const FailReason reason = !frequencyOk ? FailReason::PERIOD_OUT : + (!pulseOk ? FailReason::DUTY_OUT : FailReason::NONE); if (reason != FailReason::NONE && s.reason == FailReason::NONE) { s.reason = reason; s.firstBadPeriod = s.periods; s.firstBadRepeat = repeat; s.badFrequency = hz; s.badDuty = duty; @@ -275,7 +301,7 @@ FailReason evaluatePeriodWindow(uint64_t periodSum, uint64_t activeSum, 100.0 * static_cast(activeSum) / periodSum); bool frequencyOk = periodWithin(hz, expectedHz, tolerance); if (!frequencyOk && maxPeriod == minPeriod + 1U) { - // At a tolerance boundary, alternating adjacent RMT counts prove that the + // At a tolerance boundary, alternating adjacent capture counts prove that the // result is quantization-limited. Accept only when a one-tick correction // toward the expected value returns the averaged frequency into tolerance. // Consecutive periods telescope into one first-to-last edge interval, so @@ -289,24 +315,28 @@ FailReason evaluatePeriodWindow(uint64_t periodSum, uint64_t activeSum, frequencyOk = periodWithin(correctedHz, expectedHz, tolerance); } - bool dutyOk = dutyWithin(duty, expectedDuty, tolerance); - if (!dutyOk) { + const double expectedPulseTicks = static_cast(tickHz) * expectedDuty / + (100.0 * expectedHz); + const double measuredPulseTicks = static_cast(activeSum) / periodCount; + bool pulseOk = expectedPulseTicks > 0.0 && + fabs(measuredPulseTicks - expectedPulseTicks) * 100.0 / expectedPulseTicks <= tolerance + 0.0001; + if (!pulseOk) { // Unlike full periods, active intervals do not telescope: every pulse is // bounded by a different rising/falling edge pair. With slowly drifting // asynchronous clocks an entire short window can therefore quantize to // the same adjacent count (e.g. 41/80 for a true 50% duty). Apply one tick // per active interval even when minActive == maxActive. - const bool dutyHigh = duty > expectedDuty; + const bool dutyHigh = measuredPulseTicks > expectedPulseTicks; const uint64_t correctedActive = dutyHigh ? (activeSum > periodCount ? activeSum - periodCount : 0U) : activeSum + periodCount; - const float correctedDuty = static_cast( - 100.0 * static_cast(correctedActive) / periodSum); - dutyOk = dutyWithin(correctedDuty, expectedDuty, tolerance); + const double correctedPulseTicks = static_cast(correctedActive) / periodCount; + pulseOk = fabs(correctedPulseTicks - expectedPulseTicks) * 100.0 / + expectedPulseTicks <= tolerance + 0.0001; } FailReason reason = !frequencyOk ? FailReason::PERIOD_OUT : - (!dutyOk ? FailReason::DUTY_OUT : FailReason::NONE); + (!pulseOk ? FailReason::DUTY_OUT : FailReason::NONE); if (reason != FailReason::NONE && s.reason == FailReason::NONE) { s.reason = reason; s.firstBadPeriod = s.periods >= periodCount ? s.periods - periodCount + 1U : 1U; diff --git a/OpticalChannelTester/Core.h b/OpticalChannelTester/Core.h index 37da304..00ccfab 100644 --- a/OpticalChannelTester/Core.h +++ b/OpticalChannelTester/Core.h @@ -15,26 +15,27 @@ const char *failName(FailReason reason); struct Settings { uint16_t version; uint8_t role; - uint8_t startIndex; - uint8_t endIndex; + uint8_t frequencyIndex; + uint8_t maxPulseIndex; + uint8_t minPulseIndex; uint8_t accuracyIndex; uint8_t timeIndex; - uint8_t reserved; uint32_t checksum; }; struct TestParams { - uint32_t startHz; - uint32_t endHz; + uint32_t frequencyHz; + uint32_t maxPulseNs; + uint32_t minPulseNs; float accuracyPct; uint32_t testTimeMs; - uint8_t dutyPct; }; struct PulsePeriod { uint64_t startTick; uint32_t periodTicks; uint32_t activeTicks; + uint32_t activeTickHz; }; struct StageStats { @@ -63,13 +64,15 @@ struct PeriodLimits { struct IntegerPwmConfig { uint32_t actualHz; - uint16_t divider; + uint32_t dividerRaw; + uint32_t dutyCount; + uint32_t actualPulseNs; uint8_t bits; }; uint32_t settingsChecksum(const Settings &s); -uint32_t frequencyPointCount(uint32_t startHz, uint32_t endHz); -uint32_t frequencyAt(uint32_t startHz, uint32_t endHz, uint32_t index); +uint32_t pulseWidthPointCount(uint32_t maxPulseNs, uint32_t minPulseNs); +uint32_t pulseWidthAt(uint32_t maxPulseNs, uint32_t minPulseNs, uint32_t index); uint64_t nominalStageUs(uint32_t frequencyHz, uint32_t sampleTimeMs, uint32_t settleCycles); uint64_t nominalTotalUs(const TestParams &p, uint32_t settleCycles); bool periodWithin(float measuredHz, float expectedHz, float tolerancePct); @@ -79,11 +82,12 @@ uint8_t choosePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz, uint8_t maxBits); uint8_t chooseStablePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz, uint8_t maxBits, uint8_t dutyPct); -bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz, - uint8_t maxBits, uint8_t dutyPct, +bool choosePwmConfig(uint32_t requestedHz, uint32_t requestedPulseNs, + uint32_t sourceClockHz, uint8_t maxBits, IntegerPwmConfig &config); FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct, - uint32_t captureResolutionHz, uint8_t pwmBits, + uint32_t periodResolutionHz, uint32_t pulseResolutionHz, + uint8_t pwmBits, uint16_t averagingPeriods); FailReason evaluatePeriod(const PulsePeriod &period, uint32_t tickHz, float expectedHz, float expectedDuty, float tolerancePct, uint8_t repeat, diff --git a/OpticalChannelTester/Display.cpp b/OpticalChannelTester/Display.cpp index 3712363..10f6284 100644 --- a/OpticalChannelTester/Display.cpp +++ b/OpticalChannelTester/Display.cpp @@ -26,6 +26,7 @@ Display::Display() : oled_(128, 32, &Wire, -1) {} bool Display::begin() { Wire.begin(GPIO_SDA, GPIO_SCL); Wire.setClock(400000); // keeps a full 128x32 framebuffer update near 15 ms + Wire.setTimeOut(30); // a faulty/stretched I2C bus must not stall button polling for seconds // An absent optional OLED produces a large burst of ESP-IDF NACK messages. // Probe it once and keep the I2C driver quiet when no display is connected. esp_log_level_set("i2c.master", ESP_LOG_NONE); @@ -103,7 +104,7 @@ void Display::show(const char *a, const char *b, uint32_t progress, void Display::formatFrequency(float hz, char *out, size_t n) { float value = hz; const char *suffix = "Hz"; if (hz >= 999950.0f) { value = hz / 1000000.0f; suffix = "M"; } - else if (hz >= 1000.0f) { value = hz / 1000.0f; suffix = "k"; } + else if (hz >= 999.5f) { value = hz / 1000.0f; suffix = "k"; } if (suffix[0] == 'M' && fabsf(value - roundf(value)) < 0.0005f) snprintf(out, n, "%.0f%s", value, suffix); else if (value >= 100.0f) snprintf(out, n, "%.1f%s", value, suffix); @@ -125,6 +126,30 @@ void Display::formatTestFrequency(uint32_t hz, char *out, size_t n) { snprintf(out, n, "%lu", hz); } +void Display::formatPwmFrequency(uint32_t hz, char *out, size_t n) { + if (hz >= 1000000U && hz % 1000000U == 0U) + snprintf(out, n, "%luMHz", hz / 1000000U); + else if (hz >= 1000U && hz % 1000U == 0U) + snprintf(out, n, "%lukHz", hz / 1000U); + else if (hz >= 1000U) + snprintf(out, n, "%.3gkHz", hz / 1000.0f); + else + snprintf(out, n, "%luHz", hz); +} + +void Display::formatPulse(uint32_t pulseNs, char *out, size_t n, bool measured) { + if (pulseNs >= 1000U) { + const float us = pulseNs / 1000.0f; + if (measured) snprintf(out, n, "%.2fu", us); + else if (pulseNs % 1000U == 0U) snprintf(out, n, "%luus", pulseNs / 1000U); + else snprintf(out, n, "%.2fus", us); + } else if (measured) { + snprintf(out, n, "%.3fu", pulseNs / 1000.0f); + } else { + snprintf(out, n, "%luns", pulseNs); + } +} + void Display::formatDuration(uint64_t us, char *out, size_t n) { const uint64_t totalSeconds = (us + 999999ULL) / 1000000ULL; const uint64_t minutes = totalSeconds / 60ULL; diff --git a/OpticalChannelTester/Display.h b/OpticalChannelTester/Display.h index 969e962..ecef275 100644 --- a/OpticalChannelTester/Display.h +++ b/OpticalChannelTester/Display.h @@ -15,7 +15,9 @@ class Display { bool powered() const { return powered_; } static void formatFrequency(float hz, char *out, size_t size); static void formatTestFrequency(uint32_t hz, char *out, size_t size); - static void formatDuration(uint64_t us, char *out, size_t size); + static void formatPwmFrequency(uint32_t hz, char *out, size_t size); + static void formatPulse(uint32_t pulseNs, char *out, size_t size, bool measured = false); + static void formatDuration(uint64_t us, char *out, size_t size); private: void drawTextLine(const char *text, int16_t y, int16_t startX = 0); Adafruit_SSD1306 oled_; diff --git a/OpticalChannelTester/Log.cpp b/OpticalChannelTester/Log.cpp index cf0cfe6..dd51e80 100644 --- a/OpticalChannelTester/Log.cpp +++ b/OpticalChannelTester/Log.cpp @@ -7,7 +7,8 @@ namespace Log { void event(const char *component, const char *message) { if (!SERIAL_ACTION_LOG) return; if (SERIAL_MINIMAL_LOG && strcmp(component, "INPUT") && strcmp(component, "UI") && - strcmp(component, "CONFIG") && strcmp(component, "RESULT") && strcmp(component, "ESP-NOW")) return; + strcmp(component, "CONFIG") && strcmp(component, "RESULT") && strcmp(component, "CAPTURE") && + strcmp(component, "ESP-NOW")) return; if (SERIAL_LOG_TIMESTAMPS) Serial.printf("[%10lu][%-8s] %s\n", millis(), component, message); else Serial.printf("[%-8s] %s\n", component, message); } diff --git a/OpticalChannelTester/Measurement.cpp b/OpticalChannelTester/Measurement.cpp index ca551eb..17db2e5 100644 --- a/OpticalChannelTester/Measurement.cpp +++ b/OpticalChannelTester/Measurement.cpp @@ -12,13 +12,12 @@ bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs, !receiver_.start(expectedHz_, expectedDutyPct_)) return false; settleCycles_ = settleCycles; settleLeft_ = settleCycles; tolerancePct_ = tolerance; - averagingPeriods_ = averagingPeriods; - resetAveragingWindow(); stepTimeMs_ = (timeMs + MEASUREMENT_PROGRESS_STEPS - 1U) / MEASUREMENT_PROGRESS_STEPS; stepTicks_ = static_cast(receiver_.tickHz()) * timeMs / (1000ULL * MEASUREMENT_PROGRESS_STEPS); if (!stepTicks_) stepTicks_ = 1; currentStep_ = 0; + __atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE); stats_.reset(); publishStats(); measurementStartTick_ = deadlineTick_ = 0; startedMs_ = millis(); @@ -30,13 +29,6 @@ bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs, return true; } -void Measurement::resetAveragingWindow() { - windowPeriodCount_ = 0; - windowPeriodSum_ = windowActiveSum_ = 0; - windowMinPeriod_ = UINT32_MAX; - windowMaxPeriod_ = 0; -} - void Measurement::taskEntry(void *context) { static_cast(context)->taskLoop(); } @@ -50,30 +42,33 @@ void Measurement::taskLoop() { void Measurement::fail(FailReason reason) { if (stats_.reason == FailReason::NONE) stats_.reason = reason; + __atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE); publishStats(); - receiver_.stop(); state_ = MeasureState::FAIL; + // App stops PWM first and only then disables capture. Disabling MCPWM from + // this RX task while input edges are still arriving can race its ISR. + state_ = MeasureState::FAIL; } void Measurement::completeMeasurement() { - receiver_.stop(); - stats_.droppedItems += receiver_.takeDroppedItems(); - if (receiver_.overflowed()) { fail(FailReason::GLITCH); return; } - if (windowPeriodCount_) { - const FailReason result = evaluatePeriodWindow( - windowPeriodSum_, windowActiveSum_, windowPeriodCount_, receiver_.tickHz(), - expectedHz_, expectedDutyPct_, tolerancePct_, - windowMinPeriod_, windowMaxPeriod_, - currentStep_ + 1U, stats_); - if (result != FailReason::NONE) { fail(result); return; } - } + const uint32_t dropped = receiver_.takeDroppedItems(); + stats_.droppedItems += dropped; + if (dropped) { fail(FailReason::DATA_LOSS); return; } publishStats(); if (++currentStep_ < MEASUREMENT_PROGRESS_STEPS) { - state_ = MeasureState::STEP_READY; + // Capture and validation continue while the main task draws OLED. Pausing + // here would overflow the edge queue at higher PWM frequencies; stopping + // MCPWM Capture can race an edge ISR. Publish a snapshot, then advance the + // edge-based window without interrupting the RX pipeline. + __atomic_store_n(&progressUpdatePending_, true, __ATOMIC_RELEASE); + measurementStartTick_ = deadlineTick_; + deadlineTick_ += stepTicks_; + measurementStartMs_ = lastPeriodMs_ = millis(); return; } if (!stats_.periods) { fail(FailReason::DATA_LOSS); return; } + __atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE); state_ = MeasureState::PASS; } @@ -92,11 +87,12 @@ bool Measurement::statsSnapshot(StageStats &out) const { MeasureState Measurement::processOnce() { if (state_ != MeasureState::SETTLING && state_ != MeasureState::RUNNING) return state_; - if (receiver_.overflowed()) { fail(FailReason::GLITCH); return state_; } bool receivedPeriod = false; for (;;) { const size_t periodCount = receiver_.readPeriods(periodBatch_, PERIOD_BATCH_SIZE, pdMS_TO_TICKS(2)); - stats_.droppedItems += receiver_.takeDroppedItems(); + const uint32_t dropped = receiver_.takeDroppedItems(); + stats_.droppedItems += dropped; + if (dropped) { fail(FailReason::DATA_LOSS); return state_; } if (!periodCount) break; receivedPeriod = true; for (size_t periodIndex = 0; periodIndex < periodCount; ++periodIndex) { @@ -111,55 +107,32 @@ MeasureState Measurement::processOnce() { } continue; } - const uint64_t endTick = period.startTick + period.periodTicks; if (period.startTick < measurementStartTick_) continue; // leading incomplete period - if (endTick > deadlineTick_) { completeMeasurement(); return state_; } // trailing incomplete period - if (!period.periodTicks || period.activeTicks >= period.periodTicks) { + while (period.startTick >= deadlineTick_) { + // Progress boundaries never discard a pulse. A complete period is + // assigned by its start edge, then validated exactly once. The nine + // intermediate boundaries only publish UI snapshots. + completeMeasurement(); + if (state_ != MeasureState::RUNNING) return state_; + } + if (!period.periodTicks || !period.activeTicks || !period.activeTickHz) { fail(FailReason::EXTRA_EDGE); return state_; } - ++stats_.periods; - stats_.periodSum += period.periodTicks; - stats_.activeSum += period.activeTicks; - if (period.periodTicks < stats_.minPeriod) stats_.minPeriod = period.periodTicks; - if (period.periodTicks > stats_.maxPeriod) stats_.maxPeriod = period.periodTicks; - if (period.activeTicks < stats_.minActive) stats_.minActive = period.activeTicks; - if (period.activeTicks > stats_.maxActive) stats_.maxActive = period.activeTicks; - - if (period.periodTicks < windowMinPeriod_) windowMinPeriod_ = period.periodTicks; - if (period.periodTicks > windowMaxPeriod_) windowMaxPeriod_ = period.periodTicks; - ++windowPeriodCount_; - windowPeriodSum_ += period.periodTicks; - windowActiveSum_ += period.activeTicks; - if (windowPeriodCount_ >= averagingPeriods_) { - const FailReason result = evaluatePeriodWindow( - windowPeriodSum_, windowActiveSum_, windowPeriodCount_, receiver_.tickHz(), - expectedHz_, expectedDutyPct_, tolerancePct_, - windowMinPeriod_, windowMaxPeriod_, - currentStep_ + 1U, stats_); - resetAveragingWindow(); - if (result != FailReason::NONE) { fail(result); return state_; } - } + const FailReason result = evaluatePeriod(period, receiver_.tickHz(), + expectedHz_, expectedDutyPct_, tolerancePct_, currentStep_ + 1U, stats_); + if (result != FailReason::NONE) { fail(result); return state_; } } } if (receivedPeriod && state_ == MeasureState::RUNNING) lastPeriodMs_ = millis(); const uint64_t edgeBasedTimeout = static_cast(PWM_SETTLE_CYCLES + NO_SIGNAL_TIMEOUT_PERIODS) * expectedPeriodMs_ + 20; - const uint64_t rmtBatchTimeout = - static_cast(RMT_MIN_RECEIVE_SYMBOLS + NO_SIGNAL_TIMEOUT_PERIODS) * expectedPeriodMs_ + 20; - const uint64_t settleTimeout = edgeBasedTimeout > rmtBatchTimeout ? edgeBasedTimeout : rmtBatchTimeout; + const uint64_t settleTimeout = edgeBasedTimeout; if (state_ == MeasureState::SETTLING && millis() - startedMs_ > settleTimeout) fail(FailReason::NO_SIGNAL); if (state_ == MeasureState::RUNNING && measurementStartTick_) { const uint32_t now = millis(); - // RMT reports a block only after its user buffer has filled. At 1 kHz the - // minimum 48-symbol C3 block contains roughly 48 PWM periods and therefore - // arrives much later than the old 8-period timeout. Do not call that - // normal batching delay a lost edge. - const uint32_t batchPeriods = receiver_.receiveChunkSymbols(); - const uint32_t batchTimeoutMs = expectedPeriodMs_ * (batchPeriods + NO_SIGNAL_TIMEOUT_PERIODS) + 2U; const uint32_t edgeTimeoutMs = expectedPeriodMs_ * NO_SIGNAL_TIMEOUT_PERIODS + 2U; - const uint32_t receiveTimeoutMs = batchTimeoutMs > edgeTimeoutMs ? batchTimeoutMs : edgeTimeoutMs; - if (now - measurementStartMs_ < stepTimeMs_ && now - lastPeriodMs_ > receiveTimeoutMs) { + if (now - measurementStartMs_ < stepTimeMs_ && now - lastPeriodMs_ > edgeTimeoutMs) { fail(FailReason::LOST_EDGE); return state_; } if (now - measurementStartMs_ > stepTimeMs_ + expectedPeriodMs_ + 2) completeMeasurement(); @@ -169,24 +142,16 @@ MeasureState Measurement::processOnce() { MeasureState Measurement::update() { return state_; } -bool Measurement::continueAfterDisplay() { - if (state_ != MeasureState::STEP_READY) return false; - if (!receiver_.start(expectedHz_, expectedDutyPct_)) { - fail(FailReason::UNSUPPORTED); - return false; - } - // receiver_.start() starts a new RMT timebase and therefore a new sampling - // phase. Start a fresh averaging window for the new continuous capture. - resetAveragingWindow(); - settleLeft_ = settleCycles_; - measurementStartTick_ = deadlineTick_ = 0; - startedMs_ = millis(); measurementStartMs_ = lastPeriodMs_ = 0; - state_ = MeasureState::SETTLING; - xTaskNotifyGive(task_); - return true; +bool Measurement::takeProgressUpdate() { + return __atomic_exchange_n(&progressUpdatePending_, false, __ATOMIC_ACQ_REL); } void Measurement::abort() { - if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING || - state_ == MeasureState::STEP_READY) fail(FailReason::ABORTED); + if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING) + fail(FailReason::ABORTED); +} + +void Measurement::forceFail(FailReason reason) { + if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING) + fail(reason); } diff --git a/OpticalChannelTester/Measurement.h b/OpticalChannelTester/Measurement.h index a17fb1e..eb9d718 100644 --- a/OpticalChannelTester/Measurement.h +++ b/OpticalChannelTester/Measurement.h @@ -1,7 +1,7 @@ #pragma once #include "Receiver.h" -enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, STEP_READY, PASS, FAIL }; +enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, PASS, FAIL }; class Measurement { public: @@ -10,8 +10,9 @@ class Measurement { uint32_t testTimeMs, uint16_t averagingPeriods, uint8_t settleCycles); MeasureState update(); - bool continueAfterDisplay(); + bool takeProgressUpdate(); void abort(); + void forceFail(FailReason reason); MeasureState state() const { return state_; } FailReason reason() const { return stats_.reason; } const StageStats &stats() const { return stats_; } @@ -24,7 +25,6 @@ class Measurement { void fail(FailReason reason); void completeMeasurement(); void publishStats(); - void resetAveragingWindow(); PulseReceiver &receiver_; volatile MeasureState state_ = MeasureState::IDLE; TaskHandle_t task_ = nullptr; @@ -33,14 +33,11 @@ class Measurement { mutable portMUX_TYPE statsMux_ = portMUX_INITIALIZER_UNLOCKED; uint32_t expectedHz_ = 0; float expectedDutyPct_ = 0.0f, tolerancePct_ = 0.0f; - uint16_t averagingPeriods_ = 1; - uint32_t windowPeriodCount_ = 0; - uint64_t windowPeriodSum_ = 0, windowActiveSum_ = 0; - uint32_t windowMinPeriod_ = UINT32_MAX, windowMaxPeriod_ = 0; uint8_t settleCycles_ = 0, settleLeft_ = 0; uint64_t measurementStartTick_ = 0, deadlineTick_ = 0, stepTicks_ = 0; uint32_t startedMs_ = 0, measurementStartMs_ = 0, lastPeriodMs_ = 0; uint32_t stepTimeMs_ = 1, expectedPeriodMs_ = 1; volatile uint8_t currentStep_ = 0; + volatile bool progressUpdatePending_ = false; PulsePeriod periodBatch_[PERIOD_BATCH_SIZE] = {}; }; diff --git a/OpticalChannelTester/OpticalChannelTester.ino b/OpticalChannelTester/OpticalChannelTester.ino index 97a1d6d..cd6eaca 100644 --- a/OpticalChannelTester/OpticalChannelTester.ino +++ b/OpticalChannelTester/OpticalChannelTester.ino @@ -4,24 +4,24 @@ #ifdef PWM_OUTPUT_TEST PwmGenerator pwmOutputTest; -uint8_t pwmOutputTestDuty = 50; +uint32_t pwmOutputTestPulseNs = PWM_OUTPUT_TEST_MIN_PULSE_NS; uint32_t pwmOutputTestUpdatedMs = 0; void setup() { Serial.begin(SERIAL_BAUD); delay(200); - Serial.printf("\nPWM OUTPUT TEST: GPIO=%u requested=%luHz duty=%u..%u%% sine=%lums safe=%s\n", + Serial.printf("\nPWM OUTPUT TEST: GPIO=%u requested=%luHz pulse=%lu..%luns sine=%lums safe=%s\n", GPIO_PWM, PWM_OUTPUT_TEST_FREQUENCY_HZ, - PWM_OUTPUT_TEST_MIN_DUTY_PCT, PWM_OUTPUT_TEST_MAX_DUTY_PCT, + PWM_OUTPUT_TEST_MIN_PULSE_NS, PWM_OUTPUT_TEST_MAX_PULSE_NS, PWM_OUTPUT_TEST_SWEEP_PERIOD_MS, PWM_SAFE_LEVEL == HIGH ? "HIGH" : "LOW"); pwmOutputTest.begin(); ActualPwm actual = {}; if (pwmOutputTest.start(PWM_OUTPUT_TEST_FREQUENCY_HZ, - pwmOutputTestDuty, actual)) { - Serial.printf("PWM OUTPUT TEST STARTED: actual=%luHz duty=%.2f%% bits=%u\n", - actual.actualHz, actual.actualDutyPct, actual.bits); + pwmOutputTestPulseNs, actual)) { + Serial.printf("PWM OUTPUT TEST STARTED: actual=%luHz pulse=%luns bits=%u\n", + actual.actualHz, actual.actualPulseNs, actual.bits); } else { Serial.println("PWM OUTPUT TEST FAILED"); } @@ -35,16 +35,16 @@ void loop() { constexpr float PWM_TWO_PI = 6.28318530718f; const float phase = PWM_TWO_PI * (now % PWM_OUTPUT_TEST_SWEEP_PERIOD_MS) / PWM_OUTPUT_TEST_SWEEP_PERIOD_MS; - const float center = (PWM_OUTPUT_TEST_MIN_DUTY_PCT + PWM_OUTPUT_TEST_MAX_DUTY_PCT) * 0.5f; - const float amplitude = (PWM_OUTPUT_TEST_MAX_DUTY_PCT - PWM_OUTPUT_TEST_MIN_DUTY_PCT) * 0.5f; - const uint8_t duty = static_cast(center + amplitude * sinf(phase) + 0.5f); - if (duty == pwmOutputTestDuty) return; + const float center = (PWM_OUTPUT_TEST_MIN_PULSE_NS + PWM_OUTPUT_TEST_MAX_PULSE_NS) * 0.5f; + const float amplitude = (PWM_OUTPUT_TEST_MAX_PULSE_NS - PWM_OUTPUT_TEST_MIN_PULSE_NS) * 0.5f; + const uint32_t pulseNs = static_cast(center + amplitude * sinf(phase) + 0.5f); + if (pulseNs == pwmOutputTestPulseNs) return; ActualPwm actual = {}; - if (pwmOutputTest.start(PWM_OUTPUT_TEST_FREQUENCY_HZ, duty, actual)) { - pwmOutputTestDuty = duty; + if (pwmOutputTest.start(PWM_OUTPUT_TEST_FREQUENCY_HZ, pulseNs, actual)) { + pwmOutputTestPulseNs = pulseNs; } else { - Serial.printf("PWM OUTPUT TEST UPDATE FAILED: duty=%u%%\n", duty); + Serial.printf("PWM OUTPUT TEST UPDATE FAILED: pulse=%luns\n", pulseNs); delay(100); } } diff --git a/OpticalChannelTester/Protocol.h b/OpticalChannelTester/Protocol.h index 71d8cd4..7c6fc8d 100644 --- a/OpticalChannelTester/Protocol.h +++ b/OpticalChannelTester/Protocol.h @@ -2,7 +2,7 @@ #include "Core.h" constexpr uint16_t PROTOCOL_MAGIC = 0x4F43; -constexpr uint8_t PROTOCOL_VERSION = 8; +constexpr uint8_t PROTOCOL_VERSION = 11; enum class MessageType : uint8_t { DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT, @@ -21,8 +21,9 @@ struct ProtocolPacket { uint16_t stageCount; uint16_t sequence; uint32_t requestedHz; + uint32_t requestedPulseNs; uint32_t actualHz; - uint16_t actualDutyX100; + uint32_t actualPulseNs; uint32_t testTimeMs; uint16_t accuracyX100; uint8_t settleCycles; @@ -31,14 +32,14 @@ struct ProtocolPacket { uint8_t reason; uint32_t periods; uint32_t measuredHzX10; - uint16_t measuredDutyX10; + uint32_t measuredPulseNs; uint32_t minPeriodTicks; uint32_t maxPeriodTicks; uint16_t crc; }; #pragma pack(pop) -static_assert(sizeof(ProtocolPacket) == 54, "Protocol layout changed"); +static_assert(sizeof(ProtocolPacket) == 62, "Protocol layout changed"); uint16_t packetCrc(const ProtocolPacket &packet); void finalizePacket(ProtocolPacket &packet); diff --git a/OpticalChannelTester/Pwm.cpp b/OpticalChannelTester/Pwm.cpp index 9931a01..c615937 100644 --- a/OpticalChannelTester/Pwm.cpp +++ b/OpticalChannelTester/Pwm.cpp @@ -12,20 +12,20 @@ namespace { constexpr ledc_mode_t PWM_SPEED_MODE = LEDC_LOW_SPEED_MODE; constexpr ledc_timer_t PWM_TIMER = LEDC_TIMER_0; -void setIntegerDivider(uint16_t divider) { +void setDivider(uint32_t dividerRaw) { ledc_dev_t *hardware = LEDC_LL_GET_HW(); ledc_ll_timer_pause(hardware, PWM_SPEED_MODE, PWM_TIMER); ledc_ll_set_clock_divider(hardware, PWM_SPEED_MODE, PWM_TIMER, - static_cast(divider) << LEDC_LL_FRACTIONAL_BITS); + dividerRaw); ledc_ll_timer_rst(hardware, PWM_SPEED_MODE, PWM_TIMER); ledc_ll_ls_timer_update(hardware, PWM_SPEED_MODE, PWM_TIMER); ledc_ll_timer_resume(hardware, PWM_SPEED_MODE, PWM_TIMER); } -bool integerDividerIsSet(uint16_t expected) { +bool dividerIsSet(uint32_t expectedRaw) { uint32_t rawDivider = 0; ledc_ll_get_clock_divider(LEDC_LL_GET_HW(), PWM_SPEED_MODE, PWM_TIMER, &rawDivider); - return rawDivider == (static_cast(expected) << LEDC_LL_FRACTIONAL_BITS); + return rawDivider == expectedRaw; } #elif CONFIG_IDF_TARGET_ESP32S3 mcpwm_timer_handle_t mcpwmTimer = nullptr; @@ -34,6 +34,11 @@ mcpwm_cmpr_handle_t mcpwmComparator = nullptr; mcpwm_gen_handle_t mcpwmGenerator = nullptr; uint32_t mcpwmFrequencyHz = 0; +constexpr mcpwm_generator_action_t PWM_ACTIVE_ACTION = + PWM_ACTIVE_LEVEL == HIGH ? MCPWM_GEN_ACTION_HIGH : MCPWM_GEN_ACTION_LOW; +constexpr mcpwm_generator_action_t PWM_INACTIVE_ACTION = + PWM_ACTIVE_LEVEL == HIGH ? MCPWM_GEN_ACTION_LOW : MCPWM_GEN_ACTION_HIGH; + void releaseMcpwm() { if (mcpwmGenerator) { mcpwm_del_generator(mcpwmGenerator); @@ -94,10 +99,10 @@ void PwmGenerator::begin() { timerConfig.period_ticks / 2U) == ESP_OK; ok = ok && mcpwm_generator_set_action_on_timer_event(mcpwmGenerator, MCPWM_GEN_TIMER_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP, - MCPWM_TIMER_EVENT_EMPTY, MCPWM_GEN_ACTION_HIGH)) == ESP_OK; + MCPWM_TIMER_EVENT_EMPTY, PWM_ACTIVE_ACTION)) == ESP_OK; ok = ok && mcpwm_generator_set_action_on_compare_event(mcpwmGenerator, MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP, - mcpwmComparator, MCPWM_GEN_ACTION_LOW)) == ESP_OK; + mcpwmComparator, PWM_INACTIVE_ACTION)) == ESP_OK; ok = ok && mcpwm_timer_enable(mcpwmTimer) == ESP_OK; if (!ok) { releaseMcpwm(); @@ -109,23 +114,29 @@ void PwmGenerator::begin() { #endif } -bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) { +bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) { #if CONFIG_IDF_TARGET_ESP32C3 IntegerPwmConfig config = {}; - if (!chooseIntegerPwmConfig(hz, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, dutyPct, config)) return false; + if (!choosePwmConfig(hz, pulseNs, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, config)) return false; const uint8_t bits = config.bits; const uint32_t levels = 1UL << bits; - const uint32_t duty = (static_cast(levels) * dutyPct + 50U) / 100U; + const uint32_t duty = config.dutyCount; for (uint8_t attempt = 0; attempt < 2; ++attempt) { stop(); const bool attached = ledcAttachChannel(GPIO_PWM, config.actualHz, bits, LEDC_CHANNEL); if (attached) { - // Arduino's LEDC API normally chooses an 8-bit fractional divider. - // Force the fractional byte to zero so every PWM period contains the - // same integer number of 40 MHz source-clock ticks. - setIntegerDivider(config.divider); + // Native LEDC produces a HIGH pulse. Invert the GPIO matrix output when + // the configured active pulse level is LOW. + if (!ledcOutputInvert(GPIO_PWM, PWM_ACTIVE_LEVEL == LOW)) { + ledcDetach(GPIO_PWM); + delay(2); + continue; + } + // Apply the selected Q10.8 divider explicitly. This lets pulse width, + // rather than duty percentage, drive the hardware quantization. + setDivider(config.dividerRaw); } - if (attached && integerDividerIsSet(config.divider) && ledcWriteChannel(LEDC_CHANNEL, duty)) { + if (attached && dividerIsSet(config.dividerRaw) && ledcWriteChannel(LEDC_CHANNEL, duty)) { // On the first configuration after power-up the duty update is latched // on a timer edge. Reading immediately can therefore return zero. uint32_t settleUs = static_cast((2000000ULL + hz - 1U) / hz); @@ -133,7 +144,8 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) { delayMicroseconds(settleUs); const uint32_t actualHz = ledcReadFreq(GPIO_PWM); if (actualHz == config.actualHz) { - a = {hz, actualHz, 100.0f * duty / levels, bits}; + a = {hz, actualHz, pulseNs, config.actualPulseNs, + 100.0f * duty / levels, bits}; running_ = true; return true; } @@ -144,11 +156,13 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) { pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL); return false; #elif CONFIG_IDF_TARGET_ESP32S3 - if (!mcpwmTimer || !mcpwmComparator || !mcpwmGenerator || !hz || dutyPct > 100U || + if (!mcpwmTimer || !mcpwmComparator || !mcpwmGenerator || !hz || !pulseNs || 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(periodTicks) * dutyPct + 50U) / 100U; + uint32_t activeTicks = static_cast( + (static_cast(pulseNs) * MCPWM_RESOLUTION_HZ + 500000000ULL) / + 1000000000ULL); if (activeTicks == 0U) activeTicks = 1U; if (activeTicks >= periodTicks) activeTicks = periodTicks - 1U; @@ -165,7 +179,11 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) { return false; } - a = {hz, hz, 100.0f * activeTicks / periodTicks, periodResolutionBits(periodTicks)}; + const uint32_t actualPulseNs = static_cast( + (static_cast(activeTicks) * 1000000000ULL + MCPWM_RESOLUTION_HZ / 2U) / + MCPWM_RESOLUTION_HZ); + a = {hz, hz, pulseNs, actualPulseNs, 100.0f * activeTicks / periodTicks, + periodResolutionBits(periodTicks)}; mcpwmFrequencyHz = hz; running_ = true; return true; @@ -189,8 +207,8 @@ void PwmGenerator::stop() { } void PwmGenerator::active() { - // First detach/stop the PWM peripheral, then select the independently - // configured active level. The active and safe levels may be equal. + // First detach/stop the PWM peripheral, then apply the same active level + // that denotes the pulse during a running test. stop(); #if CONFIG_IDF_TARGET_ESP32C3 digitalWrite(GPIO_PWM, PWM_ACTIVE_LEVEL); diff --git a/OpticalChannelTester/Pwm.h b/OpticalChannelTester/Pwm.h index 40d94c7..f2207e7 100644 --- a/OpticalChannelTester/Pwm.h +++ b/OpticalChannelTester/Pwm.h @@ -1,12 +1,19 @@ #pragma once #include -struct ActualPwm { uint32_t requestedHz; uint32_t actualHz; float actualDutyPct; uint8_t bits; }; +struct ActualPwm { + uint32_t requestedHz; + uint32_t actualHz; + uint32_t requestedPulseNs; + uint32_t actualPulseNs; + float actualDutyPct; + uint8_t bits; +}; class PwmGenerator { public: void begin(); - bool start(uint32_t frequencyHz, uint8_t dutyPct, ActualPwm &actual); + bool start(uint32_t frequencyHz, uint32_t pulseNs, ActualPwm &actual); void active(); void stop(); bool running() const { return running_; } diff --git a/OpticalChannelTester/Receiver.cpp b/OpticalChannelTester/Receiver.cpp index 3fb679a..c3bc3af 100644 --- a/OpticalChannelTester/Receiver.cpp +++ b/OpticalChannelTester/Receiver.cpp @@ -1,13 +1,13 @@ #include "Receiver.h" -#include "Config.h" -#include -#if !OPTICAL_USE_RMT_DMA +#include "Log.h" +#include +#if !OPTICAL_USE_MCPWM_CAPTURE #include #include #endif uint32_t PulseReceiver::tickHz() const { -#if OPTICAL_USE_RMT_DMA +#if OPTICAL_USE_MCPWM_CAPTURE return captureResolutionHz_; #else return cpuTickHz_; @@ -15,31 +15,48 @@ uint32_t PulseReceiver::tickHz() const { } uint32_t PulseReceiver::plannedTickHz(uint32_t expectedHz, float expectedDutyPct) const { -#if OPTICAL_USE_RMT_DMA - if (!expectedHz || expectedDutyPct <= 0.0f || expectedDutyPct >= 100.0f) - return CAPTURE_RESOLUTION_OPTIONS_HZ[0]; - uint32_t dutyX100 = static_cast(expectedDutyPct * 100.0f + 0.5f); - if (dutyX100 < 5000U) dutyX100 = 10000U - dutyX100; - for (int i = static_cast(countOf(CAPTURE_RESOLUTION_OPTIONS_HZ)) - 1; i >= 0; --i) { - const uint32_t resolution = CAPTURE_RESOLUTION_OPTIONS_HZ[i]; - const uint64_t levelTicksX100 = static_cast(resolution) * dutyX100; - const uint64_t limitX100 = static_cast(expectedHz) * 10000ULL * RMT_MAX_LEVEL_TICKS; - if (levelTicksX100 <= limitX100) return resolution; - } - return CAPTURE_RESOLUTION_OPTIONS_HZ[0]; + if (!expectedHz || expectedDutyPct <= 0.0f || expectedDutyPct >= 100.0f) return 0; +#if OPTICAL_USE_MCPWM_CAPTURE + return captureResolutionHz_ ? captureResolutionHz_ : + MCPWM_CAPTURE_RESOLUTION_HZ; #else - (void)expectedHz; (void)expectedDutyPct; return cpuTickHz_; #endif } bool PulseReceiver::begin() { -#if OPTICAL_USE_RMT_DMA - queue_ = xQueueCreate(RMT_QUEUE_BLOCKS, sizeof(SymbolBlock)); - return queue_ && configureRmt(CAPTURE_RESOLUTION_OPTIONS_HZ[0]); -#else - queue_ = xQueueCreate(256, sizeof(Edge)); + queue_ = xQueueCreate(512, sizeof(Edge)); if (!queue_) return false; +#if OPTICAL_USE_MCPWM_CAPTURE + // PWM generation uses MCPWM group 0. Group 1 is dedicated to input capture, + // so RX cannot exhaust or conflict with the generator's resources. + mcpwm_capture_timer_config_t timerConfig = {}; + timerConfig.group_id = 1; + timerConfig.clk_src = MCPWM_CAPTURE_CLK_SRC_DEFAULT; + timerConfig.resolution_hz = MCPWM_CAPTURE_RESOLUTION_HZ; + if (mcpwm_new_capture_timer(&timerConfig, &captureTimer_) != ESP_OK) return false; + if (mcpwm_capture_timer_get_resolution(captureTimer_, &captureResolutionHz_) != ESP_OK || + !captureResolutionHz_) return false; + + mcpwm_capture_channel_config_t channelConfig = {}; + channelConfig.gpio_num = GPIO_RX; + channelConfig.prescale = 1; + channelConfig.flags.pos_edge = true; + channelConfig.flags.neg_edge = false; + if (mcpwm_new_capture_channel(captureTimer_, &channelConfig, &risingChannel_) != ESP_OK) + return false; + mcpwm_capture_event_callbacks_t callbacks = {}; + callbacks.on_cap = onCapture; + if (mcpwm_capture_channel_register_event_callbacks( + risingChannel_, &callbacks, this) != ESP_OK) return false; + + channelConfig.flags.pos_edge = false; + channelConfig.flags.neg_edge = true; + if (mcpwm_new_capture_channel(captureTimer_, &channelConfig, &fallingChannel_) != ESP_OK) + return false; + return mcpwm_capture_channel_register_event_callbacks( + fallingChannel_, &callbacks, this) == ESP_OK; +#else pinMode(GPIO_RX, INPUT); cpuTickHz_ = getCpuFrequencyMhz() * 1000000UL; attachInterruptArg(GPIO_RX, onGpio, this, CHANGE); @@ -47,178 +64,195 @@ bool PulseReceiver::begin() { #endif } -#if OPTICAL_USE_RMT_DMA -bool PulseReceiver::configureRmt(uint32_t resolutionHz) { - if (channel_ && captureResolutionHz_ == resolutionHz) return true; - stop(); - if (channel_) { - if (rmt_del_channel(channel_) != ESP_OK) return false; - channel_ = nullptr; - } - rmt_rx_channel_config_t cfg = {}; - cfg.clk_src = RMT_CLK_SRC_DEFAULT; cfg.resolution_hz = resolutionHz; - cfg.gpio_num = static_cast(GPIO_RX); - // Internally the measurement code always treats HIGH as the active phase. - cfg.flags.invert_in = RX_ACTIVE_LEVEL == LOW; -#if CONFIG_IDF_TARGET_ESP32S3 - cfg.mem_block_symbols = 512; - cfg.flags.with_dma = true; -#else - // C3 has 48 RMT symbols per channel and no RMT DMA. A request for 512 - // consumes all available blocks and fails with "no free rx channels". - cfg.mem_block_symbols = RMT_MIN_RECEIVE_SYMBOLS; - cfg.flags.with_dma = false; // C3 uses hardware RMT ping-pong partial reception -#endif - if (rmt_new_rx_channel(&cfg, &channel_) != ESP_OK) return false; - rmt_rx_event_callbacks_t callbacks = {}; callbacks.on_recv_done = onRmt; - if (rmt_rx_register_event_callbacks(channel_, &callbacks, this) != ESP_OK) { - rmt_del_channel(channel_); channel_ = nullptr; return false; - } - captureResolutionHz_ = resolutionHz; - return true; -} -#endif - bool PulseReceiver::start(uint32_t expectedHz, float expectedDutyPct) { -#if OPTICAL_USE_RMT_DMA - const uint32_t resolutionHz = plannedTickHz(expectedHz, expectedDutyPct); - if (!configureRmt(resolutionHz)) return false; -#else - (void)expectedHz; (void)expectedDutyPct; + if (!plannedTickHz(expectedHz, expectedDutyPct)) return false; + expectedHz_ = expectedHz; + expectedDutyPct_ = expectedDutyPct; +#if !OPTICAL_USE_MCPWM_CAPTURE + cpuTickHz_ = getCpuFrequencyMhz() * 1000000UL; + if (!cpuTickHz_) return false; #endif resetStream(); -#if OPTICAL_USE_RMT_DMA - // In partial RX mode the callback is delivered when this user buffer fills. - // Keep chunks near 5 ms so low-frequency input is reported before NO SIGNAL. - uint64_t symbols = (static_cast(expectedHz) * RMT_TARGET_CHUNK_US + 999999ULL) / 1000000ULL; - if (symbols < RMT_MIN_RECEIVE_SYMBOLS) symbols = RMT_MIN_RECEIVE_SYMBOLS; - if (symbols > RMT_MAX_RECEIVE_SYMBOLS) symbols = RMT_MAX_RECEIVE_SYMBOLS; - receiveChunkSymbols_ = static_cast(symbols); - if (rmt_enable(channel_) != ESP_OK) return false; - rmt_receive_config_t cfg = {}; - cfg.signal_range_min_ns = 1000000000UL / captureResolutionHz_; - const uint64_t maxNs = 4000000000ULL / (expectedHz ? expectedHz : 1); - // A duration field is 15 bits. Keep the driver's end-of-signal threshold - // strictly below that hardware limit (IDF rejects larger values). - const uint64_t hardwareMaxNs = static_cast(RMT_MAX_LEVEL_TICKS) * 1000000000ULL / captureResolutionHz_; - cfg.signal_range_max_ns = static_cast(maxNs > hardwareMaxNs ? hardwareMaxNs : maxNs); - cfg.flags.en_partial_rx = true; - if (rmt_receive(channel_, receiveBuffer_, - receiveChunkSymbols_ * sizeof(receiveBuffer_[0]), &cfg) != ESP_OK) { - rmt_disable(channel_); return false; +#if OPTICAL_USE_MCPWM_CAPTURE + // Progress updates keep one capture session alive. Pulse-width stages stop + // capture only after PWM is quiet, so resetStream never races the ISR. + if (running_) return true; + if (mcpwm_capture_timer_enable(captureTimer_) != ESP_OK) return false; + if (mcpwm_capture_channel_enable(risingChannel_) != ESP_OK) { + mcpwm_capture_timer_disable(captureTimer_); + return false; } + if (mcpwm_capture_channel_enable(fallingChannel_) != ESP_OK) { + mcpwm_capture_channel_disable(risingChannel_); + mcpwm_capture_timer_disable(captureTimer_); + return false; + } + running_ = true; + if (mcpwm_capture_timer_start(captureTimer_) != ESP_OK) { + running_ = false; + mcpwm_capture_channel_disable(fallingChannel_); + mcpwm_capture_channel_disable(risingChannel_); + mcpwm_capture_timer_disable(captureTimer_); + return false; + } +#else + running_ = true; #endif - running_ = true; return true; + return true; } void PulseReceiver::stop() { -#if OPTICAL_USE_RMT_DMA - if (running_) rmt_disable(channel_); -#endif + const bool wasRunning = running_; running_ = false; +#if OPTICAL_USE_MCPWM_CAPTURE + if (wasRunning) { + // Mask both edge interrupts before stopping the shared capture timer. + mcpwm_capture_channel_disable(fallingChannel_); + mcpwm_capture_channel_disable(risingChannel_); + mcpwm_capture_timer_stop(captureTimer_); + mcpwm_capture_timer_disable(captureTimer_); + } +#else + (void)wasRunning; +#endif } void PulseReceiver::resetStream() { if (queue_) xQueueReset(queue_); - overflow_ = false; droppedItems_ = 0; haveRise_ = haveFall_ = haveRawTick_ = false; - lastRawTick_ = 0; tickEpoch_ = rise_ = fall_ = 0; -#if OPTICAL_USE_RMT_DMA - block_ = {}; blockIndex_ = 0; phase_ = 0; haveLevel_ = false; level_ = false; rmtTick_ = 0; -#endif + haveReorderEdge_ = false; + droppedItems_ = 0; + polarityKnown_ = false; + activeStartRising_ = false; + syncEdgeCount_ = 0; + waitingForActiveEnd_ = true; + activeStart_ = activeEnd_ = 0; + haveRawTick_ = false; + lastRawTick_ = 0; + tickEpoch_ = 0; } -bool PulseReceiver::consumeEdge(const Edge &e, PulsePeriod &out) { +PulseReceiver::TimedEdge PulseReceiver::extendEdge(const Edge &e) { if (haveRawTick_ && e.tick < lastRawTick_ && lastRawTick_ - e.tick > 0x80000000UL) tickEpoch_ += 0x100000000ULL; haveRawTick_ = true; lastRawTick_ = e.tick; - const uint64_t tick = tickEpoch_ + e.tick; - if (e.rising) { - if (!haveRise_) { rise_ = tick; haveRise_ = true; haveFall_ = false; return false; } - if (!haveFall_) { overflow_ = true; rise_ = tick; return false; } - const uint32_t period = static_cast(tick - rise_); - const uint32_t active = fall_ - rise_; - out = {rise_, period, active}; rise_ = tick; haveFall_ = false; - return true; - } - // Reception can begin in the middle of a HIGH pulse. In that case the first - // observable edge is falling and there is no complete period to validate. - // Ignore only this leading partial pulse and synchronize on the next rise. - if (!haveRise_) return false; - if (haveFall_) { overflow_ = true; return false; } - fall_ = tick; haveFall_ = true; return false; + return {tickEpoch_ + e.tick, e.rising != 0}; } -bool PulseReceiver::overflowed() { - const bool value = overflow_; overflow_ = false; return value; +bool PulseReceiver::consumeEdge(const Edge &rawEdge, PulsePeriod &out) { + const TimedEdge edge = extendEdge(rawEdge); + if (polarityKnown_) { + // Deliberately ignore edge type after synchronization. A PWM waveform is + // just alternating intervals: active, inactive, active, inactive. An + // extra or missing edge therefore becomes a concrete wrong pulse/period + // instead of an ambiguous GLITCH state. + if (waitingForActiveEnd_) { + activeEnd_ = edge.tick; + waitingForActiveEnd_ = false; + return false; + } + const uint64_t periodTicks = edge.tick - activeStart_; + const uint64_t activeTicks = activeEnd_ - activeStart_; + out = {activeStart_, static_cast(periodTicks), + static_cast(activeTicks), tickHz()}; + activeStart_ = edge.tick; + waitingForActiveEnd_ = true; + return true; + } + + syncEdges_[syncEdgeCount_++] = edge; + if (syncEdgeCount_ < 3U) return false; + + const uint64_t firstTicks = syncEdges_[1].tick - syncEdges_[0].tick; + const uint64_t secondTicks = syncEdges_[2].tick - syncEdges_[1].tick; + const double expectedTicks = static_cast(tickHz()) * expectedDutyPct_ / + (100.0 * expectedHz_); + const double firstError = fabs(static_cast(firstTicks) - expectedTicks); + const double secondError = fabs(static_cast(secondTicks) - expectedTicks); + activeStartRising_ = firstError <= secondError ? syncEdges_[0].rising : syncEdges_[1].rising; + polarityKnown_ = true; + Log::printf("CAPTURE", "RX polarity auto: active starts on %s, first=%lluns second=%lluns", + activeStartRising_ ? "RISING" : "FALLING", + static_cast(firstTicks * 1000000000ULL / tickHz()), + static_cast(secondTicks * 1000000000ULL / tickHz())); + + bool produced = false; + if (firstError <= secondError) { + activeStart_ = syncEdges_[0].tick; + activeEnd_ = syncEdges_[1].tick; + const uint64_t periodTicks = syncEdges_[2].tick - activeStart_; + out = {activeStart_, static_cast(periodTicks), + static_cast(activeEnd_ - activeStart_), tickHz()}; + activeStart_ = syncEdges_[2].tick; + waitingForActiveEnd_ = true; + produced = true; + } else { + activeStart_ = syncEdges_[1].tick; + activeEnd_ = syncEdges_[2].tick; + waitingForActiveEnd_ = false; + } + syncEdgeCount_ = 0; + return produced; } uint32_t PulseReceiver::takeDroppedItems() { return __atomic_exchange_n(&droppedItems_, 0, __ATOMIC_RELAXED); } -#if OPTICAL_USE_RMT_DMA -bool IRAM_ATTR PulseReceiver::onRmt(rmt_channel_handle_t, const rmt_rx_done_event_data_t *data, void *ctx) { +#if OPTICAL_USE_MCPWM_CAPTURE +bool IRAM_ATTR PulseReceiver::onCapture(mcpwm_cap_channel_handle_t, + const mcpwm_capture_event_data_t *data, + void *ctx) { PulseReceiver *self = static_cast(ctx); + if (!self->running_) return false; + const bool rawRising = data->cap_edge == MCPWM_CAP_EDGE_POS; + const Edge edge = {data->cap_value, static_cast(rawRising)}; BaseType_t wake = pdFALSE; - size_t offset = 0; - while (offset < data->num_symbols) { - SymbolBlock &b = self->isrBlock_; - b.count = static_cast((data->num_symbols - offset) > BLOCK_SYMBOLS ? - BLOCK_SYMBOLS : (data->num_symbols - offset)); - memcpy(b.symbols, data->received_symbols + offset, b.count * sizeof(rmt_symbol_word_t)); - if (xQueueSendFromISR(self->queue_, &b, &wake) != pdTRUE) - __atomic_fetch_add(&self->droppedItems_, b.count, __ATOMIC_RELAXED); - offset += b.count; - } + if (xQueueSendFromISR(self->queue_, &edge, &wake) != pdTRUE) + __atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED); return wake == pdTRUE; } - -bool PulseReceiver::nextRmtEdge(Edge &edge, TickType_t waitTicks) { - for (;;) { - if (blockIndex_ >= block_.count) { - if (xQueueReceive(queue_, &block_, waitTicks) != pdTRUE) return false; - blockIndex_ = 0; phase_ = 0; - waitTicks = 0; - } - const rmt_symbol_word_t &s = block_.symbols[blockIndex_]; - const bool nextLevel = phase_ == 0 ? s.level0 : s.level1; - const uint32_t duration = phase_ == 0 ? s.duration0 : s.duration1; - phase_ ^= 1; - if (phase_ == 0) ++blockIndex_; - if (!duration) continue; - if (!haveLevel_) { haveLevel_ = true; level_ = nextLevel; rmtTick_ += duration; continue; } - if (nextLevel != level_) { - level_ = nextLevel; edge = {rmtTick_, static_cast(nextLevel)}; - rmtTick_ += duration; return true; - } - rmtTick_ += duration; - } -} - -size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks) { - size_t count = 0; - Edge e; - while (count < capacity && nextRmtEdge(e, count ? 0 : waitTicks)) - if (consumeEdge(e, periods[count])) ++count; - return count; -} #else void IRAM_ATTR PulseReceiver::onGpio(void *ctx) { PulseReceiver *self = static_cast(ctx); + if (!self->running_) return; bool level = gpio_get_level(static_cast(GPIO_RX)); if (RX_ACTIVE_LEVEL == LOW) level = !level; - Edge e = {esp_cpu_get_cycle_count(), static_cast(level)}; + const Edge edge = {esp_cpu_get_cycle_count(), static_cast(level)}; BaseType_t wake = pdFALSE; - if (xQueueSendFromISR(self->queue_, &e, &wake) != pdTRUE) + if (xQueueSendFromISR(self->queue_, &edge, &wake) != pdTRUE) __atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED); if (wake) portYIELD_FROM_ISR(); } +#endif -size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks) { +bool PulseReceiver::nextOrderedEdge(Edge &edge, TickType_t waitTicks) { + if (!haveReorderEdge_) { + if (xQueueReceive(queue_, &reorderEdge_, waitTicks) != pdTRUE) return false; + haveReorderEdge_ = true; + } + Edge next = {}; + // Keep one-event look-ahead. If both channel interrupts were pending while + // OLED/I2C ran, the MCPWM driver may dispatch them by channel number rather + // than timestamp. The signed modular comparison restores their real order. + if (xQueueReceive(queue_, &next, waitTicks) != pdTRUE) return false; + if (static_cast(next.tick - reorderEdge_.tick) < 0) { + edge = next; + } else { + edge = reorderEdge_; + reorderEdge_ = next; + } + return true; +} + +size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity, + TickType_t waitTicks) { size_t count = 0; - Edge e; - while (count < capacity && xQueueReceive(queue_, &e, count ? 0 : waitTicks) == pdTRUE) - if (consumeEdge(e, periods[count])) ++count; + Edge edge = {}; + while (count < capacity && nextOrderedEdge(edge, count ? 0 : waitTicks)) { + if (consumeEdge(edge, periods[count])) { + periods[count].activeTickHz = tickHz(); + ++count; + } + } return count; } -#endif diff --git a/OpticalChannelTester/Receiver.h b/OpticalChannelTester/Receiver.h index d68fd1b..7a1e704 100644 --- a/OpticalChannelTester/Receiver.h +++ b/OpticalChannelTester/Receiver.h @@ -1,15 +1,15 @@ #pragma once #include #include +#include #include "Config.h" #include "Core.h" -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) -#define OPTICAL_USE_RMT_DMA 1 -#include +#if CONFIG_IDF_TARGET_ESP32S3 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +#define OPTICAL_USE_MCPWM_CAPTURE 1 +#include #else -#define OPTICAL_USE_RMT_DMA 0 -#include +#define OPTICAL_USE_MCPWM_CAPTURE 0 #endif class PulseReceiver { @@ -19,48 +19,46 @@ class PulseReceiver { void stop(); void resetStream(); size_t readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks = 0); - bool overflowed(); uint32_t takeDroppedItems(); uint32_t tickHz() const; + uint32_t pulseTickHz() const { return tickHz(); } uint32_t plannedTickHz(uint32_t expectedHz, float expectedDutyPct) const; - uint16_t receiveChunkSymbols() const { return receiveChunkSymbols_; } - bool highRateBackend() const { -#if OPTICAL_USE_RMT_DMA && CONFIG_IDF_TARGET_ESP32S3 - return true; -#else - return false; -#endif + uint32_t plannedPulseTickHz(uint32_t expectedHz, float expectedDutyPct) const { + return plannedTickHz(expectedHz, expectedDutyPct); } + uint16_t receiveChunkSymbols() const { return 1; } + bool highRateBackend() const { return OPTICAL_USE_MCPWM_CAPTURE; } + private: struct Edge { uint32_t tick; uint8_t rising; }; + struct TimedEdge { uint64_t tick; bool rising; }; bool consumeEdge(const Edge &edge, PulsePeriod &period); - -#if OPTICAL_USE_RMT_DMA - static constexpr size_t BLOCK_SYMBOLS = RMT_MAX_RECEIVE_SYMBOLS; - struct SymbolBlock { uint16_t count; rmt_symbol_word_t symbols[BLOCK_SYMBOLS]; }; - static bool IRAM_ATTR onRmt(rmt_channel_handle_t, const rmt_rx_done_event_data_t *, void *); - bool configureRmt(uint32_t resolutionHz); - bool nextRmtEdge(Edge &edge, TickType_t waitTicks); - rmt_channel_handle_t channel_ = nullptr; + bool nextOrderedEdge(Edge &edge, TickType_t waitTicks); + TimedEdge extendEdge(const Edge &edge); +#if OPTICAL_USE_MCPWM_CAPTURE + static bool IRAM_ATTR onCapture(mcpwm_cap_channel_handle_t, + const mcpwm_capture_event_data_t *, void *); + mcpwm_cap_timer_handle_t captureTimer_ = nullptr; + mcpwm_cap_channel_handle_t risingChannel_ = nullptr; + mcpwm_cap_channel_handle_t fallingChannel_ = nullptr; uint32_t captureResolutionHz_ = 0; - rmt_symbol_word_t receiveBuffer_[RMT_MAX_RECEIVE_SYMBOLS]; - uint16_t receiveChunkSymbols_ = 0; - SymbolBlock isrBlock_ = {}; - SymbolBlock block_ = {}; - uint16_t blockIndex_ = 0; - uint8_t phase_ = 0; - bool haveLevel_ = false; - bool level_ = false; - uint32_t rmtTick_ = 0; #else static void IRAM_ATTR onGpio(void *ctx); uint32_t cpuTickHz_ = 0; #endif QueueHandle_t queue_ = nullptr; - volatile bool overflow_ = false; + Edge reorderEdge_ = {}; + bool haveReorderEdge_ = false; volatile uint32_t droppedItems_ = 0; - bool running_ = false; - bool haveRise_ = false, haveFall_ = false, haveRawTick_ = false; + volatile bool running_ = false; + uint32_t expectedHz_ = 0; + float expectedDutyPct_ = 50.0f; + bool polarityKnown_ = false, activeStartRising_ = false; + TimedEdge syncEdges_[3] = {}; + uint8_t syncEdgeCount_ = 0; + bool waitingForActiveEnd_ = true; + uint64_t activeStart_ = 0, activeEnd_ = 0; + bool haveRawTick_ = false; uint32_t lastRawTick_ = 0; - uint64_t tickEpoch_ = 0, rise_ = 0, fall_ = 0; + uint64_t tickEpoch_ = 0; }; diff --git a/OpticalChannelTester/SettingsStore.cpp b/OpticalChannelTester/SettingsStore.cpp index 1c4e0ce..9da94e9 100644 --- a/OpticalChannelTester/SettingsStore.cpp +++ b/OpticalChannelTester/SettingsStore.cpp @@ -3,20 +3,25 @@ #include "Log.h" #include -namespace { constexpr uint16_t SETTINGS_VERSION = 4; constexpr char NAMESPACE[] = "opt-test"; } +namespace { constexpr uint16_t SETTINGS_VERSION = 9; constexpr char NAMESPACE[] = "opt-test"; } void SettingsStore::defaults(Settings &s) const { - s = {SETTINGS_VERSION, static_cast(Role::SOLO), 0, 4, 2, 3, 0, 0}; + // 2 kHz, 200 us .. 2 us, 5%, 1 s. + s = {SETTINGS_VERSION, static_cast(Role::SOLO), 2, 3, 3, 2, 3, 0}; s.checksum = settingsChecksum(s); } bool SettingsStore::valid(const Settings &s) const { return s.version == SETTINGS_VERSION && s.role <= static_cast(Role::SLAVE) && - s.startIndex < countOf(START_FREQ_OPTIONS_HZ) && s.endIndex < countOf(END_FREQ_OPTIONS_HZ) && + s.frequencyIndex < countOf(PWM_FREQUENCY_OPTIONS_HZ) && + s.maxPulseIndex < countOf(MAX_PULSE_OPTIONS_NS) && + s.minPulseIndex < countOf(MIN_PULSE_OPTIONS_NS) && + MAX_PULSE_OPTIONS_NS[s.maxPulseIndex] >= MIN_PULSE_OPTIONS_NS[s.minPulseIndex] && + static_cast(MAX_PULSE_OPTIONS_NS[s.maxPulseIndex]) * + PWM_FREQUENCY_OPTIONS_HZ[s.frequencyIndex] < 1000000000ULL && s.accuracyIndex < countOf(ACCURACY_OPTIONS_PCT) && s.timeIndex < countOf(TEST_TIME_OPTIONS_MS) && - s.checksum == settingsChecksum(s) && - END_FREQ_OPTIONS_HZ[s.endIndex] > START_FREQ_OPTIONS_HZ[s.startIndex]; + s.checksum == settingsChecksum(s); } bool SettingsStore::load(Settings &s) { @@ -39,7 +44,7 @@ bool SettingsStore::save(Settings &s) { } TestParams SettingsStore::params(const Settings &s) const { - return {START_FREQ_OPTIONS_HZ[s.startIndex], END_FREQ_OPTIONS_HZ[s.endIndex], - ACCURACY_OPTIONS_PCT[s.accuracyIndex], TEST_TIME_OPTIONS_MS[s.timeIndex], - TEST_DUTY_PCT}; + return {PWM_FREQUENCY_OPTIONS_HZ[s.frequencyIndex], + MAX_PULSE_OPTIONS_NS[s.maxPulseIndex], MIN_PULSE_OPTIONS_NS[s.minPulseIndex], + ACCURACY_OPTIONS_PCT[s.accuracyIndex], TEST_TIME_OPTIONS_MS[s.timeIndex]}; } diff --git a/README.md b/README.md index be81e02..85c0ace 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Optical Channel Tester — ESP32-C3 / ESP32-S3 -Полностью рабочий Arduino IDE-проект для строгой проверки оптического цифрового канала. Устройство формирует PWM аппаратным LEDC, пропускает его через проверяемый канал и проверяет каждый завершённый период отдельно: частоту и заполнение. Поддерживаются роли `SOLO`, `MASTER` и `SLAVE`; роли выбираются только вручную. +Полностью рабочий Arduino IDE-проект для строгой проверки оптического цифрового канала. Устройство формирует PWM на выбранной частоте, последовательно уменьшает длительность импульса по заданному списку и проверяет ответ канала: частоту и длительность импульса. Поддерживаются роли `SOLO`, `MASTER` и `SLAVE`; роли выбираются только вручную. ## Что реализовано - неблокирующий конечный автомат без `pulseIn()` и длинных `delay()`; -- аппаратный LEDC с расчётом реально получившихся частоты, разрядности и duty; -- Arduino-ESP32 3.3.10: потоковый аппаратный RMT RX (`partial RX`), ping-pong на C3 и DMA на S3; +- аппаратные LEDC (C3) и MCPWM (S3) с расчётом реально получившихся частоты и длительности импульса; +- Arduino-ESP32 3.3.10: на S3 два аппаратных канала MCPWM Capture из отдельной группы фиксируют RISING и FALLING в независимых регистрах общего 32-битного таймера 80 МГц; - безопасный fallback для старых Arduino-ESP32 3.x через GPIO edge ISR и аппаратный CPU cycle counter (верхний предел при этом автоматически ограничен 100 кГц); - отбрасывание ровно `PWM_SETTLE_CYCLES` полных периодов; - потоковая статистика без хранения всех периодов; -- немедленный FAIL по первому плохому периоду; -- десять измерительных участков на каждой частоте с промежуточным отображением результата; +- немедленный FAIL по первому отдельному импульсу или периоду вне допуска; +- десять измерительных участков на каждой длительности импульса с промежуточным отображением результата; - ESP-NOW discovery, handshake, CRC, session/stage/sequence, ACK, повторы и защита от старых пакетов; - SSD1306 128×32: каждый экран всегда состоит ровно из двух строк; - две кнопки, debounce, long press и repeat; после long press ложный short click не создаётся; @@ -26,19 +26,17 @@ Arduino sketch находится в каталоге `OpticalChannelTester`: - `OpticalChannelTester.ino` — стандартная точка входа Arduino IDE; - `Config.h` — GPIO, тайм-ауты, пределы и все пользовательские массивы; -- `Core.*` — последовательность частот, ALL, допуски, статистика; +- `Core.*` — последовательность длительностей, ALL, допуски, статистика; - `Buttons.*` — автомат двух кнопок; - `SettingsStore.*` — NVS; - `Display.*` — OLED и компактное форматирование; - `Log.*` — журнал действий и Serial-зеркало интерфейса; -- `Pwm.*` — LEDC; -- `Receiver.*` — RMT RX / совместимый fallback; +- `Pwm.*` — LEDC / MCPWM; +- `Receiver.*` — MCPWM Capture на S3 / совместимый GPIO fallback; - `Measurement.*` — строгая проверка периодов; - `Protocol.*`, `Radio.*` — ESP-NOW; - `App.*` — общий конечный автомат SOLO/MASTER/SLAVE. -Каталог `tests` содержит локальные unit-тесты чистой логики и автомата кнопок. - ## Требования Arduino IDE Проверенная конфигурация: @@ -83,7 +81,7 @@ Arduino sketch находится в каталоге `OpticalChannelTester`: Оба профиля рассчитаны на установку S3 с совмещением контактов `5V` и `GND` с модулем C3. Номера GPIO S3 выбраны по тому же физическому ряду разъёма, а не по совпадению номера GPIO. -На ESP32-C3 PWM формируется через LEDC с целым делителем. На ESP32-S3 используется отдельный аппаратный MCPWM со счётчиком 40 МГц и произвольным целым периодом. +На ESP32-C3 PWM формируется через LEDC с аппаратным дробным делителем Q10.8. На ESP32-S3 используется отдельный MCPWM со счётчиком 20 МГц: это позволяет формировать 500 Гц в пределах 16-битного счётчика и сохраняет шаг импульса 50 нс. `PWM_ACTIVE_LEVEL` задаёт электрический уровень активной части тестового импульса на обеих платах; остальная часть работающего PWM имеет противоположный уровень. `PWM_SAFE_LEVEL` применяется только при остановленном PWM и во сне. Перед началом точки прошивка проверяет, что фактические частота и длительность импульса укладываются в выбранную точность. ### OLED @@ -97,7 +95,7 @@ OLED можно не подключать. Откройте Serial Monitor на [ 1250][UI ] MODE: SOLO | START=RUN ``` -Полоса прогресса учитывает номер частоты и десять измерительных участков внутри неё. Например, диапазон из 11 частот даёт 110 последовательных позиций прогресса. +Полоса прогресса учитывает номер длительности импульса и десять измерительных участков внутри неё. В Serial также выводятся: @@ -110,7 +108,7 @@ OLED можно не подключать. Откройте Serial Monitor на - статистика этапа, первый плохой период и итоговая причина завершения; - все строки, которые были бы показаны на OLED. -Во время строгого измерительного окна отдельные импульсы намеренно не печатаются: они проверяются потоково, а итоговая статистика выводится после окна. Это предотвращает влияние Serial на точность и переполнение очереди RMT. Журнал действий включён параметрами в `Config.h`: +Во время строгого измерительного окна отдельные импульсы намеренно не печатаются: они проверяются потоково, а итоговая статистика выводится после окна. Это предотвращает влияние Serial на точность и переполнение очереди аппаратных фронтов. Журнал действий включён параметрами в `Config.h`: ```cpp constexpr bool SERIAL_ACTION_LOG = true; @@ -153,9 +151,7 @@ MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE **GPIO ESP32 допускают только логические уровни 0…3.3 В.** Не подавайте 5 В на `GPIO_RX`; применяйте согласование уровня. Полярность выхода оптического приёмника задаётся однозначно его активным уровнем: -```cpp -#define RX_ACTIVE_LEVEL HIGH // либо LOW -``` +Базовый активный уровень входа задаёт `RX_ACTIVE_LEVEL`, но во время теста полярность определяется автоматически по первому полному периоду и фиксируется до следующего запуска приёмника. Прошивка сравнивает длительности обоих уровней с заданным импульсом и использует более близкую. Например, для 2 кГц и 200 мкс автоматически выбирается 200 мкс, а не дополнение 300 мкс. При заполнении 50% обе полярности эквивалентны. ## Управление @@ -177,52 +173,73 @@ MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE ### Энергосбережение -В ожидании процессор работает на 80 МГц, во время теста и связанной MASTER/SLAVE-сессии — на 160 МГц. Пока Solo или Master бодрствует и тест не идёт, выход передатчика удерживается на `PWM_ACTIVE_LEVEL`; в режиме Slave передатчик всегда отключён уровнем `PWM_SAFE_LEVEL`. При запуске теста, остановленном PWM и после тайм-аута сна также используется `PWM_SAFE_LEVEL` — уровень полного выключения с минимальным потреблением тока. После тайм-аута бездействия в `IDLE`, `MENU`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в непрерывный light sleep без периодических таймерных пробуждений. Экран и текущее состояние сохраняются. START и MODE будят любую роль, а фронт на оптическом входе дополнительно будит Slave. При использовании встроенного USB Serial/JTAG для следующей прошивки может потребоваться ручной вход в загрузчик кнопкой BOOT. +В ожидании процессор работает на 80 МГц, во время теста и связанной MASTER/SLAVE-сессии — на 160 МГц. Пока Solo или Master бодрствует и тест не идёт, выход передатчика удерживается на `PWM_ACTIVE_LEVEL`; в режиме Slave передатчик всегда отключён уровнем `PWM_SAFE_LEVEL`. При запуске теста, остановленном PWM и после тайм-аута сна также используется `PWM_SAFE_LEVEL` — уровень полного выключения с минимальным потреблением тока. После тайм-аута бездействия в `IDLE`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в непрерывный light sleep без периодических таймерных пробуждений. Пока открыто меню настроек, light sleep запрещён, поэтому кнопки остаются отзывчивыми независимо от времени настройки. Экран и текущее состояние сохраняются. START и MODE будят любую роль, а фронт на оптическом входе дополнительно будит Slave. После пробуждения прошивка полностью перезапускает драйверы USB Serial и I²C, а в режиме Slave также восстанавливает ESP-NOW. -START и MODE будят устройство. Пробуждающее нажатие намеренно не выполняет действие и полностью игнорируется до отпускания кнопки; действие выполняет только следующее нажатие. Принятый `DISCOVER` также немедленно выводит Slave из энергосбережения. Тайм-аут задаётся `IDLE_POWER_SAVE_TIMEOUT_MS` в `Config.h`. +START и MODE будят устройство. Первое, пробуждающее нажатие не выполняет действие и полностью подавляется до отпускания кнопки; действие выполняет только следующее нажатие. Оптический wake-сигнал также немедленно выводит Slave из энергосбережения. Тайм-аут задаётся `IDLE_POWER_SAVE_TIMEOUT_MS` в `Config.h`. -## Настройка диапазона +## Настройки теста -START и END выбираются из отдельных массивов `START_FREQ_OPTIONS_HZ` и `END_FREQ_OPTIONS_HZ` в `Config.h` и зацикливаются независимо. Полный список точных частот заранее рассчитан для XTAL 40 МГц, целого делителя LEDC и таймера 1…14 бит и записан в `TEST_FREQUENCIES_HZ`. Сохранённые индексы всегда проверяются; после изменения массивов повреждённая/несовместимая настройка не приводит к выходу за границы. +Меню содержит пять параметров: -Отдельной настройки STEP нет: тест начинается с выбранной START, проходит все соседние достижимые точки из полного списка и заканчивается на выбранной END. +1. частота ШИМ из `PWM_FREQUENCY_OPTIONS_HZ`; +2. максимальная длительность импульса из отдельного `MAX_PULSE_OPTIONS_NS`; +3. минимальная длительность импульса из отдельного `MIN_PULSE_OPTIONS_NS`; +4. точность; +5. время выборки. -`ALL` пересчитывается после изменения START, END или TEST TIME и отображается с точностью до целой секунды. Для каждой точки учитывается: +Тест работает на одной выбранной частоте и проходит длительности из списка от максимальной к минимальной. Значения, равные периоду или превышающие его, автоматически исключаются при выборе частоты. На S3 минимальная длительность ограничивается расчётной точностью генератора и MCPWM Capture 80 МГц: длинная пауза не снижает точность короткого импульса. Меню не позволяет задать точку, которой аппаратно не хватает выбранного допуска. Сохранённые индексы и их сочетания проверяются и приводятся к допустимому диапазону. + +MAX и MIN имеют независимые массивы и независимые индексы. Сам тест проходит отдельный полный список `TEST_PULSE_WIDTHS_NS`. Все пункты меню зациклены: после последнего допустимого значения выбирается первое, а при движении назад перед первым выбирается последнее. + +`ALL` пересчитывается после изменения частоты, границ импульса или времени выборки и отображается с точностью до целой секунды. Для каждой точки учитывается: ```text max(TEST_TIME, periods / RX_PROCESSING_PERIODS_PER_SECOND) -+ ожидание заполнения RMT-пакета × 10 + PWM_SETTLE_CYCLES / actualFrequency × 10 + OLED_PROGRESS_UPDATE_MS × 11 ``` -`TEST TIME` — это чистое время выборки сигнала, а не полная длительность этапа. На высоких частотах полная длительность заметно возрастает из-за проверки каждого периода. Расчёт использует консервативную производительность 300 тысяч периодов в секунду, полученную из реального журнала ESP32-C3. +`TEST TIME` — это чистое время выборки сигнала, а не полная длительность этапа. На высоких частотах полная длительность заметно возрастает из-за проверки каждого периода. Расчёт использует консервативную производительность 300 тысяч периодов в секунду. ## Строгая проверка После каждой перенастройки PWM приёмник: -1. выбирает максимальную допустимую частоту RMT 20, 40 или 80 МГц с учётом частоты и duty; +1. автоматически определяет полярность Rx; отдельные каналы MCPWM Capture S3 фиксируют RISING и FALLING в независимых регистрах общего 32-битного таймера 80 МГц, чтобы близкий второй фронт не перезаписал первый; 2. отбрасывает ровно `PWM_SETTLE_CYCLES` полных периодов; 3. очищает статистику; -4. проверяет все полные периоды десяти участков TEST TIME и между ними показывает промежуточный результат. +4. непрерывно держит MCPWM Capture включённым на всём измерительном этапе, проверяет каждый полный импульс ровно один раз и на десяти границах TEST TIME показывает промежуточный результат; границы прогресса не останавливают capture и не выбрасывают пересекающий их импульс. Между длительностями сначала останавливается PWM, затем capture, поэтому очистка очереди не пересекается с ISR. -Все тесты формируют PWM с фиксированным duty `TEST_DUTY_PCT = 50%`. Настройки duty в меню нет, но фактически подаваемое значение по-прежнему отображается во время теста и передаётся между MASTER и SLAVE. +Каждый полный период и каждый активный импульс проверяются отдельно с выбранным относительным допуском 1%, 2%, 5% или 10%. Поэтому единичное искажение не растворяется в среднем остальных импульсов. После трёх начальных фронтов приёмник работает как простой автомат чередующихся интервалов «импульс — пауза»: тип последующих фронтов не используется, а лишний или потерянный фронт превращается в конкретный `PULSE OUT` или `PERIOD OUT`, не в абстрактный `GLITCH`. Для одного измерения допускается только коррекция на один такт приёмного таймера в сторону заданного значения — это неизбежная неопределённость фиксации фронта. Если после неё конкретная частота или длительность остаётся вне допуска, тест немедленно завершается ошибкой. Накопленное среднее используется только для строк `F:..., P:...` и итогового журнала. Переполнение очереди фронтов немедленно даёт `DATA LOSS ERROR`. -Частота и duty проверяются по среднему за `MEASUREMENT_AVERAGING_PERIODS` полных периодов (по умолчанию 100). Ошибка возникает, если среднее окна устойчиво выходит за выбранный допуск 1%, 2%, 5% или 10%. Для частоты последовательные периоды образуют один интервал от первого до последнего одинакового фронта, поэтому квантовочная неопределённость составляет один такт на всё окно и применяется только при наличии соседних значений RMT. Активные интервалы duty ограничены отдельной парой разных фронтов в каждом периоде, поэтому для duty допускается один такт активной длительности на каждый период даже тогда, когда всё короткое окно квантовалось в одно значение. Например, 41/80 при ожидаемых 50% объясняется одним тактом, а 42/80 при точности 1% уже завершается ошибкой. +Во время этапа OLED показывает заданную точку и измеренный ответ, например: -Незавершённый период в начале и конце временного участка не учитывается. Перед перезапуском RMT между десятью участками незавершённое окно усреднения сбрасывается: новая фаза квантования не сравнивается с предыдущей. Общая статистика и итоговое среднее по всем принятым полным периодам этапа сохраняются. +```text +TEST: 2kHz, 2us +F:2.000k, P:2.00u +``` -Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT`, `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `DATA LOSS ERROR`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`. +При ошибке сохраняется прежний двухстрочный стиль с конкретным значением, +вышедшим за допуск: + +```text +FAIL AT 2kHz, 200us +PULSE OUT 300.01u +``` + +В MASTER/SLAVE передаются выбранная и фактическая длительность импульса и измеренный результат. На принимающей стороне полярность Rx определяется автоматически. + +Незавершённый период в начале и конце временного участка не учитывается. Общая статистика и отображаемое среднее по всем принятым полным периодам этапа сохраняются, но решение PASS/FAIL принимается по каждому импульсу отдельно. + +Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT` (в интерфейсе — ошибка длительности импульса), `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `DATA LOSS ERROR`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`. ### Пределы -- C3: гарантированный проектный диапазон до 10 кГц; настраиваемый строгий предел по умолчанию 100 кГц; -- S3: строгий предел до 1 МГц только при рабочем RMT DMA; +- C3 и S3: настраиваемый строгий предел до 1 МГц; - выше строгого аппаратного предела выдаётся `UNSUPPORTED`, ослабленной проверки нет; -- комбинации, для которых LEDC или RX timer не дают выбранную точность, завершаются `RESOLUTION` до запуска заведомо неверного теста. +- комбинации, для которых PWM или аппаратный capture не дают выбранную точность, отсекаются ещё в меню; повторная проверка перед стартом остаётся защитой от аппаратного сбоя. -Реальный предел зависит от конкретной платы, разводки, формы фронтов, Wi-Fi нагрузки и оптического оборудования. Начинайте проверку с 100 Гц…10 кГц. +Реальный предел зависит от конкретной платы, разводки, формы фронтов, Wi-Fi нагрузки и оптического оборудования. Начинайте проверку с 2 кГц и импульсов 200…2 мкс. ## Первый запуск SOLO @@ -230,20 +247,18 @@ max(TEST_TIME, periods / RX_PROCESSING_PERIODS_PER_SECOND) 2. Откройте Serial Monitor на 115200 baud. 3. Включите плату. На OLED должно быть `MODE: SOLO` / `START=RUN`. 4. Если выбран другой режим, коротко нажимайте MODE до SOLO. -5. Для первого опыта оставьте defaults: 100 Гц…10 кГц, duty 50%, accuracy 5%. +5. Для первого опыта оставьте defaults: 2 кГц, импульсы 200…2 мкс, accuracy 5%. Полярность Rx определяется автоматически. 6. Коротко нажмите START. -7. Serial покажет запрошенные и фактические параметры LEDC, список частот, ALL и статистику каждой точки. +7. Serial покажет запрошенные и фактические параметры PWM, список длительностей, ALL и статистику каждой точки. 8. Успех: `PASS ...` / `START=AGAIN`. Ошибка: `FAIL AT ...` и точная причина. ## Проверка проекта -Целевая версия проекта — Arduino-ESP32 3.3.10. В доступной среде исходный код дополнительно собран Arduino CLI с установленным Arduino-ESP32 3.3.0: +Целевая версия проекта — Arduino-ESP32 3.3.10. В доступной среде исходный код собран Arduino CLI с этой версией: | Target | Arduino-ESP32 | Flash | RAM | Результат | |---|---:|---:|---:|---| -| ESP32-C3 | 3.3.0 | 1,049,316 B (80%) | 44,312 B (13%) | PASS | -| ESP32-S3 | 3.3.0 | 980,407 B (74%) | 53,456 B (16%) | PASS | +| ESP32-C3 | 3.3.10 | 1,045,075 B (79%) | 41,012 B (12%) | PASS | +| ESP32-S3 | 3.3.10 | 976,233 B (74%) | 49,812 B (15%) | PASS | -Локальные unit-тесты: `core tests: PASS`, `button tests: PASS`. Они покрывают неделимый диапазон, END без дубля, ALL, границы допусков, немедленный FAIL, resolution, checksum настроек, CRC протокола и отсутствие short после long. - -Физическое оборудование в этой среде недоступно, поэтому реальные оптические фронты, RMT DMA под длительной нагрузкой, дальность ESP-NOW и электрическая совместимость должны быть проверены на ваших платах. Сборка и программные тесты не выдаются за аппаратный тест. +Физическое оборудование в этой среде недоступно, поэтому реальные оптические фронты, MCPWM Capture под длительной нагрузкой, дальность ESP-NOW и электрическая совместимость должны быть проверены на ваших платах. Сборка и программные тесты не выдаются за аппаратный тест.