diff --git a/.gitignore b/.gitignore index e9c978b..f14ba05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ __Previews/ History Project Logs*/ +/.build/ diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index 9814e81..b78fe5d 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -10,13 +10,14 @@ #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", + static const char *names[] = {"IDLE", "MENU", "SOLO_MEASURE", "SOLO_DRIVER", "MASTER_DISCOVER", "MASTER_WAIT_READY", "MASTER_WAIT_RESULT", "MASTER_FINALIZE", "SLAVE_READY", "SLAVE_WAIT_START", "SLAVE_MEASURE", "SLAVE_WAIT_ACK", "FINISHED"}; const uint8_t index = static_cast(state); @@ -54,7 +55,7 @@ void formatMeasured(float hz, uint32_t pulseNs, char *out, size_t size) { 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); + snprintf(out, size, UiText::TEST_TARGET_FORMAT, target); } void formatFailure(FailReason reason, uint32_t hz, uint32_t pulseNs, @@ -62,7 +63,16 @@ void formatFailure(FailReason reason, uint32_t hz, uint32_t pulseNs, (void)reason; char target[32]; formatTarget(hz, pulseNs, target, sizeof(target)); - snprintf(out, size, "FAIL AT %s", target); + snprintf(out, size, UiText::FAIL_TARGET_FORMAT, target); +} + +void formatElapsedNs(uint64_t ns, char *out, size_t size) { + // Compact form keeps `T:... D:... P:...` within 21 OLED columns. + // Exact nanoseconds remain available in the Serial diagnostic. + if (ns < 1000ULL) snprintf(out, size, "%llun", ns); + else if (ns < 1000000ULL) snprintf(out, size, "%.1fu", ns / 1000.0); + else if (ns < 1000000000ULL) snprintf(out, size, "%.0fm", ns / 1000000.0); + else snprintf(out, size, "%.2fs", ns / 1000000000.0); } size_t utf8CharacterCount(const char *text) { @@ -116,6 +126,12 @@ uint32_t stageWallTimeMs(uint32_t testTimeMs, uint32_t frequencyHz) { return static_cast((nominalStageUs(frequencyHz, testTimeMs, PWM_SETTLE_CYCLES) + 999ULL) / 1000ULL); } +const char *uiTestName(TestKind kind) { + const uint8_t index = static_cast(kind); + return index < sizeof(UiText::TEST_NAMES) / sizeof(UiText::TEST_NAMES[0]) + ? UiText::TEST_NAMES[index] : "?"; +} + 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) @@ -194,9 +210,41 @@ uint8_t cycleIndex(uint8_t value, uint8_t first, uint8_t last, int direction) { if (direction > 0) return value >= last ? first : static_cast(value + 1U); return value <= first ? last : static_cast(value - 1U); } + +bool parseUnsigned(const char *text, uint32_t &value) { + if (!text || !*text || *text == '-') return false; + char *end = nullptr; + const unsigned long parsed = strtoul(text, &end, 10); + if (!end || *end) return false; + value = static_cast(parsed); + return true; } -App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), measurement_(receiver_) {} +template +int optionIndex(const uint32_t (&options)[N], uint32_t value) { + for (size_t i = 0; i < N; ++i) + if (options[i] == value) return static_cast(i); + return -1; +} + +int accuracyOptionIndex(const char *text) { + if (!text || !*text) return -1; + char *end = nullptr; + const float value = strtof(text, &end); + if (!end || *end) return -1; + for (size_t i = 0; i < countOf(ACCURACY_OPTIONS_PCT); ++i) + if (fabsf(ACCURACY_OPTIONS_PCT[i] - value) < 0.001f) return static_cast(i); + return -1; +} + +void lowerAscii(char *text) { + for (; text && *text; ++text) + if (*text >= 'A' && *text <= 'Z') *text = static_cast(*text - 'A' + 'a'); +} +} + +App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), + measurement_(receiver_), driverTest_(receiver_) {} void App::begin() { Serial.begin(SERIAL_BAUD); @@ -221,6 +269,7 @@ void App::finishInitialization(bool factoryReset) { } sanitizeRange(); params_ = store_.params(settings_); + pwm_.configureActiveLight(txActiveLightOn(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; } @@ -233,6 +282,7 @@ void App::finishInitialization(bool factoryReset) { } void App::update() { + serviceSerialConsole(); serviceIdlePowerSave(); const uint32_t now = millis(); const ButtonEvent startEvent = startButton_.update(now); @@ -258,11 +308,14 @@ void App::update() { if (state_ == AppState::IDLE || state_ == AppState::FINISHED) { if (modeEvent == ButtonEvent::SHORT) { - settings_.role = (settings_.role + 1U) % 3U; const bool saved = store_.save(settings_); + cycleRunMode(); sanitizeRange(); const bool saved = store_.save(settings_); params_ = store_.params(settings_); if (static_cast(settings_.role) == Role::SLAVE) armSlave(); else showIdle(); - Log::printf("ACTION", "role changed to %s, NVS=%s", roleName(static_cast(settings_.role)), saved ? "OK" : "FAILED"); + Log::printf("ACTION", "mode changed to %s/%s, NVS=%s", + roleName(static_cast(settings_.role)), + testKindName(static_cast(settings_.testKind)), + saved ? "OK" : "FAILED"); } else if (modeEvent == ButtonEvent::LONG) { state_ = AppState::MENU; menuItem_ = 0; Log::event("ACTION", "settings menu entered"); showMenu(); } else if (startEvent == ButtonEvent::SHORT) { Log::event("ACTION", "test start requested"); startTest(); } @@ -273,7 +326,9 @@ void App::update() { if (state_ == AppState::SLAVE_READY && modeEvent != ButtonEvent::NONE) { radio_.end(); havePeer_ = false; if (modeEvent == ButtonEvent::SHORT) { - settings_.role = static_cast(Role::SOLO); const bool saved = store_.save(settings_); + settings_.role = static_cast(Role::SOLO); + settings_.testKind = static_cast(TestKind::OPTICAL); + const bool saved = store_.save(settings_); params_ = store_.params(settings_); state_ = AppState::IDLE; showIdle(); Log::printf("ACTION", "role changed to SOLO, NVS=%s", saved ? "OK" : "FAILED"); } else if (modeEvent == ButtonEvent::LONG) { @@ -283,7 +338,7 @@ void App::update() { } if (state_ == AppState::MENU) { if (modeEvent == ButtonEvent::SHORT) { - menuItem_ = (menuItem_ + 1U) % 5U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu(); + menuItem_ = (menuItem_ + 1U) % 6U; 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_); @@ -316,6 +371,39 @@ void App::update() { StageStats live = {}; if (measurement_.statsSnapshot(live)) showStageResult(live); } + } else if (state_ == AppState::SOLO_DRIVER) { + if (static_cast(now - localMeasurementDeadlineMs_) >= 0) + driverTest_.forceFail(FailReason::LOST_EDGE); + const DriverState ds = driverTest_.update(); + if (ds == DriverState::SUBSAMPLE_DONE) { + // Capture is already stopped. Update the OLED only in this quiet gap, + // then restart the same PWM point and arm the next tenth of the sample. + pwm_.stop(); + driverTest_.takeProgressUpdate(); + showDriverResult(driverTest_.stats()); + ActualPwm resumed = {}; + if (!pwm_.start(requestedHz_, requestedPulseNs_, resumed)) { + driverTest_.forceFail(FailReason::RESOLUTION); + } else { + actual_ = resumed; + if (!driverTest_.resumeSubsample()) { + pwm_.stop(); + driverTest_.forceFail(FailReason::DATA_LOSS); + } + } + } else if (ds == DriverState::FAIL) { + pwm_.stop(); receiver_.stop(); + driverTest_.printSummary(); + driverTest_.printTrace(); + showDriverResult(driverTest_.stats()); + finish(false, driverTest_.stats().reason, true); + } else if (ds == DriverState::PASS) { + pwm_.stop(); + driverTest_.printSummary(); + const bool finalPoint = stageIndex_ + 1U >= stageCount_; + showDriverResult(driverTest_.stats(), finalPoint); + stagePassed(); + } } else if (state_ == AppState::MASTER_DISCOVER || state_ == AppState::MASTER_WAIT_READY || state_ == AppState::MASTER_WAIT_RESULT || state_ == AppState::MASTER_FINALIZE) { handleRadio(); updateMaster(); @@ -328,14 +416,207 @@ void App::showIdle() { setActivePerformance(false); setStandbyOpticalOutput(); lastUserActivityMs_ = millis(); - char one[64]; snprintf(one, sizeof(one), "%s%s", UiText::MODE_PREFIX, - uiRoleName(static_cast(settings_.role))); + char one[64]; snprintf(one, sizeof(one), "%s: %s", + uiRoleName(static_cast(settings_.role)), + uiTestName(static_cast(settings_.testKind))); display_.show(one, UiText::START_RUN); } +void App::serviceSerialConsole() { + while (Serial.available() > 0) { + const int raw = Serial.read(); + if (raw < 0) break; + const char c = static_cast(raw); + lastUserActivityMs_ = millis(); + leaveIdlePowerSave(); + + if (c == '\r') continue; + if (c == '\n') { + if (serialLineOverflow_) Serial.println("ERR command too long"); + else if (serialLineLength_) { + serialLine_[serialLineLength_] = '\0'; + handleSerialCommand(serialLine_); + } + serialLineLength_ = 0; + serialLineOverflow_ = false; + continue; + } + if (c < ' ' || c > '~') continue; + if (serialLineLength_ + 1U < sizeof(serialLine_)) + serialLine_[serialLineLength_++] = c; + else serialLineOverflow_ = true; + } +} + +void App::printSerialHelp() { + Serial.println("COMMANDS (send with newline):"); + Serial.println(" help | status | start | stop | defaults"); + Serial.println(" set role solo|master|slave"); + Serial.println(" set test optical|driver"); + Serial.println(" set frequency 500|1000|2000|5000|10000|25000"); + Serial.println(" set max 2000|5000|10000|20000|50000|100000|200000|500000"); + Serial.println(" set min 250|500|1000|2000|5000|10000|50000"); + Serial.println(" set accuracy 1|2|5|10"); + Serial.println(" set time 100|250|500|1000|2000|5000 (ms)"); + Serial.println(" set light HH|HL|LH|LL"); +} + +void App::printSerialStatus() { + if (!initialized_) { + Serial.println("STATUS initializing"); + return; + } + params_ = store_.params(settings_); + Serial.printf("STATUS state=%s role=%s test=%s frequency=%luHz max=%luns min=%luns accuracy=%.2f%% time=%lums light=%s usb=%s\n", + appStateName(state_), roleName(static_cast(settings_.role)), + testKindName(static_cast(settings_.testKind)), params_.frequencyHz, + params_.maxPulseNs, params_.minPulseNs, params_.accuracyPct, params_.testTimeMs, + lightCodeName(static_cast(settings_.lightCode)), + usbHostPresent() ? "connected" : "disconnected"); +} + +bool App::serialSettingsMutable() const { + return initialized_ && (state_ == AppState::IDLE || state_ == AppState::FINISHED || + state_ == AppState::MENU || state_ == AppState::SLAVE_READY); +} + +void App::finishSerialSettingsChange() { + if (state_ == AppState::SLAVE_READY) radio_.end(); + state_ = AppState::IDLE; + sanitizeRange(); + params_ = store_.params(settings_); + pwm_.configureActiveLight(txActiveLightOn(settings_)); + const bool saved = store_.save(settings_); + Serial.printf("OK settings saved=%s\n", saved ? "yes" : "no"); + if (static_cast(settings_.role) == Role::SLAVE) armSlave(); + else showIdle(); + printSerialStatus(); +} + +void App::handleSerialCommand(char *line) { + lowerAscii(line); + char *save = nullptr; + char *command = strtok_r(line, " \t", &save); + char *name = strtok_r(nullptr, " \t", &save); + char *value = strtok_r(nullptr, " \t", &save); + char *extra = strtok_r(nullptr, " \t", &save); + if (!command) return; + + if ((!strcmp(command, "help") || !strcmp(command, "?")) && !name) { + printSerialHelp(); + return; + } + if ((!strcmp(command, "status") || !strcmp(command, "get")) && !name) { + printSerialStatus(); + return; + } + if (!strcmp(command, "start") && !name) { + if (!initialized_) Serial.println("ERR still initializing"); + else if (state_ == AppState::IDLE || state_ == AppState::FINISHED) { + Serial.println("OK test start requested"); + startTest(); + } else if (state_ == AppState::SLAVE_READY) Serial.println("OK slave already armed"); + else Serial.printf("ERR busy state=%s\n", appStateName(state_)); + return; + } + if ((!strcmp(command, "stop") || !strcmp(command, "abort")) && !name) { + if (!initialized_) Serial.println("ERR still initializing"); + else if (state_ == AppState::IDLE || state_ == AppState::FINISHED) Serial.println("OK already stopped"); + else if (state_ == AppState::MENU) { + state_ = AppState::IDLE; showIdle(); Serial.println("OK menu closed"); + } else if (state_ == AppState::SLAVE_READY) Serial.println("OK slave is armed; no test is running"); + else { + Serial.println("OK abort requested"); + abortTest(); + } + return; + } + if (!strcmp(command, "defaults") && !name) { + if (!serialSettingsMutable()) { + Serial.printf("ERR settings locked state=%s\n", appStateName(state_)); + return; + } + store_.defaults(settings_); + finishSerialSettingsChange(); + return; + } + if (strcmp(command, "set") || !name || !value || extra) { + Serial.println("ERR unknown command; send 'help'"); + return; + } + if (!serialSettingsMutable()) { + Serial.printf("ERR settings locked state=%s; stop the test first\n", appStateName(state_)); + return; + } + + bool accepted = false; + uint32_t numeric = 0; + if (!strcmp(name, "role")) { + if (!strcmp(value, "solo")) { settings_.role = static_cast(Role::SOLO); accepted = true; } + else if (!strcmp(value, "master")) { settings_.role = static_cast(Role::MASTER); accepted = true; } + else if (!strcmp(value, "slave")) { settings_.role = static_cast(Role::SLAVE); accepted = true; } + } else if (!strcmp(name, "test")) { + if (!strcmp(value, "optical")) { settings_.testKind = static_cast(TestKind::OPTICAL); accepted = true; } + else if (!strcmp(value, "driver") && !TARGET_IS_C3 && + static_cast(settings_.role) == Role::SOLO) { + settings_.testKind = static_cast(TestKind::DRIVER); accepted = true; + } + } else if ((!strcmp(name, "frequency") || !strcmp(name, "freq")) && parseUnsigned(value, numeric)) { + const int index = optionIndex(PWM_FREQUENCY_OPTIONS_HZ, numeric); + if (index >= 0) { settings_.frequencyIndex = static_cast(index); accepted = true; } + } else if ((!strcmp(name, "max") || !strcmp(name, "maxpulse")) && parseUnsigned(value, numeric)) { + const int index = optionIndex(MAX_PULSE_OPTIONS_NS, numeric); + if (index >= 0) { settings_.maxPulseIndex = static_cast(index); accepted = true; } + } else if ((!strcmp(name, "min") || !strcmp(name, "minpulse")) && parseUnsigned(value, numeric)) { + const int index = optionIndex(MIN_PULSE_OPTIONS_NS, numeric); + if (index >= 0) { settings_.minPulseIndex = static_cast(index); accepted = true; } + } else if (!strcmp(name, "accuracy")) { + const int index = accuracyOptionIndex(value); + if (index >= 0) { settings_.accuracyIndex = static_cast(index); accepted = true; } + } else if ((!strcmp(name, "time") || !strcmp(name, "duration")) && parseUnsigned(value, numeric)) { + const int index = optionIndex(TEST_TIME_OPTIONS_MS, numeric); + if (index >= 0) { settings_.timeIndex = static_cast(index); accepted = true; } + } else if (!strcmp(name, "light")) { + if (!strcmp(value, "hh")) { settings_.lightCode = static_cast(LightCode::HH); accepted = true; } + else if (!strcmp(value, "hl")) { settings_.lightCode = static_cast(LightCode::HL); accepted = true; } + else if (!strcmp(value, "lh")) { settings_.lightCode = static_cast(LightCode::LH); accepted = true; } + else if (!strcmp(value, "ll")) { settings_.lightCode = static_cast(LightCode::LL); accepted = true; } + } + + if (!accepted) { + Serial.println("ERR invalid setting or value; send 'help'"); + return; + } + finishSerialSettingsChange(); +} + +void App::cycleRunMode() { + const Role role = static_cast(settings_.role); + const TestKind kind = static_cast(settings_.testKind); + if (role == Role::SOLO && kind == TestKind::OPTICAL && !TARGET_IS_C3) { + settings_.testKind = static_cast(TestKind::DRIVER); + } else if (role == Role::SOLO) { + settings_.role = static_cast(Role::MASTER); + settings_.testKind = static_cast(TestKind::OPTICAL); + } else if (role == Role::MASTER) { + settings_.role = static_cast(Role::SLAVE); + settings_.testKind = static_cast(TestKind::OPTICAL); + } else { + settings_.role = static_cast(Role::SOLO); + settings_.testKind = static_cast(TestKind::OPTICAL); + } +} + void App::sanitizeRange() { if (settings_.role > static_cast(Role::SLAVE)) settings_.role = static_cast(Role::SOLO); + if (settings_.testKind > static_cast(TestKind::DRIVER)) + settings_.testKind = static_cast(TestKind::OPTICAL); + if (settings_.lightCode > static_cast(LightCode::LL)) + settings_.lightCode = static_cast(LightCode::HH); + if (settings_.role != static_cast(Role::SOLO) || + (TARGET_IS_C3 && settings_.testKind == static_cast(TestKind::DRIVER))) + settings_.testKind = static_cast(TestKind::OPTICAL); settings_.frequencyIndex %= countOf(PWM_FREQUENCY_OPTIONS_HZ); settings_.maxPulseIndex %= countOf(MAX_PULSE_OPTIONS_NS); settings_.minPulseIndex %= countOf(MIN_PULSE_OPTIONS_NS); @@ -346,9 +627,15 @@ void App::sanitizeRange() { 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; + if (settings_.testKind == static_cast(TestKind::DRIVER)) { + const uint8_t driverLastMin = lastMinPulseIndexAtMost( + MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]); + const uint8_t firstDriverMin = firstMinPulseIndexAtLeast( + DRIVER_MIN_INPUT_PULSE_NS, driverLastMin); + if (settings_.minPulseIndex < firstDriverMin) + settings_.minPulseIndex = firstDriverMin; + settings_.lightCode = static_cast(LightCode::HL); + } } void App::serviceRxPinStateLog() { @@ -376,25 +663,26 @@ void App::changeMenu(int 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); + const uint8_t first = static_cast(settings_.testKind) == TestKind::DRIVER + ? firstMinPulseIndexAtLeast(DRIVER_MIN_INPUT_PULSE_NS, last) : 0U; + 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; + case 5: value = &settings_.lightCode; count = 4; break; default: return; } *value = cycleIndex(*value, 0, static_cast(count - 1U), d); } sanitizeRange(); params_ = store_.params(settings_); - Log::printf("ACTION", "menu item=%u changed direction=%+d frequency=%u max-pulse=%u min-pulse=%u accuracy=%u time=%u", + pwm_.configureActiveLight(txActiveLightOn(settings_)); + Log::printf("ACTION", "menu item=%u changed direction=%+d frequency=%u max-pulse=%u min-pulse=%u accuracy=%u time=%u light=%s", menuItem_, d, settings_.frequencyIndex, settings_.maxPulseIndex, - settings_.minPulseIndex, settings_.accuracyIndex, settings_.timeIndex); + settings_.minPulseIndex, settings_.accuracyIndex, settings_.timeIndex, + lightCodeName(static_cast(settings_.lightCode))); showMenu(); } @@ -423,6 +711,15 @@ void App::showMenu() { snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f); label = UiText::MENU_TEST_TIME; break; + case 5: { + const char *code = lightCodeName(static_cast(settings_.lightCode)); + snprintf(value, sizeof(value), "%s", code); + label = UiText::MENU_LIGHT_CODE; + formatMenuLine(label, value, one, sizeof(one)); + snprintf(total, sizeof(total), UiText::LIGHT_CODE_FORMAT, code[0], code[1]); + display_.show(one, total); + return; + } default: return; } formatMenuLine(label, value, one, sizeof(one)); @@ -434,23 +731,29 @@ void App::startTest() { leaveIdlePowerSave(); pwm_.stop(); setActivePerformance(true); + sanitizeRange(); 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_); + Log::printf("TEST", "starting role=%s test=%s light=%s stages=%lu", + roleName(static_cast(settings_.role)), + testKindName(static_cast(settings_.testKind)), + lightCodeName(static_cast(settings_.lightCode)), stageCount_); if (SERIAL_MINIMAL_LOG) { - Log::printf("CONFIG", "mode=%s frequency=%luHz pulse=%lu..%luns accuracy=%.2f%% time=%lums TX=%s RX=AUTO stages=%lu", - roleName(static_cast(settings_.role)), params_.frequencyHz, + Log::printf("CONFIG", "mode=%s/%s frequency=%luHz pulse=%lu..%luns accuracy=%.2f%% time=%lums LIGHT=%s stages=%lu", + roleName(static_cast(settings_.role)), + testKindName(static_cast(settings_.testKind)), params_.frequencyHz, params_.maxPulseNs, params_.minPulseNs, params_.accuracyPct, params_.testTimeMs, - PWM_ACTIVE_LEVEL == HIGH ? "HIGH" : "LOW", + lightCodeName(static_cast(settings_.lightCode)), stageCount_); } printConfiguration(); const Role role = static_cast(settings_.role); if (role == Role::SOLO) { if (!prepareStage()) return; - state_ = AppState::SOLO_MEASURE; + state_ = static_cast(settings_.testKind) == TestKind::DRIVER + ? AppState::SOLO_DRIVER : AppState::SOLO_MEASURE; } else if (!radio_.begin()) finish(false, FailReason::LINK_LOST); else if (role == Role::MASTER) startMasterDiscovery(); else { state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave armed and waiting for Master"); display_.show(UiText::SLAVE_READY, UiText::WAIT_MASTER); } @@ -498,24 +801,31 @@ bool App::prepareStage(bool showProgress) { params_.accuracyPct); finish(false, FailReason::RESOLUTION); return false; } - const uint32_t plannedRxHz = receiver_.plannedTickHz(actual_.actualHz, actual_.actualDutyPct); - const uint32_t plannedPulseRxHz = receiver_.plannedPulseTickHz( - actual_.actualHz, actual_.actualDutyPct); - const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct, - plannedRxHz, plannedPulseRxHz, actual_.bits, - MEASUREMENT_AVERAGING_PERIODS); - if (resolution != FailReason::NONE) { - Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u period-capture=%luHz pulse-capture=%luHz tolerance=%.3f%%", - actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz, plannedPulseRxHz, - effectiveTolerancePct(params_.accuracyPct)); - finish(false, resolution); return false; + const bool driverMode = static_cast(settings_.testKind) == TestKind::DRIVER; + if (!driverMode) { + const uint32_t plannedRxHz = receiver_.plannedTickHz(actual_.actualHz, actual_.actualDutyPct); + const uint32_t plannedPulseRxHz = receiver_.plannedPulseTickHz( + actual_.actualHz, actual_.actualDutyPct); + const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, + params_.accuracyPct, plannedRxHz, plannedPulseRxHz, actual_.bits, + MEASUREMENT_AVERAGING_PERIODS); + if (resolution != FailReason::NONE) { + Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u period-capture=%luHz pulse-capture=%luHz tolerance=%.3f%%", + actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz, + plannedPulseRxHz, effectiveTolerancePct(params_.accuracyPct)); + finish(false, resolution); return false; + } + } else if (!receiver_.highRateBackend()) { + finish(false, FailReason::UNSUPPORTED); return false; } Log::printf("PWM", "stage=%lu/%lu requested=%luHz/%luns actual=%luHz/%luns duty=%.3f%% bits=%u STARTED", stageIndex_ + 1, stageCount_, requestedHz_, requestedPulseNs_, actual_.actualHz, actual_.actualPulseNs, actual_.actualDutyPct, actual_.bits); if (showProgress) showStageProgress(); - if (static_cast(settings_.role) == Role::SOLO && !startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { - finish(false, FailReason::UNSUPPORTED); return false; + if (static_cast(settings_.role) == Role::SOLO) { + const bool started = driverMode ? startDriverMeasurement() : + startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct); + if (!started) { finish(false, FailReason::UNSUPPORTED); return false; } } return true; } @@ -526,7 +836,7 @@ bool App::startLocalMeasurement(float hz, float duty) { receiver_.plannedPulseTickHz(static_cast(hz + 0.5f), duty), PWM_SETTLE_CYCLES, params_.testTimeMs); const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, - MEASUREMENT_AVERAGING_PERIODS, PWM_SETTLE_CYCLES); + MEASUREMENT_AVERAGING_PERIODS, PWM_SETTLE_CYCLES, rxActiveLightOn(settings_)); const uint32_t nominalMs = stageWallTimeMs(params_.testTimeMs, static_cast(hz + 0.5f)); const uint64_t watchdogMs = static_cast(nominalMs) * 2ULL + 2000ULL; @@ -536,6 +846,18 @@ bool App::startLocalMeasurement(float hz, float duty) { return ok; } +bool App::startDriverMeasurement() { + const bool ok = driverTest_.start(actual_.actualHz, actual_.actualPulseNs, + params_.accuracyPct, params_.testTimeMs, PWM_SETTLE_CYCLES, + txActiveLightOn(settings_), rxActiveLightOn(settings_)); + const uint32_t nominalMs = stageWallTimeMs(params_.testTimeMs, actual_.actualHz); + const uint64_t watchdogMs = static_cast(nominalMs) * 2ULL + 2000ULL; + localMeasurementDeadlineMs_ = millis() + static_cast( + watchdogMs > UINT32_MAX ? UINT32_MAX : watchdogMs); + if (!ok) Log::event("DRIVER", "response test start FAILED"); + return ok; +} + void App::stagePassed() { Log::printf("TEST", "stage %lu/%lu PASS; PWM stopping", stageIndex_ + 1, stageCount_); pwm_.stop(); @@ -543,8 +865,16 @@ void App::stagePassed() { // 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; } + if (++stageIndex_ >= stageCount_) { + const bool preserveDriverMeasurements = + static_cast(settings_.testKind) == TestKind::DRIVER; + finish(true, FailReason::NONE, preserveDriverMeasurements); + return; + } + if (static_cast(settings_.role) == Role::SOLO) { + if (prepareStage()) state_ = static_cast(settings_.testKind) == TestKind::DRIVER + ? AppState::SOLO_DRIVER : AppState::SOLO_MEASURE; + } else if (static_cast(settings_.role) == Role::MASTER) { requestedHz_ = params_.frequencyHz; requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_); @@ -561,7 +891,7 @@ void App::startMasterDiscovery() { requestedPulseNs_ = 0; havePeer_ = false; radio_.flush(); opticalWakeActive_ = true; lastOpticalWakeToggleMs_ = millis(); - pwm_.active(); + pwm_.lightOn(); pendingPacket_ = makePacket(MessageType::DISCOVER); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = millis(); retries_ = 0; state_ = AppState::MASTER_DISCOVER; Log::printf("ESP-NOW", "discovery started session=%08lX", session_); @@ -575,7 +905,8 @@ ProtocolPacket App::makePacket(MessageType type) const { 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; + p.accuracyX100 = static_cast(params_.accuracyPct * 100.0f + 0.5f); + p.lightCode = settings_.lightCode; return p; } @@ -663,6 +994,8 @@ void App::handleRadio() { state_ = AppState::SLAVE_WAIT_START; params_.testTimeMs = r.packet.testTimeMs; params_.accuracyPct = r.packet.accuracyX100 / 100.0f; + if (r.packet.lightCode <= static_cast(LightCode::LL)) + settings_.lightCode = r.packet.lightCode; requestedHz_ = r.packet.requestedHz; requestedPulseNs_ = r.packet.requestedPulseNs; stageCount_ = r.packet.stageCount; actual_ = {}; @@ -753,7 +1086,7 @@ void App::updateMaster() { if (state_ == AppState::MASTER_DISCOVER) { if (now - lastOpticalWakeToggleMs_ >= OPTICAL_WAKE_HALF_PERIOD_MS) { opticalWakeActive_ = !opticalWakeActive_; - if (opticalWakeActive_) pwm_.active(); + if (opticalWakeActive_) pwm_.lightOn(); else pwm_.stop(); lastOpticalWakeToggleMs_ = now; } @@ -836,7 +1169,10 @@ void App::sendAbort(FailReason reason) { void App::abortTest() { Log::event("ACTION", "abort requested: sending ABORT, stopping receiver and PWM"); - sendAbort(FailReason::ABORTED); measurement_.abort(); finish(false, FailReason::ABORTED); + sendAbort(FailReason::ABORTED); + measurement_.abort(); + driverTest_.abort(); + finish(false, FailReason::ABORTED); } void App::finish(bool pass, FailReason reason, bool preserveDisplay) { @@ -903,17 +1239,39 @@ bool App::idlePowerSaveAllowed() const { // Never enter blocking light sleep while the settings screen is open. A // wake-up press is deliberately consumed by the button state machine, which // is useful in IDLE but makes menu navigation appear frozen. - return initialized_ && (state_ == AppState::IDLE || + return !usbHostPresent() && initialized_ && (state_ == AppState::IDLE || state_ == AppState::FINISHED || state_ == AppState::SLAVE_READY); } +bool App::usbHostPresent() const { +#if ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT && SOC_USB_SERIAL_JTAG_SUPPORTED + // This is driven by USB SOF packets, not by CDC traffic: an enumerated host + // keeps the board awake even if COM is closed and no bytes are exchanged. + // Retain the state across short SOF/driver glitches. + const uint32_t now = millis(); + if (Serial.isPlugged()) { + lastUsbHostSeenMs_ = now ? now : 1U; + return true; + } + return lastUsbHostSeenMs_ && + now - lastUsbHostSeenMs_ <= USB_HOST_DISCONNECT_GRACE_MS; +#else + return false; +#endif +} + void App::setStandbyOpticalOutput() { - if (static_cast(settings_.role) == Role::SLAVE) pwm_.stop(); + // A gate driver must never be held enabled while the tester is idle or + // showing a result. DRIVER is SOLO-only, so force real light OFF here. + if (static_cast(settings_.role) == Role::SLAVE || + static_cast(settings_.testKind) == TestKind::DRIVER) pwm_.stop(); else pwm_.active(); } void App::setActivePerformance(bool active) { - const uint32_t targetMhz = active ? 160U : 80U; + const bool driverMode = initialized_ && + static_cast(settings_.testKind) == TestKind::DRIVER; + const uint32_t targetMhz = active ? (driverMode ? 240U : 160U) : 80U; if (getCpuFrequencyMhz() != targetMhz && !setCpuFrequencyMhz(targetMhz)) Log::printf("POWER", "CPU frequency change to %luMHz FAILED", targetMhz); } @@ -1020,13 +1378,18 @@ void App::printConfiguration() { if (SERIAL_MINIMAL_LOG) return; const char *board = TARGET_IS_C3 ? "ESP32-C3" : "ESP32-S3"; uint8_t mac[6] = {}; esp_read_mac(mac, ESP_MAC_WIFI_STA); - Serial.printf("\nOptical Channel Tester | %s | mode=%s\n", board, roleName(static_cast(settings_.role))); + Serial.printf("\nOptical Channel Tester | %s | mode=%s/%s | light=%s\n", board, + roleName(static_cast(settings_.role)), + testKindName(static_cast(settings_.testKind)), + lightCodeName(static_cast(settings_.lightCode))); Serial.printf("MAC=%02X:%02X:%02X:%02X:%02X:%02X\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); Serial.printf("GPIO PWM=%u RX=%u START=%u MODE=%u SDA=%u SCL=%u\n", GPIO_PWM, GPIO_RX, GPIO_BUTTON_START, GPIO_BUTTON_MODE, GPIO_SDA, GPIO_SCL); - Serial.printf("Test %lu Hz, pulse %lu..%lu ns, accuracy %.2f%%, %lums, RX AUTO\n", + Serial.printf("Test %lu Hz, pulse %lu..%lu ns, accuracy %.2f%%, %lums, TX light=%c RX active light=%c\n", params_.frequencyHz, params_.maxPulseNs, params_.minPulseNs, - params_.accuracyPct, params_.testTimeMs); + params_.accuracyPct, params_.testTimeMs, + lightCodeName(static_cast(settings_.lightCode))[0], + lightCodeName(static_cast(settings_.lightCode))[1]); stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs); Serial.printf("Pulse widths descending (%lu): ", stageCount_); for (uint32_t i = 0; i < stageCount_; ++i) @@ -1093,6 +1456,66 @@ void App::showStageResult(const StageStats &s) { overallProgressTotal(stageCount_)); } +void App::showDriverResult(const DriverStats &s, bool testPassed) { + char one[64], two[64]; + const bool haveResponse = s.responses || s.lastResponseTicks; + const uint64_t delaySumTicks = + s.turnOn.delaySumTicks + s.turnOff.delaySumTicks; + const uint64_t responseSumTicks = + s.turnOn.responseSumTicks + s.turnOff.responseSumTicks; + const uint64_t displayedDelayTicks = s.responses ? + delaySumTicks / s.responses : s.lastDelayTicks; + const uint64_t displayedResponseTicks = s.responses ? + responseSumTicks / s.responses : s.lastResponseTicks; + const uint32_t delayNs = static_cast( + (displayedDelayTicks * 1000000000ULL + + driverTest_.tickHz() / 2U) / driverTest_.tickHz()); + const uint32_t responseNs = static_cast( + (displayedResponseTicks * 1000000000ULL + + driverTest_.tickHz() / 2U) / driverTest_.tickHz()); + char delay[12] = "---", response[12] = "---"; + if (haveResponse) { + Display::formatPulse(delayNs, delay, sizeof(delay)); + Display::formatPulse(responseNs, response, sizeof(response)); + } + if (testPassed) { + snprintf(one, sizeof(one), "%s", UiText::PASS_WORD); + snprintf(two, sizeof(two), UiText::DRIVER_MEASUREMENT_FORMAT, + delay, response); + } else if (s.reason != FailReason::NONE) { + snprintf(one, sizeof(one), "%s", uiFailName(s.reason)); + char elapsed[12] = "---", errorDelay[12] = "---", errorPulse[12] = "---"; + if (s.errorElapsedTicks) { + const uint64_t elapsedNs = + (s.errorElapsedTicks * 1000000000ULL + driverTest_.tickHz() / 2U) / + driverTest_.tickHz(); + formatElapsedNs(elapsedNs, elapsed, sizeof(elapsed)); + } + if (s.errorDelayValid) { + const uint64_t errorDelayNs = + (static_cast(s.errorDelayTicks) * 1000000000ULL + + driverTest_.tickHz() / 2U) / driverTest_.tickHz(); + formatElapsedNs(errorDelayNs, errorDelay, sizeof(errorDelay)); + } + if (s.errorPulseValid) { + const uint64_t errorPulseNs = + (static_cast(s.errorPulseTicks) * 1000000000ULL + + driverTest_.tickHz() / 2U) / driverTest_.tickHz(); + formatElapsedNs(errorPulseNs, errorPulse, sizeof(errorPulse)); + } + snprintf(two, sizeof(two), "T:%s D:%s P:%s", + elapsed, errorDelay, errorPulse); + } else { + formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); + snprintf(two, sizeof(two), UiText::DRIVER_MEASUREMENT_FORMAT, + delay, response); + } + display_.show(one, two, + overallProgress(stageIndex_, driverTest_.progressStep()), + overallProgressTotal(stageCount_), s.reason == FailReason::NONE ? nullptr : + roleCorner(Role::SOLO)); +} + void App::showRemoteResult(const ProtocolPacket &packet) { const FailReason reason = packet.reason <= static_cast(FailReason::ABORTED) ? static_cast(packet.reason) : FailReason::UNSUPPORTED; @@ -1135,6 +1558,15 @@ void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) co } void App::showStageProgress() { + if (static_cast(settings_.testKind) == TestKind::DRIVER) { + char one[64], two[64]; + formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); + snprintf(two, sizeof(two), UiText::DRIVER_MEASUREMENT_FORMAT, + "---", "---"); + display_.show(one, two, overallProgress(stageIndex_, 0), + overallProgressTotal(stageCount_)); + return; + } char one[64]; formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, 0), diff --git a/OpticalChannelTester/App.h b/OpticalChannelTester/App.h index 9386b0a..b3da243 100644 --- a/OpticalChannelTester/App.h +++ b/OpticalChannelTester/App.h @@ -1,13 +1,14 @@ #pragma once #include "Buttons.h" #include "Display.h" +#include "DriverTest.h" #include "Measurement.h" #include "Pwm.h" #include "Radio.h" #include "SettingsStore.h" enum class AppState : uint8_t { - IDLE, MENU, SOLO_MEASURE, MASTER_DISCOVER, MASTER_WAIT_READY, + IDLE, MENU, SOLO_MEASURE, SOLO_DRIVER, MASTER_DISCOVER, MASTER_WAIT_READY, MASTER_WAIT_RESULT, MASTER_FINALIZE, SLAVE_READY, SLAVE_WAIT_START, SLAVE_MEASURE, SLAVE_WAIT_ACK, FINISHED }; @@ -22,11 +23,13 @@ class App { void finishInitialization(bool factoryReset); void showMenu(); void changeMenu(int direction); + void cycleRunMode(); void sanitizeRange(); void startTest(); bool armSlave(bool preserveDisplay = false); bool prepareStage(bool showProgress = true); bool startLocalMeasurement(float hz, float duty); + bool startDriverMeasurement(); void startMasterDiscovery(); void handleRadio(); void updateMaster(); @@ -38,6 +41,7 @@ class App { void printConfiguration(); void printStageStats(const StageStats &s, uint32_t hz); void showStageResult(const StageStats &s); + void showDriverResult(const DriverStats &s, bool testPassed = false); void showRemoteResult(const ProtocolPacket &packet); void fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) const; void showStageProgress(); @@ -49,8 +53,15 @@ class App { bool packetForCurrent(const ProtocolPacket &p) const; void serviceIdlePowerSave(); void serviceRxPinStateLog(); + void serviceSerialConsole(); + void handleSerialCommand(char *line); + void printSerialHelp(); + void printSerialStatus(); + bool serialSettingsMutable() const; + void finishSerialSettingsChange(); void leaveIdlePowerSave(bool wakeDisplay = true); bool idlePowerSaveAllowed() const; + bool usbHostPresent() const; void setStandbyOpticalOutput(); void setActivePerformance(bool active); @@ -62,6 +73,7 @@ class App { PwmGenerator pwm_; PulseReceiver receiver_; Measurement measurement_; + DriverTest driverTest_; Radio radio_; AppState state_ = AppState::IDLE; uint8_t menuItem_ = 0; @@ -88,4 +100,8 @@ class App { uint32_t lastOpticalWakeToggleMs_ = 0; bool opticalWakeActive_ = false; bool rxPinStateKnown_ = false, rxPinState_ = false; + mutable uint32_t lastUsbHostSeenMs_ = 0; + char serialLine_[96] = {}; + uint8_t serialLineLength_ = 0; + bool serialLineOverflow_ = false; }; diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h index a22ee9f..3ba2bd3 100644 --- a/OpticalChannelTester/Config.h +++ b/OpticalChannelTester/Config.h @@ -74,15 +74,12 @@ 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 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 +// Fixed PCB conversion between electrical GPIO levels and actual optical +// light. User settings HH/HL/LH/LL operate only in the optical domain and +// never change these hardware facts. +#define TX_LIGHT_ON_GPIO_LEVEL LOW +#define RX_LIGHT_ON_GPIO_LEVEL LOW +#define TX_LIGHT_OFF_GPIO_LEVEL (TX_LIGHT_ON_GPIO_LEVEL == HIGH ? LOW : HIGH) #define PWM_SETTLE_CYCLES 5U constexpr uint32_t BUTTON_DEBOUNCE_MS = 30; @@ -95,8 +92,8 @@ constexpr uint32_t LINK_REPLY_TIMEOUT_MS = 1500; constexpr uint8_t LINK_PACKET_RETRIES = 10; constexpr uint32_t LINK_RETRY_INTERVAL_MS = 1000; constexpr uint32_t DISCOVERY_RETRY_INTERVAL_MS = 20; -// During discovery Master alternates PWM_ACTIVE_LEVEL and PWM_SAFE_LEVEL to -// wake a sleeping Slave through the optical channel. +// During discovery Master alternates actual optical light ON and OFF to wake +// a sleeping Slave through the optical channel. constexpr uint32_t OPTICAL_WAKE_HALF_PERIOD_MS = 50; constexpr uint32_t LINK_HEARTBEAT_INTERVAL_MS = 500; constexpr uint32_t LINK_HEARTBEAT_TIMEOUT_MS = 2500; @@ -114,6 +111,9 @@ constexpr uint8_t MEASUREMENT_PROGRESS_STEPS = 10; constexpr uint32_t OLED_PROGRESS_UPDATE_MS = 15; constexpr uint32_t IDLE_POWER_SAVE_TIMEOUT_MS = 60000; +// usb_serial_jtag_is_connected() needs no open COM port or CDC traffic, but a +// short SOF detection gap must not send the board to sleep. +constexpr uint32_t USB_HOST_DISCONNECT_GRACE_MS = 5000; constexpr uint16_t SLAVE_LISTEN_INTERVAL_MS = 100; constexpr uint16_t SLAVE_LISTEN_WINDOW_MS = 20; static_assert(SLAVE_LISTEN_WINDOW_MS < SLAVE_LISTEN_INTERVAL_MS, @@ -139,6 +139,22 @@ constexpr uint8_t LEDC_MAX_BITS = 14; constexpr uint32_t MCPWM_RESOLUTION_HZ = 20000000; constexpr uint32_t MCPWM_MAX_PERIOD_TICKS = 65535; +// Concept 1SP0635 status acknowledgement, expressed in the optical domain. +constexpr uint32_t DRIVER_MIN_INPUT_PULSE_NS = 2000; +constexpr uint32_t DRIVER_ACK_DELAY_NS = 250; +constexpr uint32_t DRIVER_ACK_WIDTH_NS = 700; +constexpr uint32_t DRIVER_ACK_START_MAX_NS = 2000; +constexpr uint32_t DRIVER_ACK_MERGE_MARGIN_NS = 250; +// Any response this long is a fault, not a normal acknowledgement. +constexpr uint32_t DRIVER_FAULT_MIN_NS = 1500; +// A short circuit is about 9 us. Gate-monitoring may be stretched by an +// overlapping turn-off ACK, but remains shorter on the tested driver. +constexpr uint32_t DRIVER_SHORT_CIRCUIT_MIN_NS = 6000; +constexpr uint32_t DRIVER_RX_STUCK_MIN_NS = 20000; +// Retained by the generic receiver backend; the driver test itself uses the +// stricter ACK start deadline above. +constexpr uint32_t DRIVER_RESPONSE_TIMEOUT_NS = 10000; + // -------------------------- Menu value arrays ----------------------------- // 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 @@ -147,13 +163,13 @@ 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 + 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000 }; constexpr uint32_t MIN_PULSE_OPTIONS_NS[] = { - 250, 500, 1000, 2000, 5000, 10000 + 250, 500, 1000, 2000, 5000, 10000, 50000 }; constexpr uint32_t TEST_PULSE_WIDTHS_NS[] = { - 250, 500, 1000, 2000, 5000, 10000, 20000, 50000, + 50, 100, 150, 200, 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}; diff --git a/OpticalChannelTester/Config_Text.h b/OpticalChannelTester/Config_Text.h index 12ad205..9272914 100644 --- a/OpticalChannelTester/Config_Text.h +++ b/OpticalChannelTester/Config_Text.h @@ -17,6 +17,10 @@ constexpr const char *ROLE_NAMES[] = { "СОЛО", "МАСТЕР", "СЛЕЙВ" }; +constexpr const char *TEST_NAMES[] = { + "ОПТИКА", "ДРАЙВЕР" +}; + constexpr const char *FAIL_NAMES[] = { "НЕТ ОШИБКИ", "НЕТ СИГНАЛА", @@ -29,7 +33,13 @@ constexpr const char *FAIL_NAMES[] = { "СВЯЗЬ ПОТЕРЯНА", "РЕЖИМ НЕ ПОДДЕРЖИВ.", "НЕ ХВАТАЕТ ТОЧНОСТИ", - "ТЕСТ ОСТАНОВЛЕН" + "ТЕСТ ОСТАНОВЛЕН", + "НЕТ ОТВЕТА ACK", + "ТАЙМИНГ ACK", + "АВАРИЯ ДРАЙВЕРА", + "ОТВЕТЫ ACK СЛИЛИСЬ", + "ОШИБКА ЗАТВОРА", + "КОРОТКОЕ ЗАМЫКАНИЕ" }; constexpr const char *MODE_PREFIX = "РЕЖИМ: "; @@ -40,6 +50,8 @@ 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_LIGHT_CODE = "АКТ. УРОВЕНЬ:"; +constexpr const char *LIGHT_CODE_FORMAT = "TX:%c, RX:%c"; constexpr const char *MENU_TOTAL_TIME = "ОБЩЕЕ ВРЕМЯ:"; constexpr const char *FREQUENCY_UNIT = " Гц"; @@ -48,7 +60,7 @@ constexpr const char *WAIT_MASTER = "ОЖИДАНИЕ МАСТЕРА"; constexpr const char *LINK_FAILED = "СВЯЗЬ НЕ УСТАНОВЛЕНА"; constexpr const char *RADIO_ERROR = "ОШИБКА СВЯЗИ"; constexpr const char *MASTER_SEARCH = "ПОИСК СЛЕЙВА"; -constexpr const char *HOLD_START_STOP = "УДЕРЖ. START ДЛЯ СТОП"; +constexpr const char *HOLD_START_STOP = "УДЕРЖ. ПУСК ДЛЯ СТОП"; constexpr const char *MASTER_SEEN = "МАСТЕР ОБНАРУЖЕН"; constexpr const char *ACK_SENT = "ОТВЕТ ОТПРАВЛЕН"; constexpr const char *START_AGAIN = "ГОТОВ К ЗАПУСКУ"; @@ -57,9 +69,13 @@ constexpr const char *TEST_FAILED = "ТЕСТ НЕ ПРОЙДЕН"; constexpr const char *PASS_WORD = "ТЕСТ ПРОЙДЕН"; 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 *TEST_TARGET_FORMAT = "ТЕСТ: %s"; +constexpr const char *FAIL_TARGET_FORMAT = "СБОЙ: %s"; +constexpr const char *PERIOD_OUT_FORMAT = "ЧАСТОТА: %s"; +constexpr const char *DUTY_OUT_FORMAT = "ИМПУЛЬС: %s"; constexpr const char *NO_MEASUREMENT = "F:---, P:---"; +constexpr const char *DRIVER_RESPONSE_FORMAT = "ACK:%lu D:%luns"; +constexpr const char *DRIVER_MEASUREMENT_FORMAT = "D: %s, P: %s"; #elif UI_LANGUAGE == UI_LANGUAGE_EN @@ -67,6 +83,10 @@ constexpr const char *ROLE_NAMES[] = { "SOLO", "MASTER", "SLAVE" }; +constexpr const char *TEST_NAMES[] = { + "OPTICAL", "DRIVER" +}; + constexpr const char *FAIL_NAMES[] = { "NONE", "NO SIGNAL", @@ -79,7 +99,13 @@ constexpr const char *FAIL_NAMES[] = { "LINK LOST", "UNSUPPORTED", "RESOLUTION", - "ABORTED" + "ABORTED", + "ACK MISSING", + "ACK TIMING", + "DRIVER FAULT", + "ACK MERGED", + "GATE FAULT", + "SHORT CIRCUIT FAULT" }; constexpr const char *MODE_PREFIX = "MODE: "; @@ -90,6 +116,8 @@ 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_LIGHT_CODE = "ACTIVE LEVEL:"; +constexpr const char *LIGHT_CODE_FORMAT = "TX:%c, RX:%c"; constexpr const char *MENU_TOTAL_TIME = "TOTAL TIME:"; constexpr const char *FREQUENCY_UNIT = " Hz"; @@ -107,9 +135,13 @@ constexpr const char *TEST_FAILED = "TEST FAILED"; constexpr const char *PASS_WORD = "TEST PASS"; constexpr const char *FAIL_FORMAT = "FAIL %s"; constexpr const char *TEST_FORMAT = "%s, %s"; +constexpr const char *TEST_TARGET_FORMAT = "TEST: %s"; +constexpr const char *FAIL_TARGET_FORMAT = "FAIL AT %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:---"; +constexpr const char *DRIVER_RESPONSE_FORMAT = "ACK:%lu D:%luns"; +constexpr const char *DRIVER_MEASUREMENT_FORMAT = "D: %s, P: %s"; #else #error "UI_LANGUAGE must be UI_LANGUAGE_EN or UI_LANGUAGE_RU" diff --git a/OpticalChannelTester/Core.cpp b/OpticalChannelTester/Core.cpp index 7e39809..3ab7fbb 100644 --- a/OpticalChannelTester/Core.cpp +++ b/OpticalChannelTester/Core.cpp @@ -9,10 +9,24 @@ const char *roleName(Role r) { return i < 3 ? names[i] : "?"; } +const char *testKindName(TestKind kind) { + static const char *names[] = {"OPTICAL", "DRIVER"}; + const uint8_t i = static_cast(kind); + return i < 2 ? names[i] : "?"; +} + +const char *lightCodeName(LightCode code) { + static const char *names[] = {"HH", "HL", "LH", "LL"}; + const uint8_t i = static_cast(code); + return i < 4 ? names[i] : "??"; +} + const char *failName(FailReason r) { static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "PULSE OUT", "EXTRA EDGE", "GLITCH", "LOST EDGE", "DATA LOSS ERROR", "LINK LOST", - "UNSUPPORTED", "RESOLUTION", "ABORTED"}; + "UNSUPPORTED", "RESOLUTION", "ABORTED", "ACK MISSING", "ACK TIMING", + "DRIVER FAULT", "ACK MERGED", "GATE MONITORING FAULT", + "SHORT CIRCUIT FAULT"}; const uint8_t i = static_cast(r); return i < (sizeof(names) / sizeof(names[0])) ? names[i] : "UNKNOWN"; } @@ -31,6 +45,14 @@ uint32_t settingsChecksum(const Settings &s) { return hash; } +bool txActiveLightOn(const Settings &s) { + return static_cast(s.lightCode) < static_cast(LightCode::LH); +} + +bool rxActiveLightOn(const Settings &s) { + return (static_cast(s.lightCode) & 1U) == 0U; +} + uint32_t pulseWidthPointCount(uint32_t maxPulseNs, uint32_t minPulseNs) { if (!minPulseNs || maxPulseNs < minPulseNs) return 0; uint32_t count = 0; diff --git a/OpticalChannelTester/Core.h b/OpticalChannelTester/Core.h index 00ccfab..8a5fb94 100644 --- a/OpticalChannelTester/Core.h +++ b/OpticalChannelTester/Core.h @@ -4,22 +4,31 @@ #include enum class Role : uint8_t { SOLO, MASTER, SLAVE }; +enum class TestKind : uint8_t { OPTICAL, DRIVER }; +enum class LightCode : uint8_t { HH, HL, LH, LL }; enum class FailReason : uint8_t { NONE, NO_SIGNAL, PERIOD_OUT, DUTY_OUT, EXTRA_EDGE, GLITCH, LOST_EDGE, - DATA_LOSS, LINK_LOST, UNSUPPORTED, RESOLUTION, ABORTED + DATA_LOSS, LINK_LOST, UNSUPPORTED, RESOLUTION, ABORTED, + ACK_MISSING, ACK_TIMING, DRIVER_FAULT, ACK_MERGED, + GATE_MONITOR_FAULT, SHORT_CIRCUIT_FAULT }; const char *roleName(Role role); +const char *testKindName(TestKind kind); +const char *lightCodeName(LightCode code); const char *failName(FailReason reason); struct Settings { uint16_t version; uint8_t role; + uint8_t testKind; + uint8_t lightCode; uint8_t frequencyIndex; uint8_t maxPulseIndex; uint8_t minPulseIndex; uint8_t accuracyIndex; uint8_t timeIndex; + uint16_t reserved; uint32_t checksum; }; @@ -71,6 +80,8 @@ struct IntegerPwmConfig { }; uint32_t settingsChecksum(const Settings &s); +bool txActiveLightOn(const Settings &s); +bool rxActiveLightOn(const Settings &s); 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); diff --git a/OpticalChannelTester/DriverTest.cpp b/OpticalChannelTester/DriverTest.cpp new file mode 100644 index 0000000..d42fbae --- /dev/null +++ b/OpticalChannelTester/DriverTest.cpp @@ -0,0 +1,635 @@ +#include "DriverTest.h" + +#include "Config.h" +#include "Log.h" + +#include +#include +#include +#include +#include + +void DriverEdgeStats::reset() { + memset(this, 0, sizeof(*this)); + minDelayTicks = minResponseTicks = UINT32_MAX; +} + +void DriverStats::reset() { + memset(this, 0, sizeof(*this)); + minDelayTicks = minResponseTicks = UINT32_MAX; + turnOn.reset(); + turnOff.reset(); + reason = FailReason::NONE; +} + +uint64_t DriverTest::nsToTicks(uint32_t ns) const { + return (static_cast(ns) * captureHz_ + 999999999ULL) / + 1000000000ULL; +} + +uint64_t DriverTest::ticksToNs(uint64_t ticks) const { + return (ticks * 1000000000ULL + captureHz_ / 2U) / captureHz_; +} + +bool DriverTest::start(uint32_t frequencyHz, uint32_t pulseNs, + float tolerancePct, uint32_t testTimeMs, + uint8_t settleCycles, bool activeTxLightOn, + bool activeRxLightOn) { + (void)tolerancePct; + (void)activeRxLightOn; + if (!receiver_.highRateBackend() || !frequencyHz || !pulseNs || + !testTimeMs || GPIO_PWM >= 32U || GPIO_RX >= 32U) return false; + + requestCaptureStop(); + if (!waitCaptureStopped(25U)) return false; + if (!pollTask_ && xTaskCreatePinnedToCore(pollTaskEntry, "driver-poll", + 3072, this, configMAX_PRIORITIES - 1U, &pollTask_, 0) != pdPASS) + return false; + if (!analyzerTask_ && xTaskCreatePinnedToCore(analyzerTaskEntry, + "driver-analyze", 4096, this, 4, &analyzerTask_, 1) != pdPASS) + return false; + + captureHz_ = getCpuFrequencyMhz() * 1000000UL; + if (!captureHz_ || captureHz_ % frequencyHz) return false; + pollPeriodCycles_ = captureHz_ / frequencyHz; + pollWindowBeforeCycles_ = captureHz_ / 200000U; // 5 us + const uint64_t periodNs = 1000000000ULL / frequencyHz; + uint64_t windowNs = pulseNs + 50000ULL; + const uint64_t maximumWindowNs = periodNs * 3ULL / 4ULL; + if (windowNs > maximumWindowNs) windowNs = maximumWindowNs; + pollWindowAfterCycles_ = static_cast( + windowNs * captureHz_ / 1000000000ULL); + const uint8_t activeTxRaw = activeTxLightOn ? TX_LIGHT_ON_GPIO_LEVEL : + TX_LIGHT_OFF_GPIO_LEVEL; + pollTxStartRawHigh_ = activeTxRaw == HIGH; + rxActiveRawHigh_ = RX_LIGHT_ON_GPIO_LEVEL == LOW; // ACK/fault = light OFF + + ackStartMaxTicks_ = nsToTicks(DRIVER_ACK_START_MAX_NS); + faultLongTicks_ = nsToTicks(DRIVER_FAULT_MIN_NS); + shortCircuitTicks_ = nsToTicks(DRIVER_SHORT_CIRCUIT_MIN_NS); + stuckTicks_ = nsToTicks(DRIVER_RX_STUCK_MIN_NS); + testTicks_ = static_cast(captureHz_) * testTimeMs / 1000ULL; + subsampleTicks_ = testTicks_ / SUBSAMPLE_COUNT; + if (!pollPeriodCycles_ || !pollWindowAfterCycles_ || !ackStartMaxTicks_ || + !faultLongTicks_ || !shortCircuitTicks_ || !stuckTicks_ || + !testTicks_ || !subsampleTicks_) + return false; + + clearCapture(); + stats_.reset(); + publishStats(); + pendingCount_ = 0; + response_ = {}; + measurementStartTick_ = deadlineTick_ = 0; + pointOriginTick_ = lastEventTick_ = 0; + settleCycles_ = settleCycles; + settledCycles_ = 0; + completedSubsamples_ = 0; + measurementClosed_ = false; + havePointOrigin_ = false; + rxActive_ = (gpio_get_level(static_cast(GPIO_RX)) != 0) == + rxActiveRawHigh_; + currentStep_ = 0; + traceWrite_ = traceCount_ = 0; + __atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE); + state_ = DriverState::SETTLING; + return armCapture(); +} + +bool DriverTest::armCapture() { + Serial.flush(); + if (!__atomic_load_n(&core0WdtDisabled_, __ATOMIC_ACQUIRE)) { + const bool disabled = disableCore0WDT(); + __atomic_store_n(&core0WdtDisabled_, disabled, __ATOMIC_RELEASE); + if (!disabled) { + state_ = DriverState::IDLE; + return false; + } + } + __atomic_store_n(&captureReady_, false, __ATOMIC_RELEASE); + __atomic_store_n(&captureActive_, true, __ATOMIC_RELEASE); + xTaskNotifyGive(pollTask_); + const uint32_t readyDeadline = millis() + 25U; + while (!__atomic_load_n(&captureReady_, __ATOMIC_ACQUIRE) && + static_cast(millis() - readyDeadline) < 0) delay(0); + if (!__atomic_load_n(&captureReady_, __ATOMIC_ACQUIRE)) { + requestCaptureStop(); + waitCaptureStopped(25U); + state_ = DriverState::IDLE; + return false; + } + xTaskNotifyGive(analyzerTask_); + return true; +} + +bool DriverTest::resumeSubsample() { + if (state_ != DriverState::SUBSAMPLE_DONE) return false; + if (!waitCaptureStopped(25U)) { + fail(FailReason::DATA_LOSS, lastEventTick_); + return false; + } + + clearCapture(); + pendingCount_ = 0; + response_ = {}; + measurementStartTick_ = deadlineTick_ = 0; + settledCycles_ = 0; + measurementClosed_ = false; + rxActive_ = (gpio_get_level(static_cast(GPIO_RX)) != 0) == + rxActiveRawHigh_; + state_ = DriverState::SETTLING; + if (armCapture()) return true; + fail(FailReason::DATA_LOSS, lastEventTick_); + return false; +} + +void DriverTest::pollTaskEntry(void *context) { + static_cast(context)->pollTaskLoop(); +} + +void DriverTest::pollTaskLoop() { + constexpr uint32_t PIN_MASK = (1UL << GPIO_PWM) | (1UL << GPIO_RX); + for (;;) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + uint32_t levels = GPIO.in & PIN_MASK; + uint32_t nextStart = 0; + uint32_t windowEnd = 0; + uint32_t lastTxStart = 0; + RawEvent hotEvents[32] = {}; + uint8_t hotCount = 0; + bool sawTxStart = false; + bool critical = false; + + auto sampleOnce = [&]() { + const uint32_t current = GPIO.in & PIN_MASK; + if (current == levels) return; + const uint32_t now = esp_cpu_get_cycle_count(); + const uint32_t changed = current ^ levels; + if ((changed & (1UL << GPIO_PWM)) && hotCount < 32U) + hotEvents[hotCount++] = {now, + (current & (1UL << GPIO_PWM)) != 0U, Source::TX}; + if ((changed & (1UL << GPIO_RX)) && hotCount < 32U) + hotEvents[hotCount++] = {now, + (current & (1UL << GPIO_RX)) != 0U, Source::RX}; + if ((changed & (1UL << GPIO_PWM)) && + ((current & (1UL << GPIO_PWM)) != 0U) == pollTxStartRawHigh_) { + lastTxStart = now; + sawTxStart = true; + } + levels = current; + }; + + auto flushHot = [&]() { + for (uint8_t i = 0; i < hotCount; ++i) + recordRaw(hotEvents[i].tick, hotEvents[i].rising, + hotEvents[i].source); + hotCount = 0; + }; + + portENTER_CRITICAL(&pollMux_); + critical = true; + __atomic_store_n(&captureReady_, true, __ATOMIC_RELEASE); + while (__atomic_load_n(&captureActive_, __ATOMIC_ACQUIRE) && !sawTxStart) + for (uint8_t i = 0; i < 16U; ++i) sampleOnce(); + if (sawTxStart) windowEnd = lastTxStart + pollWindowAfterCycles_; + const bool synchronized = sawTxStart; + + while (__atomic_load_n(&captureActive_, __ATOMIC_ACQUIRE) && synchronized) { + while (__atomic_load_n(&captureActive_, __ATOMIC_ACQUIRE) && + static_cast(esp_cpu_get_cycle_count() - windowEnd) < 0) + for (uint8_t i = 0; i < 16U; ++i) sampleOnce(); + + portEXIT_CRITICAL(&pollMux_); + critical = false; + flushHot(); + if (!__atomic_load_n(&captureActive_, __ATOMIC_ACQUIRE)) break; + nextStart = lastTxStart + pollPeriodCycles_; + sawTxStart = false; + + uint32_t outsideSpins = 0; + while (__atomic_load_n(&captureActive_, __ATOMIC_ACQUIRE) && + static_cast(esp_cpu_get_cycle_count() - + (nextStart - pollWindowBeforeCycles_)) < 0) { + for (uint8_t i = 0; i < 16U; ++i) sampleOnce(); + if (++outsideSpins >= 256U) { + outsideSpins = 0; + taskYIELD(); + } + } + if (!__atomic_load_n(&captureActive_, __ATOMIC_ACQUIRE)) break; + + portENTER_CRITICAL(&pollMux_); + critical = true; + windowEnd = nextStart + pollWindowAfterCycles_; + } + if (critical) portEXIT_CRITICAL(&pollMux_); + flushHot(); + if (__atomic_exchange_n(&core0WdtDisabled_, false, + __ATOMIC_ACQ_REL)) enableCore0WDT(); + __atomic_store_n(&captureReady_, false, __ATOMIC_RELEASE); + } +} + +void DriverTest::analyzerTaskEntry(void *context) { + static_cast(context)->analyzerTaskLoop(); +} + +void DriverTest::analyzerTaskLoop() { + TimedEvent events[64] = {}; + for (;;) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + while (state_ == DriverState::SETTLING || state_ == DriverState::RUNNING) { + const size_t count = readRaw(events, 64, pdMS_TO_TICKS(1)); + for (size_t i = 0; i < count && + (state_ == DriverState::SETTLING || state_ == DriverState::RUNNING); + ++i) processEvent(events[i]); + const uint32_t dropped = takeDropped(); + if (dropped) { + stats_.droppedItems += dropped; + fail(FailReason::DATA_LOSS, lastEventTick_); + } + } + } +} + +void DriverTest::processEvent(const TimedEvent &event) { + lastEventTick_ = event.tick; + if (!havePointOrigin_) { + pointOriginTick_ = event.tick; + havePointOrigin_ = true; + } + rememberTrace(event); + if (state_ == DriverState::SETTLING) processSettling(event); + else if (state_ == DriverState::RUNNING) processRunning(event); +} + +void DriverTest::processSettling(const TimedEvent &event) { + if (event.source == Source::RX) { + rxActive_ = event.rising == rxActiveRawHigh_; + return; + } + const uint8_t rawLevel = event.rising ? HIGH : LOW; + const bool lightOn = rawLevel == TX_LIGHT_ON_GPIO_LEVEL; + if (!lightOn) return; + if (settledCycles_ < settleCycles_) { + ++settledCycles_; + return; + } + if (rxActive_) return; + state_ = DriverState::RUNNING; + measurementStartTick_ = event.tick; + const uint64_t measuredBefore = + static_cast(completedSubsamples_) * subsampleTicks_; + const uint64_t thisSubsampleTicks = + completedSubsamples_ + 1U == SUBSAMPLE_COUNT ? + testTicks_ - measuredBefore : subsampleTicks_; + deadlineTick_ = event.tick + thisSubsampleTicks; + processTx(event, true); +} + +void DriverTest::processRunning(const TimedEvent &event) { + expirePending(event.tick); + if (state_ != DriverState::RUNNING) return; + + if (response_.active && event.tick - response_.startTick >= stuckTicks_) { + const uint64_t delay = response_.associated ? + response_.startTick - response_.tx.tick : 0; + fail(FailReason::SHORT_CIRCUIT_FAULT, event.tick, delay, + event.tick - response_.startTick); + return; + } + if (event.source == Source::TX) { + const uint8_t rawLevel = event.rising ? HIGH : LOW; + const bool lightOn = rawLevel == TX_LIGHT_ON_GPIO_LEVEL; + if (event.tick < deadlineTick_) processTx(event, lightOn); + else measurementClosed_ = true; + } else processRx(event, event.rising == rxActiveRawHigh_); + completeIfPossible(event.tick); +} + +bool DriverTest::addPending(uint64_t tick, bool lightOn) { + if (pendingCount_ >= MAX_PENDING) { + fail(FailReason::DATA_LOSS, tick); + return false; + } + pending_[pendingCount_++] = {tick, lightOn}; + return true; +} + +void DriverTest::processTx(const TimedEvent &event, bool lightOn) { + if (!addPending(event.tick, lightOn)) return; + ++stats_.inputEdges; +} + +int8_t DriverTest::matchingPending(uint64_t rxTick) const { + for (uint8_t i = 0; i < pendingCount_; ++i) + if (rxTick >= pending_[i].tick && + rxTick - pending_[i].tick <= ackStartMaxTicks_) + return static_cast(i); + return -1; +} + +void DriverTest::removePending(uint8_t index) { + if (index >= pendingCount_) return; + for (uint8_t i = index + 1U; i < pendingCount_; ++i) + pending_[i - 1U] = pending_[i]; + --pendingCount_; +} + +void DriverTest::processRx(const TimedEvent &event, bool activeNow) { + rxActive_ = activeNow; + if (activeNow) { + if (response_.active) { + fail(FailReason::DATA_LOSS, event.tick); + return; + } + response_ = {}; + response_.active = true; + response_.startTick = event.tick; + const int8_t index = matchingPending(event.tick); + if (index >= 0) { + response_.associated = true; + response_.tx = pending_[index]; + removePending(static_cast(index)); + } else ++stats_.unexpectedResponses; + return; + } + + if (!response_.active) return; + const uint64_t width = event.tick - response_.startTick; + if (!response_.associated) { + response_ = {}; + fail(width >= shortCircuitTicks_ ? FailReason::SHORT_CIRCUIT_FAULT : + FailReason::GATE_MONITOR_FAULT, + event.tick, 0, width); + return; + } + + const uint64_t guard = mergeGuardTicks(); + for (uint8_t i = 0; i < pendingCount_; ++i) { + if (pending_[i].tick > response_.startTick && + event.tick - pending_[i].tick >= guard) { + const uint64_t delay = response_.startTick - response_.tx.tick; + response_ = {}; + fail(FailReason::ACK_MERGED, event.tick, delay, width); + return; + } + } + if (width >= faultLongTicks_) { + const uint64_t delay = response_.startTick - response_.tx.tick; + response_ = {}; + fail(width >= shortCircuitTicks_ ? FailReason::SHORT_CIRCUIT_FAULT : + FailReason::GATE_MONITOR_FAULT, + event.tick, delay, width); + return; + } + + const uint64_t delay = response_.startTick - response_.tx.tick; + const bool lightOn = response_.tx.lightOn; + response_ = {}; + acceptAcknowledgement(delay, width, lightOn); +} + +uint64_t DriverTest::mergeGuardTicks() const { + uint32_t observedMax = stats_.maxDelayTicks; + const uint64_t baseline = observedMax ? observedMax : + nsToTicks(DRIVER_ACK_DELAY_NS + 500U); + return baseline + nsToTicks(DRIVER_ACK_MERGE_MARGIN_NS); +} + +void DriverTest::acceptAcknowledgement(uint64_t delay, uint64_t width, + bool lightOn) { + const uint32_t delay32 = delay > UINT32_MAX ? UINT32_MAX : + static_cast(delay); + const uint32_t width32 = width > UINT32_MAX ? UINT32_MAX : + static_cast(width); + stats_.lastDelayTicks = delay32; + stats_.lastResponseTicks = width32; + ++stats_.responses; + if (delay32 < stats_.minDelayTicks) stats_.minDelayTicks = delay32; + if (delay32 > stats_.maxDelayTicks) stats_.maxDelayTicks = delay32; + if (width32 < stats_.minResponseTicks) stats_.minResponseTicks = width32; + if (width32 > stats_.maxResponseTicks) stats_.maxResponseTicks = width32; + + DriverEdgeStats &edge = lightOn ? stats_.turnOn : stats_.turnOff; + ++edge.responses; + edge.delaySumTicks += delay32; + edge.responseSumTicks += width32; + if (delay32 < edge.minDelayTicks) edge.minDelayTicks = delay32; + if (delay32 > edge.maxDelayTicks) edge.maxDelayTicks = delay32; + if (width32 < edge.minResponseTicks) edge.minResponseTicks = width32; + if (width32 > edge.maxResponseTicks) edge.maxResponseTicks = width32; + publishStats(); +} + +void DriverTest::expirePending(uint64_t now) { + for (uint8_t i = 0; i < pendingCount_; ++i) { + if (now <= pending_[i].tick + ackStartMaxTicks_) continue; + fail(FailReason::ACK_MISSING, now, now - pending_[i].tick, 0); + return; + } +} + +void DriverTest::completeIfPossible(uint64_t now) { + if (state_ != DriverState::RUNNING) return; + if (!measurementClosed_ && now >= deadlineTick_) measurementClosed_ = true; + if (!measurementClosed_ || response_.active || pendingCount_) return; + if (!stats_.turnOn.responses || !stats_.turnOff.responses) { + fail(FailReason::ACK_MISSING, now); + return; + } + ++completedSubsamples_; + currentStep_ = completedSubsamples_; + publishStats(); + requestCaptureStop(); + if (!waitCaptureStopped(25U)) { + fail(FailReason::DATA_LOSS, lastEventTick_); + return; + } + if (completedSubsamples_ >= SUBSAMPLE_COUNT) { + __atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE); + state_ = DriverState::PASS; + } else { + __atomic_store_n(&progressUpdatePending_, true, __ATOMIC_RELEASE); + state_ = DriverState::SUBSAMPLE_DONE; + } +} + +void DriverTest::fail(FailReason reason, uint64_t tick, uint64_t delay, + uint64_t pulseWidth) { + if (state_ == DriverState::FAIL || state_ == DriverState::PASS) return; + if (stats_.reason == FailReason::NONE) { + stats_.reason = reason; + if (tick && havePointOrigin_ && tick >= pointOriginTick_) + stats_.errorElapsedTicks = tick - pointOriginTick_; + if (delay) { + stats_.errorDelayTicks = delay > UINT32_MAX ? UINT32_MAX : + static_cast(delay); + stats_.errorDelayValid = true; + } + if (pulseWidth) { + stats_.errorPulseTicks = pulseWidth > UINT32_MAX ? UINT32_MAX : + static_cast(pulseWidth); + stats_.errorPulseValid = true; + } + } + publishStats(); + __atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE); + requestCaptureStop(); + state_ = DriverState::FAIL; +} + +void DriverTest::publishStats() { + portENTER_CRITICAL(&statsMux_); + publishedStats_ = stats_; + portEXIT_CRITICAL(&statsMux_); +} + +void DriverTest::forceFail(FailReason reason) { + if (state_ == DriverState::SETTLING || state_ == DriverState::RUNNING || + state_ == DriverState::SUBSAMPLE_DONE) + fail(reason, lastEventTick_); +} + +void DriverTest::abort() { + if (state_ == DriverState::SETTLING || state_ == DriverState::RUNNING || + state_ == DriverState::SUBSAMPLE_DONE) + fail(FailReason::ABORTED, lastEventTick_); + else { + requestCaptureStop(); + state_ = DriverState::IDLE; + } +} + +bool DriverTest::takeProgressUpdate() { + return __atomic_exchange_n(&progressUpdatePending_, false, + __ATOMIC_ACQ_REL); +} + +void DriverTest::requestCaptureStop() { + __atomic_store_n(&captureActive_, false, __ATOMIC_RELEASE); +} + +bool DriverTest::waitCaptureStopped(uint32_t timeoutMs) { + const uint32_t deadline = millis() + timeoutMs; + while (__atomic_load_n(&captureReady_, __ATOMIC_ACQUIRE) && + static_cast(millis() - deadline) < 0) delay(0); + if (__atomic_load_n(&captureReady_, __ATOMIC_ACQUIRE)) return false; + if (__atomic_exchange_n(&core0WdtDisabled_, false, + __ATOMIC_ACQ_REL)) enableCore0WDT(); + return true; +} + +void DriverTest::clearCapture() { + const uint16_t write = __atomic_load_n(&ringWrite_, __ATOMIC_ACQUIRE); + __atomic_store_n(&ringRead_, write, __ATOMIC_RELEASE); + __atomic_store_n(&droppedItems_, 0U, __ATOMIC_RELEASE); + haveRawTick_ = false; + lastRawTick_ = 0; + tickEpoch_ = 0; +} + +void IRAM_ATTR DriverTest::recordRaw(uint32_t tick, bool rising, + Source source) { + const uint16_t write = ringWrite_; + const uint16_t next = static_cast( + (write + 1U) & (RING_CAPACITY - 1U)); + if (next == ringRead_) { + ++droppedItems_; + return; + } + ring_[write] = {tick, rising, source}; + asm volatile("memw" ::: "memory"); + ringWrite_ = next; +} + +size_t DriverTest::readRaw(TimedEvent *events, size_t capacity, + TickType_t waitTicks) { + if (!events || !capacity) return 0; + uint16_t read = __atomic_load_n(&ringRead_, __ATOMIC_RELAXED); + if (read == __atomic_load_n(&ringWrite_, __ATOMIC_ACQUIRE) && waitTicks) { + vTaskDelay(waitTicks); + read = __atomic_load_n(&ringRead_, __ATOMIC_RELAXED); + } + const uint16_t write = __atomic_load_n(&ringWrite_, __ATOMIC_ACQUIRE); + size_t count = 0; + while (read != write && count < capacity) { + const RawEvent raw = ring_[read]; + read = static_cast((read + 1U) & (RING_CAPACITY - 1U)); + if (haveRawTick_ && raw.tick < lastRawTick_ && + lastRawTick_ - raw.tick > 0x80000000UL) tickEpoch_ += 1ULL << 32U; + lastRawTick_ = raw.tick; + haveRawTick_ = true; + events[count++] = {tickEpoch_ + raw.tick, raw.rising, raw.source}; + } + __atomic_store_n(&ringRead_, read, __ATOMIC_RELEASE); + return count; +} + +uint32_t DriverTest::takeDropped() { + return __atomic_exchange_n(&droppedItems_, 0U, __ATOMIC_ACQ_REL); +} + +void DriverTest::rememberTrace(const TimedEvent &event) { + trace_[traceWrite_] = {event.tick, static_cast(event.source), + static_cast(event.rising), static_cast(state_), + pendingCount_}; + traceWrite_ = static_cast((traceWrite_ + 1U) % TRACE_CAPACITY); + if (traceCount_ < TRACE_CAPACITY) ++traceCount_; +} + +void DriverTest::printSummary() const { + auto printEdge = [&](const char *name, const DriverEdgeStats &edge) { + if (!edge.responses) { + Log::printf("DRIVER", "%s ACK=0", name); + return; + } + Log::printf("DRIVER", + "%s ACK=%lu D=%lluns/%lluns/%lluns P=%lluns/%lluns/%lluns", + name, static_cast(edge.responses), + static_cast(ticksToNs(edge.minDelayTicks)), + static_cast(ticksToNs( + edge.delaySumTicks / edge.responses)), + static_cast(ticksToNs(edge.maxDelayTicks)), + static_cast(ticksToNs(edge.minResponseTicks)), + static_cast(ticksToNs( + edge.responseSumTicks / edge.responses)), + static_cast(ticksToNs(edge.maxResponseTicks))); + }; + Log::printf("DRIVER", "TX edges=%lu responses=%lu dropped=%lu unexpected=%lu result=%s", + static_cast(publishedStats_.inputEdges), + static_cast(publishedStats_.responses), + static_cast(publishedStats_.droppedItems), + static_cast(publishedStats_.unexpectedResponses), + failName(publishedStats_.reason)); + if (publishedStats_.reason != FailReason::NONE) { + Log::printf("DRIVER", + "error timing: T=%lluns D=%s%lluns P=%s%lluns", + static_cast( + ticksToNs(publishedStats_.errorElapsedTicks)), + publishedStats_.errorDelayValid ? "" : "N/A/", + static_cast( + ticksToNs(publishedStats_.errorDelayTicks)), + publishedStats_.errorPulseValid ? "" : "N/A/", + static_cast( + ticksToNs(publishedStats_.errorPulseTicks))); + } + printEdge("ON", publishedStats_.turnOn); + printEdge("OFF", publishedStats_.turnOff); +} + +void DriverTest::printTrace() const { + if (!traceCount_) return; + const uint8_t first = static_cast( + (traceWrite_ + TRACE_CAPACITY - traceCount_) % TRACE_CAPACITY); + const uint64_t origin = trace_[first].tick; + Log::printf("DRIVER", "RAM trace: %u events, tick=%luHz", traceCount_, + static_cast(captureHz_)); + for (uint8_t i = 0; i < traceCount_; ++i) { + const TraceEvent &event = trace_[(first + i) % TRACE_CAPACITY]; + Log::printf("DRIVER", "E%02u +%lluns %s/%s state=%u pending=%u", i, + static_cast(ticksToNs(event.tick - origin)), + event.source == static_cast(Source::TX) ? "TX" : "RX", + event.rising ? "rise" : "fall", event.state, event.pending); + } +} diff --git a/OpticalChannelTester/DriverTest.h b/OpticalChannelTester/DriverTest.h new file mode 100644 index 0000000..fc83719 --- /dev/null +++ b/OpticalChannelTester/DriverTest.h @@ -0,0 +1,167 @@ +#pragma once + +#include + +#include "Receiver.h" + +enum class DriverState : uint8_t { + IDLE, SETTLING, RUNNING, SUBSAMPLE_DONE, PASS, FAIL +}; + +struct DriverEdgeStats { + uint32_t responses; + uint32_t minDelayTicks; + uint32_t maxDelayTicks; + uint64_t delaySumTicks; + uint32_t minResponseTicks; + uint32_t maxResponseTicks; + uint64_t responseSumTicks; + void reset(); +}; + +struct DriverStats { + uint32_t inputEdges; + uint32_t responses; + uint32_t minDelayTicks; + uint32_t maxDelayTicks; + uint32_t minResponseTicks; + uint32_t maxResponseTicks; + uint32_t lastDelayTicks; + uint32_t lastResponseTicks; + uint32_t droppedItems; + uint32_t unexpectedResponses; + uint64_t errorElapsedTicks; + uint32_t errorDelayTicks; + uint32_t errorPulseTicks; + bool errorDelayValid; + bool errorPulseValid; + DriverEdgeStats turnOn; + DriverEdgeStats turnOff; + FailReason reason; + void reset(); +}; + +class DriverTest { + public: + explicit DriverTest(PulseReceiver &receiver) : receiver_(receiver) {} + bool start(uint32_t frequencyHz, uint32_t pulseNs, float tolerancePct, + uint32_t testTimeMs, uint8_t settleCycles, + bool activeTxLightOn, bool activeRxLightOn); + DriverState update() const { return state_; } + void abort(); + void forceFail(FailReason reason); + bool resumeSubsample(); + bool takeProgressUpdate(); + void printSummary() const; + void printTrace() const; + uint8_t progressStep() const { return currentStep_; } + uint32_t tickHz() const { return captureHz_; } + const DriverStats &stats() const { return publishedStats_; } + + private: + enum class Source : uint8_t { TX, RX }; + struct RawEvent { uint32_t tick; bool rising; Source source; }; + struct TimedEvent { uint64_t tick; bool rising; Source source; }; + struct PendingTx { uint64_t tick; bool lightOn; }; + struct Response { + bool active; + bool associated; + uint64_t startTick; + PendingTx tx; + }; + struct TraceEvent { + uint64_t tick; + uint8_t source; + uint8_t rising; + uint8_t state; + uint8_t pending; + }; + + static void analyzerTaskEntry(void *context); + static void pollTaskEntry(void *context); + void analyzerTaskLoop(); + void pollTaskLoop(); + void processEvent(const TimedEvent &event); + void processSettling(const TimedEvent &event); + void processRunning(const TimedEvent &event); + void processTx(const TimedEvent &event, bool lightOn); + void processRx(const TimedEvent &event, bool activeNow); + void expirePending(uint64_t now); + void completeIfPossible(uint64_t now); + bool addPending(uint64_t tick, bool lightOn); + int8_t matchingPending(uint64_t rxTick) const; + void removePending(uint8_t index); + uint64_t mergeGuardTicks() const; + void acceptAcknowledgement(uint64_t delay, uint64_t width, bool lightOn); + void fail(FailReason reason, uint64_t tick = 0, uint64_t delay = 0, + uint64_t pulseWidth = 0); + void publishStats(); + void rememberTrace(const TimedEvent &event); + bool armCapture(); + void requestCaptureStop(); + bool waitCaptureStopped(uint32_t timeoutMs); + void clearCapture(); + void recordRaw(uint32_t tick, bool rising, Source source); + size_t readRaw(TimedEvent *events, size_t capacity, TickType_t waitTicks); + uint32_t takeDropped(); + uint64_t nsToTicks(uint32_t ns) const; + uint64_t ticksToNs(uint64_t ticks) const; + + static constexpr uint8_t MAX_PENDING = 8; + static constexpr uint8_t SUBSAMPLE_COUNT = 10; + static constexpr uint16_t RING_CAPACITY = 2048; + static constexpr uint8_t TRACE_CAPACITY = 32; + static_assert((RING_CAPACITY & (RING_CAPACITY - 1U)) == 0, + "driver ring capacity must be a power of two"); + + PulseReceiver &receiver_; + TaskHandle_t analyzerTask_ = nullptr; + TaskHandle_t pollTask_ = nullptr; + volatile DriverState state_ = DriverState::IDLE; + DriverStats stats_ = {}; + DriverStats publishedStats_ = {}; + mutable portMUX_TYPE statsMux_ = portMUX_INITIALIZER_UNLOCKED; + + RawEvent ring_[RING_CAPACITY] = {}; + volatile uint16_t ringWrite_ = 0; + volatile uint16_t ringRead_ = 0; + volatile uint32_t droppedItems_ = 0; + volatile bool captureActive_ = false; + volatile bool captureReady_ = false; + volatile bool core0WdtDisabled_ = false; + uint32_t captureHz_ = 0; + uint32_t pollPeriodCycles_ = 0; + uint32_t pollWindowBeforeCycles_ = 0; + uint32_t pollWindowAfterCycles_ = 0; + bool pollTxStartRawHigh_ = false; + portMUX_TYPE pollMux_ = portMUX_INITIALIZER_UNLOCKED; + + PendingTx pending_[MAX_PENDING] = {}; + uint8_t pendingCount_ = 0; + Response response_ = {}; + uint64_t ackStartMaxTicks_ = 0; + uint64_t faultLongTicks_ = 0; + uint64_t shortCircuitTicks_ = 0; + uint64_t stuckTicks_ = 0; + uint64_t testTicks_ = 0; + uint64_t subsampleTicks_ = 0; + uint64_t measurementStartTick_ = 0; + uint64_t deadlineTick_ = 0; + uint64_t pointOriginTick_ = 0; + uint64_t lastEventTick_ = 0; + uint8_t settleCycles_ = 0; + uint8_t settledCycles_ = 0; + uint8_t completedSubsamples_ = 0; + bool rxActiveRawHigh_ = true; + bool rxActive_ = false; + bool measurementClosed_ = false; + bool havePointOrigin_ = false; + bool haveRawTick_ = false; + uint32_t lastRawTick_ = 0; + uint64_t tickEpoch_ = 0; + volatile uint8_t currentStep_ = 0; + volatile bool progressUpdatePending_ = false; + TraceEvent trace_[TRACE_CAPACITY] = {}; + uint8_t traceWrite_ = 0; + uint8_t traceCount_ = 0; +}; diff --git a/OpticalChannelTester/Log.cpp b/OpticalChannelTester/Log.cpp index dd51e80..c8d2387 100644 --- a/OpticalChannelTester/Log.cpp +++ b/OpticalChannelTester/Log.cpp @@ -8,7 +8,7 @@ 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, "CAPTURE") && - strcmp(component, "ESP-NOW")) return; + strcmp(component, "DRIVER") && 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 17db2e5..4adb984 100644 --- a/OpticalChannelTester/Measurement.cpp +++ b/OpticalChannelTester/Measurement.cpp @@ -3,13 +3,14 @@ #include bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs, - uint16_t averagingPeriods, uint8_t settleCycles) { + uint16_t averagingPeriods, uint8_t settleCycles, + bool activeRxLightOn) { if (!task_ && xTaskCreate(taskEntry, "optical-rx", 4096, this, 4, &task_) != pdPASS) return false; expectedHz_ = static_cast(hz + 0.5f); expectedDutyPct_ = duty; tolerance = effectiveTolerancePct(tolerance); if (!expectedHz_ || !timeMs || !averagingPeriods || - !receiver_.start(expectedHz_, expectedDutyPct_)) return false; + !receiver_.start(expectedHz_, expectedDutyPct_, activeRxLightOn)) return false; settleCycles_ = settleCycles; settleLeft_ = settleCycles; tolerancePct_ = tolerance; stepTimeMs_ = (timeMs + MEASUREMENT_PROGRESS_STEPS - 1U) / MEASUREMENT_PROGRESS_STEPS; diff --git a/OpticalChannelTester/Measurement.h b/OpticalChannelTester/Measurement.h index eb9d718..bbe8a89 100644 --- a/OpticalChannelTester/Measurement.h +++ b/OpticalChannelTester/Measurement.h @@ -8,7 +8,7 @@ class Measurement { explicit Measurement(PulseReceiver &receiver) : receiver_(receiver) {} bool start(float expectedHz, float expectedDuty, float tolerancePct, uint32_t testTimeMs, uint16_t averagingPeriods, - uint8_t settleCycles); + uint8_t settleCycles, bool activeRxLightOn); MeasureState update(); bool takeProgressUpdate(); void abort(); diff --git a/OpticalChannelTester/OpticalChannelTester.ino b/OpticalChannelTester/OpticalChannelTester.ino index cd6eaca..6fd658a 100644 --- a/OpticalChannelTester/OpticalChannelTester.ino +++ b/OpticalChannelTester/OpticalChannelTester.ino @@ -10,11 +10,11 @@ uint32_t pwmOutputTestUpdatedMs = 0; void setup() { Serial.begin(SERIAL_BAUD); delay(200); - Serial.printf("\nPWM OUTPUT TEST: GPIO=%u requested=%luHz pulse=%lu..%luns sine=%lums safe=%s\n", + Serial.printf("\nPWM OUTPUT TEST: GPIO=%u requested=%luHz pulse=%lu..%luns sine=%lums light-off=%s\n", GPIO_PWM, PWM_OUTPUT_TEST_FREQUENCY_HZ, PWM_OUTPUT_TEST_MIN_PULSE_NS, PWM_OUTPUT_TEST_MAX_PULSE_NS, PWM_OUTPUT_TEST_SWEEP_PERIOD_MS, - PWM_SAFE_LEVEL == HIGH ? "HIGH" : "LOW"); + TX_LIGHT_OFF_GPIO_LEVEL == HIGH ? "HIGH" : "LOW"); pwmOutputTest.begin(); ActualPwm actual = {}; diff --git a/OpticalChannelTester/Protocol.h b/OpticalChannelTester/Protocol.h index 7c6fc8d..ca45ab0 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 = 11; +constexpr uint8_t PROTOCOL_VERSION = 12; enum class MessageType : uint8_t { DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT, @@ -26,7 +26,7 @@ struct ProtocolPacket { uint32_t actualPulseNs; uint32_t testTimeMs; uint16_t accuracyX100; - uint8_t settleCycles; + uint8_t lightCode; uint8_t progressStep; uint8_t passed; uint8_t reason; diff --git a/OpticalChannelTester/Pwm.cpp b/OpticalChannelTester/Pwm.cpp index c615937..13c889a 100644 --- a/OpticalChannelTester/Pwm.cpp +++ b/OpticalChannelTester/Pwm.cpp @@ -34,11 +34,6 @@ 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); @@ -99,22 +94,27 @@ 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, PWM_ACTIVE_ACTION)) == ESP_OK; + MCPWM_TIMER_EVENT_EMPTY, TX_LIGHT_ON_GPIO_LEVEL == HIGH ? + MCPWM_GEN_ACTION_HIGH : MCPWM_GEN_ACTION_LOW)) == ESP_OK; ok = ok && mcpwm_generator_set_action_on_compare_event(mcpwmGenerator, MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP, - mcpwmComparator, PWM_INACTIVE_ACTION)) == ESP_OK; + mcpwmComparator, TX_LIGHT_OFF_GPIO_LEVEL == HIGH ? + MCPWM_GEN_ACTION_HIGH : MCPWM_GEN_ACTION_LOW)) == ESP_OK; ok = ok && mcpwm_timer_enable(mcpwmTimer) == ESP_OK; if (!ok) { releaseMcpwm(); pinMode(GPIO_PWM, OUTPUT); - digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL); + digitalWrite(GPIO_PWM, TX_LIGHT_OFF_GPIO_LEVEL); return; } - mcpwm_generator_set_force_level(mcpwmGenerator, PWM_SAFE_LEVEL, true); + mcpwm_generator_set_force_level(mcpwmGenerator, TX_LIGHT_OFF_GPIO_LEVEL, true); #endif } bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) { + const uint8_t activeLevel = activeLightOn_ ? TX_LIGHT_ON_GPIO_LEVEL : + TX_LIGHT_OFF_GPIO_LEVEL; + const uint8_t inactiveLevel = activeLevel == HIGH ? LOW : HIGH; #if CONFIG_IDF_TARGET_ESP32C3 IntegerPwmConfig config = {}; if (!choosePwmConfig(hz, pulseNs, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, config)) return false; @@ -127,7 +127,7 @@ bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) { if (attached) { // 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)) { + if (!ledcOutputInvert(GPIO_PWM, activeLevel == LOW)) { ledcDetach(GPIO_PWM); delay(2); continue; @@ -153,7 +153,7 @@ bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) { if (attached) ledcDetach(GPIO_PWM); delay(2); } - pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL); + pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, TX_LIGHT_OFF_GPIO_LEVEL); return false; #elif CONFIG_IDF_TARGET_ESP32S3 if (!mcpwmTimer || !mcpwmComparator || !mcpwmGenerator || !hz || !pulseNs || @@ -169,13 +169,21 @@ bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) { stop(); bool ok = mcpwm_timer_set_period(mcpwmTimer, periodTicks) == ESP_OK; ok = ok && mcpwm_comparator_set_compare_value(mcpwmComparator, activeTicks) == 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, activeLevel == HIGH ? + MCPWM_GEN_ACTION_HIGH : MCPWM_GEN_ACTION_LOW)) == ESP_OK; + ok = ok && mcpwm_generator_set_action_on_compare_event(mcpwmGenerator, + MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP, + mcpwmComparator, inactiveLevel == HIGH ? + MCPWM_GEN_ACTION_HIGH : MCPWM_GEN_ACTION_LOW)) == ESP_OK; // stop() applies a continuous force level (hold_on=true). Remove that same // continuous-force action; hold_on=false addresses a different, one-shot // force mechanism and would leave the safe level permanently active. ok = ok && mcpwm_generator_set_force_level(mcpwmGenerator, -1, true) == ESP_OK; ok = ok && mcpwm_timer_start_stop(mcpwmTimer, MCPWM_TIMER_START_NO_STOP) == ESP_OK; if (!ok) { - mcpwm_generator_set_force_level(mcpwmGenerator, PWM_SAFE_LEVEL, true); + mcpwm_generator_set_force_level(mcpwmGenerator, TX_LIGHT_OFF_GPIO_LEVEL, true); return false; } @@ -193,9 +201,10 @@ bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) { void PwmGenerator::stop() { #if CONFIG_IDF_TARGET_ESP32C3 if (running_) ledcDetach(GPIO_PWM); - pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL); + pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, TX_LIGHT_OFF_GPIO_LEVEL); #elif CONFIG_IDF_TARGET_ESP32S3 - if (mcpwmGenerator) mcpwm_generator_set_force_level(mcpwmGenerator, PWM_SAFE_LEVEL, true); + if (mcpwmGenerator) mcpwm_generator_set_force_level( + mcpwmGenerator, TX_LIGHT_OFF_GPIO_LEVEL, true); if (running_ && mcpwmTimer) { mcpwm_timer_start_stop(mcpwmTimer, MCPWM_TIMER_STOP_EMPTY); const uint32_t waitUs = mcpwmFrequencyHz ? (1000000U / mcpwmFrequencyHz + 2U) : 2U; @@ -211,12 +220,29 @@ void PwmGenerator::active() { // that denotes the pulse during a running test. stop(); #if CONFIG_IDF_TARGET_ESP32C3 - digitalWrite(GPIO_PWM, PWM_ACTIVE_LEVEL); + digitalWrite(GPIO_PWM, activeLightOn_ ? TX_LIGHT_ON_GPIO_LEVEL : + TX_LIGHT_OFF_GPIO_LEVEL); #elif CONFIG_IDF_TARGET_ESP32S3 - if (mcpwmGenerator) mcpwm_generator_set_force_level(mcpwmGenerator, PWM_ACTIVE_LEVEL, true); + const uint8_t level = activeLightOn_ ? TX_LIGHT_ON_GPIO_LEVEL : + TX_LIGHT_OFF_GPIO_LEVEL; + if (mcpwmGenerator) mcpwm_generator_set_force_level(mcpwmGenerator, level, true); else { pinMode(GPIO_PWM, OUTPUT); - digitalWrite(GPIO_PWM, PWM_ACTIVE_LEVEL); + digitalWrite(GPIO_PWM, level); + } +#endif +} + +void PwmGenerator::lightOn() { + stop(); +#if CONFIG_IDF_TARGET_ESP32C3 + digitalWrite(GPIO_PWM, TX_LIGHT_ON_GPIO_LEVEL); +#elif CONFIG_IDF_TARGET_ESP32S3 + if (mcpwmGenerator) + mcpwm_generator_set_force_level(mcpwmGenerator, TX_LIGHT_ON_GPIO_LEVEL, true); + else { + pinMode(GPIO_PWM, OUTPUT); + digitalWrite(GPIO_PWM, TX_LIGHT_ON_GPIO_LEVEL); } #endif } diff --git a/OpticalChannelTester/Pwm.h b/OpticalChannelTester/Pwm.h index f2207e7..d0ed189 100644 --- a/OpticalChannelTester/Pwm.h +++ b/OpticalChannelTester/Pwm.h @@ -13,10 +13,15 @@ struct ActualPwm { class PwmGenerator { public: void begin(); + void configureActiveLight(bool lightOn) { activeLightOn_ = lightOn; } bool start(uint32_t frequencyHz, uint32_t pulseNs, ActualPwm &actual); + // Hold the optical level selected as the active TX pulse. void active(); + // Hold actual optical light ON, independently of HH/HL/LH/LL. + void lightOn(); void stop(); bool running() const { return running_; } private: bool running_ = false; + bool activeLightOn_ = true; }; diff --git a/OpticalChannelTester/Receiver.cpp b/OpticalChannelTester/Receiver.cpp index c3bc3af..7d97afe 100644 --- a/OpticalChannelTester/Receiver.cpp +++ b/OpticalChannelTester/Receiver.cpp @@ -40,6 +40,10 @@ bool PulseReceiver::begin() { mcpwm_capture_channel_config_t channelConfig = {}; channelConfig.gpio_num = GPIO_RX; + // ACK pulses are sub-microsecond and consecutive acknowledgements can be + // only 1 us apart. A low-priority capture interrupt can leave the channel + // status pending long enough for the next timestamp to overwrite it. + channelConfig.intr_priority = 3; channelConfig.prescale = 1; channelConfig.flags.pos_edge = true; channelConfig.flags.neg_edge = false; @@ -54,8 +58,13 @@ bool PulseReceiver::begin() { 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; + if (mcpwm_capture_channel_register_event_callbacks( + fallingChannel_, &callbacks, this) != ESP_OK) return false; + + // The TX channel is created immediately before a driver test. Only the end + // of the active PWM pulse is armed; handling its start here would occupy the + // shared MCPWM ISR during the RX acknowledgement only ~300 ns later. + return true; #else pinMode(GPIO_RX, INPUT); cpuTickHz_ = getCpuFrequencyMhz() * 1000000UL; @@ -64,7 +73,8 @@ bool PulseReceiver::begin() { #endif } -bool PulseReceiver::start(uint32_t expectedHz, float expectedDutyPct) { +bool PulseReceiver::start(uint32_t expectedHz, float expectedDutyPct, + bool activeLightOn) { if (!plannedTickHz(expectedHz, expectedDutyPct)) return false; expectedHz_ = expectedHz; expectedDutyPct_ = expectedDutyPct; @@ -73,6 +83,64 @@ bool PulseReceiver::start(uint32_t expectedHz, float expectedDutyPct) { if (!cpuTickHz_) return false; #endif resetStream(); + const bool rawHighMeansLightOn = RX_LIGHT_ON_GPIO_LEVEL == HIGH; + activeStartRising_ = rawHighMeansLightOn == activeLightOn; + Log::printf("CAPTURE", "RX active optical level=%s, raw active starts on %s", + activeLightOn ? "H/light-on" : "L/light-off", + activeStartRising_ ? "RISING" : "FALLING"); + return startCapture(false); +} + +#if OPTICAL_USE_MCPWM_CAPTURE +bool PulseReceiver::configureDriverTxCapture(bool risingEdge) { + if (running_) return false; + if (txChannel_) { + if (mcpwm_del_capture_channel(txChannel_) != ESP_OK) return false; + txChannel_ = nullptr; + } + mcpwm_capture_channel_config_t config = {}; + config.gpio_num = GPIO_PWM; + config.intr_priority = 3; + config.prescale = 1; + config.flags.pos_edge = risingEdge; + config.flags.neg_edge = !risingEdge; + config.flags.io_loop_back = true; + if (mcpwm_new_capture_channel(captureTimer_, &config, &txChannel_) != ESP_OK) + return false; + mcpwm_capture_event_callbacks_t callbacks = {}; + callbacks.on_cap = onCapture; + return mcpwm_capture_channel_register_event_callbacks( + txChannel_, &callbacks, this) == ESP_OK; +} +#endif + +bool PulseReceiver::startDriver(uint32_t frequencyHz, uint32_t pulseNs, + bool activeTxLightOn) { +#if OPTICAL_USE_MCPWM_CAPTURE + if (!frequencyHz || !pulseNs || !tickHz() || running_) return false; + resetStream(); + const uint64_t pulseTicks = + (static_cast(pulseNs) * tickHz() + 500000000ULL) / + 1000000000ULL; + const uint32_t periodTicks = tickHz() / frequencyHz; + if (!pulseTicks || pulseTicks >= periodTicks || pulseTicks > UINT32_MAX) + return false; + driverPulseTicks_ = static_cast(pulseTicks); + driverReleaseSlackTicks_ = static_cast( + (static_cast(DRIVER_RESPONSE_TIMEOUT_NS) * tickHz() + + 999999999ULL) / 1000000000ULL); + const uint8_t activeRawLevel = activeTxLightOn ? + TX_LIGHT_ON_GPIO_LEVEL : TX_LIGHT_OFF_GPIO_LEVEL; + const bool pulseEndIsRising = activeRawLevel == LOW; + if (!driverReleaseSlackTicks_ || + !configureDriverTxCapture(pulseEndIsRising)) return false; + return startCapture(true); +#else + return false; +#endif +} + +bool PulseReceiver::startCapture(bool withTx) { #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. @@ -87,15 +155,25 @@ bool PulseReceiver::start(uint32_t expectedHz, float expectedDutyPct) { mcpwm_capture_timer_disable(captureTimer_); return false; } + if (withTx && mcpwm_capture_channel_enable(txChannel_) != ESP_OK) { + mcpwm_capture_channel_disable(fallingChannel_); + mcpwm_capture_channel_disable(risingChannel_); + mcpwm_capture_timer_disable(captureTimer_); + return false; + } + txCaptureEnabled_ = withTx; running_ = true; if (mcpwm_capture_timer_start(captureTimer_) != ESP_OK) { running_ = false; + if (txCaptureEnabled_) mcpwm_capture_channel_disable(txChannel_); + txCaptureEnabled_ = false; mcpwm_capture_channel_disable(fallingChannel_); mcpwm_capture_channel_disable(risingChannel_); mcpwm_capture_timer_disable(captureTimer_); return false; } #else + if (withTx) return false; running_ = true; #endif return true; @@ -106,7 +184,9 @@ void PulseReceiver::stop() { running_ = false; #if OPTICAL_USE_MCPWM_CAPTURE if (wasRunning) { - // Mask both edge interrupts before stopping the shared capture timer. + // Mask capture interrupts before stopping the shared capture timer. + if (txCaptureEnabled_) mcpwm_capture_channel_disable(txChannel_); + txCaptureEnabled_ = false; mcpwm_capture_channel_disable(fallingChannel_); mcpwm_capture_channel_disable(risingChannel_); mcpwm_capture_timer_stop(captureTimer_); @@ -120,10 +200,15 @@ void PulseReceiver::stop() { void PulseReceiver::resetStream() { if (queue_) xQueueReset(queue_); haveReorderEdge_ = false; + __atomic_store_n(&driverRingWrite_, 0U, __ATOMIC_RELEASE); + __atomic_store_n(&driverRingRead_, 0U, __ATOMIC_RELEASE); + haveLastDriverEdge_ = false; + lastDriverEdge_ = {}; + driverPulseTicks_ = 0; + driverReleaseSlackTicks_ = 0; droppedItems_ = 0; polarityKnown_ = false; activeStartRising_ = false; - syncEdgeCount_ = 0; waitingForActiveEnd_ = true; activeStart_ = activeEnd_ = 0; haveRawTick_ = false; @@ -159,39 +244,13 @@ bool PulseReceiver::consumeEdge(const Edge &rawEdge, PulsePeriod &out) { 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; + // HH/HL/LH/LL defines the active optical state explicitly. Synchronize on + // its physical starting edge instead of guessing polarity from pulse width. + if (edge.rising != activeStartRising_) return false; 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; + activeStart_ = edge.tick; + waitingForActiveEnd_ = true; + return false; } uint32_t PulseReceiver::takeDroppedItems() { @@ -199,13 +258,45 @@ uint32_t PulseReceiver::takeDroppedItems() { } #if OPTICAL_USE_MCPWM_CAPTURE -bool IRAM_ATTR PulseReceiver::onCapture(mcpwm_cap_channel_handle_t, +bool IRAM_ATTR PulseReceiver::onCapture(mcpwm_cap_channel_handle_t channel, 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)}; + const Edge edge = {data->cap_value, static_cast(rawRising), + static_cast(channel == self->txChannel_ ? CaptureSource::TX : + CaptureSource::RX)}; + if (self->txCaptureEnabled_) { + // Three capture channels are independent ISR producers. Serialize their + // reservation/publication of a ring slot; treating this as an SPSC ring + // loses or duplicates RX events when TX and RX interrupts overlap. + portENTER_CRITICAL_ISR(&self->driverRingMux_); + if (self->haveLastDriverEdge_ && + self->lastDriverEdge_.tick == edge.tick && + self->lastDriverEdge_.rising == edge.rising && + self->lastDriverEdge_.source == edge.source) { + // The same channel callback can be delivered twice while several MCPWM + // capture status bits are pending. Two physical edges cannot have the + // same source, direction and 12.5 ns hardware timestamp. + portEXIT_CRITICAL_ISR(&self->driverRingMux_); + return false; + } + const uint16_t write = __atomic_load_n( + &self->driverRingWrite_, __ATOMIC_RELAXED); + const uint16_t next = static_cast( + (write + 1U) % DRIVER_RING_CAPACITY); + if (next == __atomic_load_n(&self->driverRingRead_, __ATOMIC_ACQUIRE)) { + __atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED); + } else { + self->driverRing_[write] = edge; + self->lastDriverEdge_ = edge; + self->haveLastDriverEdge_ = true; + __atomic_store_n(&self->driverRingWrite_, next, __ATOMIC_RELEASE); + } + portEXIT_CRITICAL_ISR(&self->driverRingMux_); + return false; + } BaseType_t wake = pdFALSE; if (xQueueSendFromISR(self->queue_, &edge, &wake) != pdTRUE) __atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED); @@ -215,9 +306,9 @@ bool IRAM_ATTR PulseReceiver::onCapture(mcpwm_cap_channel_handle_t, 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; - const Edge edge = {esp_cpu_get_cycle_count(), static_cast(level)}; + const bool level = gpio_get_level(static_cast(GPIO_RX)); + const Edge edge = {esp_cpu_get_cycle_count(), static_cast(level), + static_cast(CaptureSource::RX)}; BaseType_t wake = pdFALSE; if (xQueueSendFromISR(self->queue_, &edge, &wake) != pdTRUE) __atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED); @@ -256,3 +347,84 @@ size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity, } return count; } + +size_t PulseReceiver::readEvents(CaptureEvent *events, size_t capacity, + TickType_t waitTicks) { + if (!events || capacity < 2U || !txCaptureEnabled_ || !driverPulseTicks_ || + !driverReleaseSlackTicks_) return 0; + + constexpr size_t MAX_BATCH = 64; + const size_t limit = capacity < MAX_BATCH ? capacity : MAX_BATCH; + Edge ordered[MAX_BATCH] = {}; + + uint16_t read = __atomic_load_n(&driverRingRead_, __ATOMIC_RELAXED); + if (read == __atomic_load_n(&driverRingWrite_, __ATOMIC_ACQUIRE) && waitTicks) { + vTaskDelay(waitTicks); + read = __atomic_load_n(&driverRingRead_, __ATOMIC_RELAXED); + } + // Work on one immutable producer snapshot. RX belonging to a pulse start is + // deliberately retained until that pulse's captured end arrives: only then + // can the missing start interrupt be reconstructed and sorted before RX. + const uint16_t write = __atomic_load_n(&driverRingWrite_, __ATOMIC_ACQUIRE); + uint16_t scan = read; + bool haveTxEnd = false; + uint32_t lastTxEnd = 0; + size_t projectedCount = 0; + while (scan != write) { + const Edge &edge = driverRing_[scan]; + const size_t needed = edge.source == static_cast(CaptureSource::TX) + ? 2U : 1U; + if (projectedCount + needed > limit) break; + projectedCount += needed; + if (edge.source == static_cast(CaptureSource::TX)) { + lastTxEnd = edge.tick; + haveTxEnd = true; + } + scan = static_cast((scan + 1U) % DRIVER_RING_CAPACITY); + } + if (!haveTxEnd) { + if (waitTicks) vTaskDelay(waitTicks); + return 0; + } + + const uint32_t releaseThrough = lastTxEnd + driverReleaseSlackTicks_; + size_t count = 0; + while (read != write) { + const Edge edge = driverRing_[read]; + if (edge.source == static_cast(CaptureSource::RX) && + static_cast(edge.tick - releaseThrough) > 0) + break; + const size_t needed = edge.source == static_cast(CaptureSource::TX) + ? 2U : 1U; + if (count + needed > limit) break; + read = static_cast((read + 1U) % DRIVER_RING_CAPACITY); + if (edge.source == static_cast(CaptureSource::TX)) { + Edge pulseStart = edge; + pulseStart.tick -= driverPulseTicks_; + pulseStart.rising = !edge.rising; + ordered[count++] = pulseStart; + } + ordered[count++] = edge; + } + __atomic_store_n(&driverRingRead_, read, __ATOMIC_RELEASE); + + // MCPWM channels share one timer but their callbacks can be dispatched in + // channel order when several interrupts are pending. Restore the hardware + // order inside the captured batch using the common timestamp. + for (size_t i = 1; i < count; ++i) { + const Edge key = ordered[i]; + size_t j = i; + while (j && static_cast(ordered[j - 1].tick - key.tick) > 0) { + ordered[j] = ordered[j - 1]; + --j; + } + ordered[j] = key; + } + + for (size_t i = 0; i < count; ++i) { + const TimedEdge timed = extendEdge(ordered[i]); + events[i] = {timed.tick, timed.rising, + static_cast(ordered[i].source)}; + } + return count; +} diff --git a/OpticalChannelTester/Receiver.h b/OpticalChannelTester/Receiver.h index 7a1e704..3ae90bb 100644 --- a/OpticalChannelTester/Receiver.h +++ b/OpticalChannelTester/Receiver.h @@ -12,13 +12,24 @@ #define OPTICAL_USE_MCPWM_CAPTURE 0 #endif +enum class CaptureSource : uint8_t { RX, TX }; + +struct CaptureEvent { + uint64_t tick; + bool rising; + CaptureSource source; +}; + class PulseReceiver { public: bool begin(); - bool start(uint32_t expectedHz, float expectedDutyPct); + bool start(uint32_t expectedHz, float expectedDutyPct, bool activeLightOn); + bool startDriver(uint32_t frequencyHz, uint32_t pulseNs, + bool activeTxLightOn); void stop(); void resetStream(); size_t readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks = 0); + size_t readEvents(CaptureEvent *events, size_t capacity, TickType_t waitTicks = 0); uint32_t takeDroppedItems(); uint32_t tickHz() const; uint32_t pulseTickHz() const { return tickHz(); } @@ -30,32 +41,43 @@ class PulseReceiver { bool highRateBackend() const { return OPTICAL_USE_MCPWM_CAPTURE; } private: - struct Edge { uint32_t tick; uint8_t rising; }; + struct Edge { uint32_t tick; uint8_t rising; uint8_t source; }; + static constexpr uint16_t DRIVER_RING_CAPACITY = 512; struct TimedEdge { uint64_t tick; bool rising; }; + bool startCapture(bool withTx); bool consumeEdge(const Edge &edge, PulsePeriod &period); bool nextOrderedEdge(Edge &edge, TickType_t waitTicks); TimedEdge extendEdge(const Edge &edge); #if OPTICAL_USE_MCPWM_CAPTURE + bool configureDriverTxCapture(bool risingEdge); 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; + mcpwm_cap_channel_handle_t txChannel_ = nullptr; uint32_t captureResolutionHz_ = 0; #else static void IRAM_ATTR onGpio(void *ctx); uint32_t cpuTickHz_ = 0; #endif QueueHandle_t queue_ = nullptr; + Edge driverRing_[DRIVER_RING_CAPACITY] = {}; + volatile uint16_t driverRingWrite_ = 0; + volatile uint16_t driverRingRead_ = 0; + portMUX_TYPE driverRingMux_ = portMUX_INITIALIZER_UNLOCKED; + Edge lastDriverEdge_ = {}; + bool haveLastDriverEdge_ = false; + uint32_t driverPulseTicks_ = 0; + uint32_t driverReleaseSlackTicks_ = 0; Edge reorderEdge_ = {}; bool haveReorderEdge_ = false; volatile uint32_t droppedItems_ = 0; volatile bool running_ = false; + volatile bool txCaptureEnabled_ = 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; diff --git a/OpticalChannelTester/SettingsStore.cpp b/OpticalChannelTester/SettingsStore.cpp index 9da94e9..15b5e23 100644 --- a/OpticalChannelTester/SettingsStore.cpp +++ b/OpticalChannelTester/SettingsStore.cpp @@ -3,16 +3,22 @@ #include "Log.h" #include -namespace { constexpr uint16_t SETTINGS_VERSION = 9; constexpr char NAMESPACE[] = "opt-test"; } +namespace { constexpr uint16_t SETTINGS_VERSION = 11; constexpr char NAMESPACE[] = "opt-test"; } void SettingsStore::defaults(Settings &s) const { // 2 kHz, 200 us .. 2 us, 5%, 1 s. - s = {SETTINGS_VERSION, static_cast(Role::SOLO), 2, 3, 3, 2, 3, 0}; + s = {SETTINGS_VERSION, static_cast(Role::SOLO), + static_cast(TestKind::OPTICAL), static_cast(LightCode::HH), + 2, 6, 3, 2, 3, 0, 0}; s.checksum = settingsChecksum(s); } bool SettingsStore::valid(const Settings &s) const { return s.version == SETTINGS_VERSION && s.role <= static_cast(Role::SLAVE) && + s.testKind <= static_cast(TestKind::DRIVER) && + s.lightCode <= static_cast(LightCode::LL) && + (s.testKind != static_cast(TestKind::DRIVER) || + s.role == static_cast(Role::SOLO)) && s.frequencyIndex < countOf(PWM_FREQUENCY_OPTIONS_HZ) && s.maxPulseIndex < countOf(MAX_PULSE_OPTIONS_NS) && s.minPulseIndex < countOf(MIN_PULSE_OPTIONS_NS) && diff --git a/README.md b/README.md index 85c0ace..292d9b7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -# Optical Channel Tester — ESP32-C3 / ESP32-S3 +# Optical Channel / Gate Driver Tester — ESP32-C3 / ESP32-S3 Полностью рабочий Arduino IDE-проект для строгой проверки оптического цифрового канала. Устройство формирует PWM на выбранной частоте, последовательно уменьшает длительность импульса по заданному списку и проверяет ответ канала: частоту и длительность импульса. Поддерживаются роли `SOLO`, `MASTER` и `SLAVE`; роли выбираются только вручную. ## Что реализовано - неблокирующий конечный автомат без `pulseIn()` и длинных `delay()`; +- общие для оптического и драйверного тестов частота PWM и диапазон импульсов; +- четыре рабочих режима: `SOLO OPTICAL`, `SOLO DRIVER` (только S3), + `MASTER OPTICAL`, `SLAVE OPTICAL`; +- настраиваемый оптический код `HH`, `HL`, `LH`, `LL`, не зависящий от + электрической инверсии GPIO; - аппаратные 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 кГц); @@ -81,7 +86,7 @@ Arduino sketch находится в каталоге `OpticalChannelTester`: Оба профиля рассчитаны на установку S3 с совмещением контактов `5V` и `GND` с модулем C3. Номера GPIO S3 выбраны по тому же физическому ряду разъёма, а не по совпадению номера GPIO. -На ESP32-C3 PWM формируется через LEDC с аппаратным дробным делителем Q10.8. На ESP32-S3 используется отдельный MCPWM со счётчиком 20 МГц: это позволяет формировать 500 Гц в пределах 16-битного счётчика и сохраняет шаг импульса 50 нс. `PWM_ACTIVE_LEVEL` задаёт электрический уровень активной части тестового импульса на обеих платах; остальная часть работающего PWM имеет противоположный уровень. `PWM_SAFE_LEVEL` применяется только при остановленном PWM и во сне. Перед началом точки прошивка проверяет, что фактические частота и длительность импульса укладываются в выбранную точность. +На ESP32-C3 PWM формируется через LEDC с аппаратным дробным делителем Q10.8. На ESP32-S3 используется отдельный MCPWM со счётчиком 20 МГц: это позволяет формировать 500 Гц в пределах 16-битного счётчика и сохраняет шаг импульса 50 нс. `TX_LIGHT_ON_GPIO_LEVEL` задаёт постоянное свойство схемы: какой физический уровень `PIN_TX` включает свет. Для приведённой схемы с SN75451 это `HIGH`. Пользовательский код света не изменяет это соответствие. При остановке PWM прошивка выдаёт `TX_LIGHT_OFF_GPIO_LEVEL`. Перед началом точки проверяется, что фактические частота и длительность укладываются в выбранную точность. ### OLED @@ -147,17 +152,17 @@ MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE На обеих ESP должны совпадать `ESPNOW_WIFI_CHANNEL` и версия прошивки. Slave сначала переводится в `SLAVE READY` кнопкой START, затем START нажимается на Master. -Пока бодрствующий Slave находится в `SLAVE READY`, радиоприём работает периодически: окно 20 мс каждые 100 мс, а собственный оптический передатчик Slave отключён уровнем `PWM_SAFE_LEVEL`. При переходе Slave в light sleep ESP-NOW и Wi-Fi полностью отключаются, поэтому радиопакет не может его разбудить. Master во время поиска посылает серию `DISCOVER` каждые 20 мс и одновременно переключает оптический выход между `PWM_ACTIVE_LEVEL` и `PWM_SAFE_LEVEL` каждые `OPTICAL_WAKE_HALF_PERIOD_MS`. Изменение уровня на оптическом входе пробуждает Slave через GPIO; после этого Slave запускает ESP-NOW и принимает повторяемый `DISCOVER`. Постоянный HIGH или LOW сам по себе повторных пробуждений не вызывает. После обнаружения Master прекращает wake-сигнал, а Slave включает непрерывный радиоприём на всю тестовую сессию. Параметры задаются в `Config.h`. +Пока бодрствующий Slave находится в `SLAVE READY`, радиоприём работает периодически: окно 20 мс каждые 100 мс, а собственный оптический передатчик Slave выключен. При переходе Slave в light sleep ESP-NOW и Wi-Fi полностью отключаются, поэтому радиопакет не может его разбудить. Master во время поиска посылает серию `DISCOVER` каждые 20 мс и одновременно переключает реальный свет ON/OFF каждые `OPTICAL_WAKE_HALF_PERIOD_MS`. Изменение уровня на оптическом входе пробуждает Slave через GPIO; после этого Slave запускает ESP-NOW и принимает повторяемый `DISCOVER`. **GPIO ESP32 допускают только логические уровни 0…3.3 В.** Не подавайте 5 В на `GPIO_RX`; применяйте согласование уровня. Полярность выхода оптического приёмника задаётся однозначно его активным уровнем: -Базовый активный уровень входа задаёт `RX_ACTIVE_LEVEL`, но во время теста полярность определяется автоматически по первому полному периоду и фиксируется до следующего запуска приёмника. Прошивка сравнивает длительности обоих уровней с заданным импульсом и использует более близкую. Например, для 2 кГц и 200 мкс автоматически выбирается 200 мкс, а не дополнение 300 мкс. При заполнении 50% обе полярности эквивалентны. +Физический уровень выхода приёмника при включённом свете задаёт `RX_LIGHT_ON_GPIO_LEVEL`. Вторая буква `HH/HL/LH/LL` выбирает, свет ON (`H`) или OFF (`L`) является активным импульсом принимаемой последовательности. Поэтому полярность не угадывается по длительности и однозначна даже при заполнении 50%. ## Управление -В ожидании: +В ожидании на S3: -- MODE short: `SOLO → MASTER → SLAVE → SOLO`, выбранная роль сохраняется; +- MODE short: `SOLO ОПТИКА → SOLO ДРАЙВЕР → MASTER ОПТИКА → SLAVE ОПТИКА`; - MODE long: открыть настройки; - START short: начать тест; - START long во время теста: ABORT, PWM немедленно выключается. @@ -173,19 +178,33 @@ 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`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в непрерывный light sleep без периодических таймерных пробуждений. Пока открыто меню настроек, light sleep запрещён, поэтому кнопки остаются отзывчивыми независимо от времени настройки. Экран и текущее состояние сохраняются. START и MODE будят любую роль, а фронт на оптическом входе дополнительно будит Slave. После пробуждения прошивка полностью перезапускает драйверы USB Serial и I²C, а в режиме Slave также восстанавливает ESP-NOW. +В ожидании процессор работает на 80 МГц, во время теста и связанной MASTER/SLAVE-сессии — на 160 МГц. В `SOLO ОПТИКА` и Master передатчик удерживается в активном оптическом состоянии, выбранном первой буквой кода света. В `SOLO ДРАЙВЕР`, Slave, при остановленном PWM и во сне свет всегда принудительно выключен: драйвер не может остаться включённым после теста. После тайм-аута бездействия в `IDLE`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в непрерывный light sleep без периодических таймерных пробуждений. START и MODE будят устройство. Первое, пробуждающее нажатие не выполняет действие и полностью подавляется до отпускания кнопки; действие выполняет только следующее нажатие. Оптический wake-сигнал также немедленно выводит Slave из энергосбережения. Тайм-аут задаётся `IDLE_POWER_SAVE_TIMEOUT_MS` в `Config.h`. ## Настройки теста -Меню содержит пять параметров: +Меню содержит шесть общих параметров: 1. частота ШИМ из `PWM_FREQUENCY_OPTIONS_HZ`; 2. максимальная длительность импульса из отдельного `MAX_PULSE_OPTIONS_NS`; 3. минимальная длительность импульса из отдельного `MIN_PULSE_OPTIONS_NS`; 4. точность; 5. время выборки. +6. код света `HH`, `HL`, `LH`, `LL`. + +Экран активного уровня: + +```text +АКТ. УРОВЕНЬ: HL +TX:H, RX:L +``` + +Первая буква задаёт активное состояние света TX, вторая — активное состояние +света RX. Буквы никогда не обозначают уровни GPIO. Для обычной прямой оптики +типичен `HH`; для драйвера 1SP0635, у которого управление выполняется светом ON, +а ACK передаётся светом OFF, типичен `HL`. В MASTER/SLAVE Master передаёт код +света Slave вместе с параметрами этапа. Тест работает на одной выбранной частоте и проходит длительности из списка от максимальной к минимальной. Значения, равные периоду или превышающие его, автоматически исключаются при выборе частоты. На S3 минимальная длительность ограничивается расчётной точностью генератора и MCPWM Capture 80 МГц: длинная пауза не снижает точность короткого импульса. Меню не позволяет задать точку, которой аппаратно не хватает выбранного допуска. Сохранённые индексы и их сочетания проверяются и приводятся к допустимому диапазону. @@ -203,9 +222,9 @@ max(TEST_TIME, periods / RX_PROCESSING_PERIODS_PER_SECOND) ## Строгая проверка -После каждой перенастройки PWM приёмник: +В тесте оптики после каждой перенастройки PWM приёмник: -1. автоматически определяет полярность Rx; отдельные каналы MCPWM Capture S3 фиксируют RISING и FALLING в независимых регистрах общего 32-битного таймера 80 МГц, чтобы близкий второй фронт не перезаписал первый; +1. применяет явно выбранную полярность света Rx; отдельные каналы MCPWM Capture S3 фиксируют RISING и FALLING в независимых регистрах общего 32-битного таймера 80 МГц; 2. отбрасывает ровно `PWM_SETTLE_CYCLES` полных периодов; 3. очищает статистику; 4. непрерывно держит MCPWM Capture включённым на всём измерительном этапе, проверяет каждый полный импульс ровно один раз и на десяти границах TEST TIME показывает промежуточный результат; границы прогресса не останавливают capture и не выбрасывают пересекающий их импульс. Между длительностями сначала останавливается PWM, затем capture, поэтому очистка очереди не пересекается с ISR. @@ -233,6 +252,63 @@ PULSE OUT 300.01u Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT` (в интерфейсе — ошибка длительности импульса), `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `DATA LOSS ERROR`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`. +## Тест драйвера — только ESP32-S3 + +В `SOLO ДРАЙВЕР` используются те же частота, MAX/MIN, точность и время точки. +Для драйверного теста минимальная выбираемая длительность входного импульса — +2 мкс. При переключении из оптического режима сохранённое значение меньше +2 мкс автоматически заменяется на 2 мкс. Частота, MAX, MIN и время точки +остаются общими настраиваемыми параметрами. + +Реальные фронты `PIN_TX` и `PIN_RX` фиксируются прямым GPIO sampling в +выделенной высокоприоритетной задаче ядра 0 по общему 240-МГц счётчику CPU. +Ни один фронт TX не восстанавливается расчётом. Анализ выполняется отдельной +задачей ядра 1. + +Для каждого фронта TX ожидается активный ответ RX. Паспортные 250 нс до начала +ACK и 700 нс его длительности используются как справочные величины, но не как +условие PASS/FAIL: задержки тестовой передающей и принимающей оптики могут их +сдвигать. Если полный импульс ответа присутствует, ACK считается принятым. +Каждый RX сопоставляется с ближайшим предшествующим фронтом TX. Поэтому при +пропуске одного ответа ACK следующего фронта не может быть ошибочно приписан +пропущенному фронту с задержкой, равной длительности входного импульса плюс D. +На OLED в режиме драйвера первая строка показывает параметры текущей точки +`ТЕСТ: частота, импульс`, а вторая — измеренную задержку от фронта TX до начала +ответа и длительность ответа в формате `D: ..., P: ...`. +Отсутствие ответа в течение 10 мкс даёт `НЕТ ОТВЕТА ACK`. Отдельный импульс +статуса драйвера и ответ, остающийся активным дольше 1,5 мкс, считаются +`АВАРИЯ ДРАЙВЕРА`. + +Для каждого фронта TX всегда требуется отдельный полный ACK. Перед началом +измерения анализатор пропускает заданные циклы установления и ещё один тайм-аут +ACK, после чего начинает проверку с нового фронта TX. Ответ с границы запуска +не используется для синхронизации и не попадает в рабочую статистику. + +Время каждой точки делится на 10 равных подвыборок. Во время подвыборки OLED и +Serial не обновляются. После каждой подвыборки захват и PWM останавливаются, +на OLED выводятся накопленные `D` и `P` и прогресс, затем та же точка +автоматически продолжается. Это исключает потерю короткого RX-фронта из-за +I2C-обновления дисплея. + +После успешного завершения последней точки OLED показывает `ТЕСТ ПРОЙДЕН`, а +во второй строке сохраняет итоговые накопленные значения `D` и `P`. + +При ошибке русская локализация не использует английские фразы `FAIL AT`, +`TEST`, `FREQ OUT` или `PULSE OUT`. Первая строка показывает причину по-русски, +вторая — `T` (время от начала точки до ошибки), `D` (задержку ошибочного ответа, +если она определима) и `P` (длительность ошибочного RX-импульса). Для +отсутствующего ACK значение `P` выводится как `---`. Точные значения в +наносекундах дополнительно записываются в Serial-журнал. + +Подключение ESP32-S3 к работающему USB-хосту запрещает переход в light sleep, +даже если COM-порт закрыт и обмена Serial нет. Краткие пропадания USB SOF +фильтруются в течение 5 секунд. Кабель или зарядник только с питанием, без +линий D+/D−, программно определить невозможно без отдельного входа контроля +VBUS. +Тест идёт от MAX к MIN и останавливается на первой длительности, при которой +драйвер перестал отвечать корректно. Это определяет границу распознавания +входа, но не доказывает, что напряжение VGE успело полностью открыть IGBT. + ### Пределы - C3 и S3: настраиваемый строгий предел до 1 МГц;