#include "App.h" #include "Config.h" #include "Config_Text.h" #include "Log.h" #include #include #include #include #include #include #include #include #include #include namespace { const char *uiFailName(FailReason reason); const char *appStateName(AppState state) { static const char *names[] = {"IDLE", "MENU", "SOLO_MEASURE", "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); return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN"; } const char *buttonEventName(ButtonEvent event) { static const char *names[] = {"NONE", "SHORT", "LONG", "REPEAT"}; const uint8_t index = static_cast(event); return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN"; } uint32_t pulseFromDuty(float hz, float dutyPct) { return hz > 0.0f ? static_cast(lroundf(dutyPct * 10000000.0f / hz)) : 0U; } float dutyFromPulse(uint32_t hz, uint32_t pulseNs) { return static_cast(static_cast(hz) * pulseNs / 10000000.0); } bool configuredTxPulseLightOn(const Settings &settings) { return static_cast(settings.testKind) == TestKind::DRIVER ? txActiveLightOn(settings) : true; } const char *configuredLevelName(const Settings &settings) { return static_cast(settings.testKind) == TestKind::DRIVER ? lightCodeName(static_cast(settings.lightCode)) : "AUTO"; } void formatTarget(uint32_t hz, uint32_t pulseNs, char *out, size_t size) { char frequency[16], pulse[12]; Display::formatPwmFrequency(hz, frequency, sizeof(frequency)); Display::formatPulse(pulseNs, pulse, sizeof(pulse)); snprintf(out, size, UiText::TEST_FORMAT, frequency, pulse); } void formatMeasured(float hz, uint32_t pulseNs, char *out, size_t size) { char frequency[12], pulse[12]; Display::formatFrequency(hz, frequency, sizeof(frequency)); Display::formatPulse(pulseNs, pulse, sizeof(pulse), true); snprintf(out, size, "F:%s, P:%s", frequency, pulse); } void formatTestTarget(uint32_t hz, uint32_t pulseNs, char *out, size_t size) { char target[32]; formatTarget(hz, pulseNs, target, sizeof(target)); snprintf(out, size, UiText::TEST_TARGET_FORMAT, target); } void formatFailure(FailReason reason, uint32_t hz, uint32_t pulseNs, char *out, size_t size) { (void)reason; char target[32]; formatTarget(hz, pulseNs, target, sizeof(target)); snprintf(out, size, UiText::FAIL_TARGET_FORMAT, target); } void formatElapsedNs(uint64_t ns, char *out, size_t size) { // Compact form keeps error timing 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) { size_t count = 0; while (text && *text) { const uint8_t byte = static_cast(*text++); // Continuation bytes (10xxxxxx) belong to the preceding UTF-8 character. if ((byte & 0xC0U) != 0x80U) ++count; } return count; } const char *uiRoleName(Role role) { const uint8_t index = static_cast(role); return index < sizeof(UiText::ROLE_NAMES) / sizeof(UiText::ROLE_NAMES[0]) ? UiText::ROLE_NAMES[index] : "?"; } const char *uiFailName(FailReason reason) { const uint8_t index = static_cast(reason); return index < sizeof(UiText::FAIL_NAMES) / sizeof(UiText::FAIL_NAMES[0]) ? UiText::FAIL_NAMES[index] : "UNKNOWN"; } const char *roleCorner(Role role) { static const char *markers[] = {"O", "M", "S"}; const uint8_t index = static_cast(role); return index < sizeof(markers) / sizeof(markers[0]) ? markers[index] : "?"; } void formatMenuLine(const char *label, const char *value, char *out, size_t size) { constexpr size_t OLED_TEXT_COLUMNS = 21; const size_t labelLength = utf8CharacterCount(label); const size_t valueLength = utf8CharacterCount(value); const size_t usedColumns = labelLength + valueLength; const int padding = static_cast( usedColumns < OLED_TEXT_COLUMNS ? OLED_TEXT_COLUMNS - usedColumns : 0U); snprintf(out, size, "%s%*s%s", label, padding, "", value); } uint32_t overallProgress(uint32_t stageIndex, uint8_t step, uint8_t stepsPerStage = MEASUREMENT_PROGRESS_STEPS) { if (step > stepsPerStage) step = stepsPerStage; return stageIndex * stepsPerStage + step; } uint32_t overallProgressTotal( uint32_t stageCount, uint8_t stepsPerStage = MEASUREMENT_PROGRESS_STEPS) { return stageCount * stepsPerStage; } 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) --last; return last; } uint8_t firstMaxPulseIndexAtLeast(uint32_t pulseNs, uint8_t last) { for (uint8_t i = 0; i <= last; ++i) if (MAX_PULSE_OPTIONS_NS[i] >= pulseNs) return i; return last; } uint8_t lastMinPulseIndexAtMost(uint32_t pulseNs) { for (size_t i = countOf(MIN_PULSE_OPTIONS_NS); i > 0; --i) if (MIN_PULSE_OPTIONS_NS[i - 1U] <= pulseNs) return static_cast(i - 1U); return 0; } uint32_t plannedPulseCaptureHz(uint32_t frequencyHz, float dutyPct) { if (!frequencyHz || dutyPct <= 0.0f || dutyPct >= 100.0f) return 0; return MCPWM_CAPTURE_RESOLUTION_HZ; } uint32_t plannedCaptureHz(uint32_t frequencyHz, float dutyPct) { // One 32-bit S3 MCPWM capture timer measures period and pulse at 80 MHz. return plannedPulseCaptureHz(frequencyHz, dutyPct); } bool pulsePointHasResolution(uint32_t hz, uint32_t pulseNs, float accuracyPct) { uint32_t actualHz = 0, actualPulseNs = 0; uint8_t bits = 0; if (TARGET_IS_C3) { IntegerPwmConfig config = {}; if (!choosePwmConfig(hz, pulseNs, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, config)) return false; actualHz = config.actualHz; actualPulseNs = config.actualPulseNs; bits = config.bits; } else { if (!hz || MCPWM_RESOLUTION_HZ % hz) return false; const uint32_t periodTicks = MCPWM_RESOLUTION_HZ / hz; if (periodTicks < 2U || periodTicks > MCPWM_MAX_PERIOD_TICKS) return false; uint32_t activeTicks = static_cast( (static_cast(pulseNs) * MCPWM_RESOLUTION_HZ + 500000000ULL) / 1000000000ULL); if (!activeTicks || activeTicks >= periodTicks) return false; actualHz = hz; actualPulseNs = static_cast( (static_cast(activeTicks) * 1000000000ULL + MCPWM_RESOLUTION_HZ / 2U) / MCPWM_RESOLUTION_HZ); bits = 1; for (uint32_t ticks = periodTicks; ticks > 1U; ticks >>= 1U) ++bits; } if (!periodWithin(actualHz, hz, accuracyPct) || !periodWithin(actualPulseNs, pulseNs, accuracyPct)) return false; const float dutyPct = dutyFromPulse(actualHz, actualPulseNs); const uint32_t captureHz = plannedCaptureHz(actualHz, dutyPct); const uint32_t pulseCaptureHz = plannedPulseCaptureHz(actualHz, dutyPct); return captureHz && pulseCaptureHz && validateResolution(actualHz, dutyPct, accuracyPct, captureHz, pulseCaptureHz, bits, MEASUREMENT_AVERAGING_PERIODS) == FailReason::NONE; } uint32_t minimumPulseForAccuracy(uint32_t frequencyHz, float accuracyPct) { for (uint32_t pulseNs : TEST_PULSE_WIDTHS_NS) if (pulsePointHasResolution(frequencyHz, pulseNs, accuracyPct)) return pulseNs; return UINT32_MAX; } uint8_t firstMinPulseIndexAtLeast(uint32_t pulseNs, uint8_t last) { for (uint8_t i = 0; i <= last; ++i) if (MIN_PULSE_OPTIONS_NS[i] >= pulseNs) return i; return last; } uint8_t cycleIndex(uint8_t value, uint8_t first, uint8_t last, int direction) { if (first >= last) return first; if (direction > 0) return value >= last ? first : static_cast(value + 1U); return value <= first ? last : static_cast(value - 1U); } 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; } 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); #if ARDUINO_USB_CDC_ON_BOOT Serial.setTxTimeoutMs(SERIAL_TX_TIMEOUT_MS); #endif Log::printf("BOOT", "firmware start, Serial=%lu baud", SERIAL_BAUD); startButton_.begin(); modeButton_.begin(); pwm_.begin(); bootCheckStartedMs_ = millis(); bootResetCandidate_ = startButton_.pressed() && modeButton_.pressed(); Log::printf("BOOT", "buttons initialized, factory-reset candidate=%s", bootResetCandidate_ ? "YES" : "NO"); if (!bootResetCandidate_) finishInitialization(false); } void App::finishInitialization(bool factoryReset) { if (initialized_) return; Log::printf("BOOT", "initialization continues, factory-reset=%s", factoryReset ? "YES" : "NO"); if (factoryReset) { store_.defaults(settings_); store_.save(settings_); Log::event("BOOT", "FACTORY DEFAULTS RESTORED"); } else if (!store_.load(settings_)) { store_.save(settings_); Log::event("BOOT", "NVS invalid/missing: defaults loaded"); } sanitizeRange(); params_ = store_.params(settings_); pwm_.configureActiveLight(configuredTxPulseLightOn(settings_)); if (!display_.begin()) Log::event("BOOT", "OLED unavailable; Serial UI remains fully operational"); initialized_ = true; if (!receiver_.begin()) { Log::event("BOOT", "FATAL: capture peripheral init failed"); finish(false, FailReason::UNSUPPORTED); return; } Log::printf("BOOT", "capture initialized: %s", receiver_.highRateBackend() ? "MCPWM 80MHz" : "GPIO cycle counter"); lastUserActivityMs_ = millis(); setActivePerformance(false); printConfiguration(); if (static_cast(settings_.role) == Role::SLAVE) armSlave(); else showIdle(); } void App::update() { serviceSerialConsole(); serviceIdlePowerSave(); const uint32_t now = millis(); const ButtonEvent startEvent = startButton_.update(now); const ButtonEvent modeEvent = modeButton_.update(now); if (startEvent != ButtonEvent::NONE) Log::printf("INPUT", "START %s state=%s", buttonEventName(startEvent), appStateName(state_)); if (modeEvent != ButtonEvent::NONE) Log::printf("INPUT", "MODE %s state=%s", buttonEventName(modeEvent), appStateName(state_)); if (startEvent != ButtonEvent::NONE || modeEvent != ButtonEvent::NONE) { lastUserActivityMs_ = now; leaveIdlePowerSave(); } if (!initialized_) { if (!startButton_.pressed() || !modeButton_.pressed()) finishInitialization(false); else if (now - bootCheckStartedMs_ >= FACTORY_RESET_HOLD_MS) finishInitialization(true); return; } serviceRxPinStateLog(); if (state_ != AppState::IDLE && state_ != AppState::MENU && state_ != AppState::FINISHED && startEvent == ButtonEvent::LONG) { abortTest(); return; } if (state_ != AppState::IDLE && state_ != AppState::MENU && state_ != AppState::FINISHED && modeEvent != ButtonEvent::NONE) Log::event("ACTION", "MODE ignored while test is active"); if (state_ == AppState::IDLE || state_ == AppState::FINISHED) { if (modeEvent == ButtonEvent::SHORT) { 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", "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(); } else if (state_ == AppState::FINISHED && static_cast(settings_.role) == Role::SLAVE && now >= slaveRearmAtMs_) armSlave(pendingReason_ != FailReason::NONE); return; } if (state_ == AppState::SLAVE_READY && modeEvent != ButtonEvent::NONE) { radio_.end(); havePeer_ = false; if (modeEvent == ButtonEvent::SHORT) { settings_.role = static_cast(Role::SOLO); 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) { state_ = AppState::MENU; menuItem_ = 0; showMenu(); } return; } if (state_ == AppState::MENU) { if (modeEvent == ButtonEvent::SHORT) { 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_); Log::printf("ACTION", "settings menu saved and closed, NVS=%s", saved ? "OK" : "FAILED"); state_ = AppState::IDLE; printConfiguration(); if (static_cast(settings_.role) == Role::SLAVE) armSlave(); else showIdle(); } else if (startEvent == ButtonEvent::SHORT) changeMenu(+1); else if (startEvent == ButtonEvent::LONG || startEvent == ButtonEvent::REPEAT) changeMenu(-1); return; } if (state_ == AppState::SOLO_MEASURE) { if (static_cast(now - localMeasurementDeadlineMs_) >= 0) { Log::event("MEASURE", "local stage watchdog expired"); measurement_.forceFail(FailReason::LOST_EDGE); } const MeasureState ms = measurement_.update(); if (ms == MeasureState::FAIL) { pwm_.stop(); printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); finish(false, measurement_.reason(), true); } else if (ms == MeasureState::PASS) { pwm_.stop(); printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); stagePassed(); } else if (measurement_.takeProgressUpdate()) { StageStats live = {}; if (measurement_.statsSnapshot(live)) showStageResult(live); } } else if (state_ == AppState::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(); } else { handleRadio(); updateSlave(); } } void App::showIdle() { setActivePerformance(false); setStandbyOpticalOutput(); lastUserActivityMs_ = millis(); 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 (DRIVER only)"); } 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, configuredLevelName(settings_), 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(configuredTxPulseLightOn(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 (static_cast(settings_.testKind) != TestKind::DRIVER) { Serial.println("ERR level setting is available only in DRIVER test"); return; } 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); settings_.accuracyIndex %= countOf(ACCURACY_OPTIONS_PCT); settings_.timeIndex %= countOf(TEST_TIME_OPTIONS_MS); const uint32_t hz = PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex]; const uint8_t lastValid = lastValidMaxPulseIndex(hz); if (settings_.maxPulseIndex > lastValid) settings_.maxPulseIndex = lastValid; const uint8_t lastMin = lastMinPulseIndexAtMost(MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]); if (settings_.minPulseIndex > lastMin) settings_.minPulseIndex = lastMin; 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; } } void App::serviceRxPinStateLog() { #ifdef RX_PIN_CHANGE_TEST const bool level = digitalRead(GPIO_RX) == HIGH; const bool outsideTest = state_ == AppState::IDLE || state_ == AppState::MENU || state_ == AppState::SLAVE_READY || state_ == AppState::FINISHED; if (rxPinStateKnown_ && level != rxPinState_ && outsideTest) Log::printf("RX TEST", "GPIO=%u state=%s (%u)", GPIO_RX, level ? "HIGH" : "LOW", level ? 1U : 0U); rxPinState_ = level; rxPinStateKnown_ = true; #endif } void App::changeMenu(int d) { sanitizeRange(); if (menuItem_ == 1) { const uint8_t last = lastValidMaxPulseIndex( PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex]); const uint8_t first = firstMaxPulseIndexAtLeast( MIN_PULSE_OPTIONS_NS[settings_.minPulseIndex], last); settings_.maxPulseIndex = cycleIndex(settings_.maxPulseIndex, first, last, d); } else if (menuItem_ == 2) { const uint8_t last = lastMinPulseIndexAtMost( MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]); const uint8_t first = 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: if (static_cast(settings_.testKind) != TestKind::DRIVER) { showMenu(); return; } value = &settings_.lightCode; count = 4; break; default: return; } *value = cycleIndex(*value, 0, static_cast(count - 1U), d); } sanitizeRange(); params_ = store_.params(settings_); pwm_.configureActiveLight(configuredTxPulseLightOn(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, configuredLevelName(settings_)); showMenu(); } void App::showMenu() { char one[64], value[24], total[64], all[12]; const char *label = nullptr; Display::formatDuration(actualNominalTotalUs(), all, sizeof(all)); switch (menuItem_) { case 0: Display::formatPwmFrequency(params_.frequencyHz, value, sizeof(value)); label = UiText::MENU_FREQUENCY; break; case 1: Display::formatPulse(params_.maxPulseNs, value, sizeof(value)); label = UiText::MENU_MAX_PULSE; break; case 2: Display::formatPulse(params_.minPulseNs, value, sizeof(value)); label = UiText::MENU_MIN_PULSE; break; case 3: snprintf(value, sizeof(value), "+/-%g%%", params_.accuracyPct); label = UiText::MENU_ACCURACY; break; case 4: snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f); label = UiText::MENU_TEST_TIME; break; case 5: { if (static_cast(settings_.testKind) != TestKind::DRIVER) { snprintf(value, sizeof(value), "%s", UiText::LIGHT_AUTO); label = UiText::MENU_LIGHT_CODE; formatMenuLine(label, value, one, sizeof(one)); display_.show(one, UiText::LIGHT_AUTO_FORMAT); return; } 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)); formatMenuLine(UiText::MENU_TOTAL_TIME, all, total, sizeof(total)); display_.show(one, total); } 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 test=%s light=%s stages=%lu", roleName(static_cast(settings_.role)), testKindName(static_cast(settings_.testKind)), configuredLevelName(settings_), stageCount_); if (SERIAL_MINIMAL_LOG) { 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, configuredLevelName(settings_), stageCount_); } printConfiguration(); const Role role = static_cast(settings_.role); if (role == Role::SOLO) { if (!prepareStage()) return; 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); } } bool App::armSlave(bool preserveDisplay) { setActivePerformance(false); pwm_.stop(); lastUserActivityMs_ = millis(); params_ = store_.params(settings_); stageIndex_ = 0; stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs); requestedHz_ = params_.frequencyHz; requestedPulseNs_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false; lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0; retries_ = 0; slaveRearmAtMs_ = 0; if (!radio_.begin()) { state_ = AppState::FINISHED; pendingReason_ = FailReason::LINK_LOST; slaveRearmAtMs_ = millis() + LINK_HEARTBEAT_TIMEOUT_MS; display_.show(UiText::LINK_FAILED, UiText::RADIO_ERROR, 0, 0, roleCorner(Role::SLAVE)); return false; } radio_.setWindowedReceive(true); radio_.flush(); state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave automatically armed and waiting for Master"); if (!preserveDisplay) display_.show(UiText::SLAVE_READY, UiText::WAIT_MASTER); return true; } bool App::prepareStage(bool showProgress) { requestedHz_ = params_.frequencyHz; requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_); actual_ = {}; const uint32_t maxHz = TARGET_IS_C3 ? C3_STRICT_MAX_HZ : (receiver_.highRateBackend() ? S3_STRICT_MAX_HZ : C3_STRICT_MAX_HZ); if (requestedHz_ > maxHz) { finish(false, FailReason::UNSUPPORTED); return false; } Log::printf("PWM", "starting GPIO=%u requested=%luHz pulse=%luns", GPIO_PWM, requestedHz_, requestedPulseNs_); if (!pwm_.start(requestedHz_, requestedPulseNs_, actual_)) { Log::printf("PWM", "START FAILED GPIO=%u requested=%luHz pulse=%luns; PWM setup failed", GPIO_PWM, requestedHz_, requestedPulseNs_); finish(false, FailReason::RESOLUTION); return false; } if (!periodWithin(actual_.actualHz, requestedHz_, params_.accuracyPct) || !periodWithin(actual_.actualPulseNs, requestedPulseNs_, params_.accuracyPct)) { Log::printf("PWM", "requested point cannot be generated within tolerance: requested=%luHz/%luns actual=%luHz/%luns tolerance=%.2f%%", requestedHz_, requestedPulseNs_, actual_.actualHz, actual_.actualPulseNs, params_.accuracyPct); finish(false, FailReason::RESOLUTION); return false; } const 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) { const bool started = driverMode ? startDriverMeasurement() : startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct); if (!started) { finish(false, FailReason::UNSUPPORTED); return false; } } return true; } bool App::startLocalMeasurement(float hz, float duty) { Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% period-capture=%luHz pulse-capture=%luHz settle=%u cycles window=%lums; every pulse validated", hz, duty, effectiveTolerancePct(params_.accuracyPct), receiver_.plannedTickHz(static_cast(hz + 0.5f), duty), receiver_.plannedPulseTickHz(static_cast(hz + 0.5f), duty), PWM_SETTLE_CYCLES, params_.testTimeMs); const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, MEASUREMENT_AVERAGING_PERIODS, PWM_SETTLE_CYCLES, true); const uint32_t nominalMs = stageWallTimeMs(params_.testTimeMs, static_cast(hz + 0.5f)); const uint64_t watchdogMs = static_cast(nominalMs) * 2ULL + 2000ULL; localMeasurementDeadlineMs_ = millis() + static_cast( watchdogMs > UINT32_MAX ? UINT32_MAX : watchdogMs); Log::printf("MEASURE", "receiver start %s, continuous edge capture", ok ? "OK" : "FAILED"); return ok; } 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(); // With PWM already quiet it is safe to stop capture before clearing its // queue for the next pulse width. Never reset a FreeRTOS queue concurrently // with the capture ISR. if (static_cast(settings_.role) == Role::SOLO) receiver_.stop(); if (++stageIndex_ >= stageCount_) { 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_); actual_ = {}; stageStartConfirmed_ = false; pendingPacket_ = makePacket(MessageType::PREPARE); sendCurrent(MessageType::PREPARE); state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } } void App::startMasterDiscovery() { session_ = esp_random(); if (!session_) session_ = 1; sequence_ = 1; stageIndex_ = 0; requestedHz_ = params_.frequencyHz; requestedPulseNs_ = 0; havePeer_ = false; radio_.flush(); opticalWakeActive_ = true; lastOpticalWakeToggleMs_ = millis(); pwm_.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_); display_.show(UiText::MASTER_SEARCH, UiText::HOLD_START_STOP); } ProtocolPacket App::makePacket(MessageType type) const { ProtocolPacket p = {}; p.type = static_cast(type); p.session = session_; p.stage = stageIndex_; p.stageCount = static_cast(stageCount_); p.sequence = sequence_; p.requestedHz = requestedHz_; p.requestedPulseNs = requestedPulseNs_; p.actualHz = actual_.actualHz; p.actualPulseNs = actual_.actualPulseNs; p.testTimeMs = params_.testTimeMs; p.accuracyX100 = static_cast(params_.accuracyPct * 100.0f + 0.5f); p.lightCode = settings_.lightCode; return p; } void App::sendCurrent(MessageType type) { ++sequence_; pendingPacket_ = makePacket(type); const bool ok = sendLinked(pendingPacket_); lastSendMs_ = millis(); if (!ok) Log::printf("ESP-NOW", "sendCurrent %s FAILED", messageName(type)); } bool App::sendLinked(ProtocolPacket packet) { return havePeer_ ? radio_.sendTo(peer_, packet) : radio_.sendBroadcast(packet); } void App::updateHeartbeat() { if (!havePeer_ || state_ == AppState::FINISHED) return; const uint32_t now = millis(); const uint32_t radioRxMs = radio_.lastReceiveMs(); if (radioRxMs && now - radioRxMs < now - lastPeerSeenMs_) lastPeerSeenMs_ = radioRxMs; if (now - lastPeerSeenMs_ >= LINK_HEARTBEAT_TIMEOUT_MS) { Log::event("ESP-NOW", "peer heartbeat timeout"); finish(false, FailReason::LINK_LOST); return; } if (static_cast(settings_.role) == Role::MASTER && now - lastHeartbeatMs_ >= LINK_HEARTBEAT_INTERVAL_MS) { ProtocolPacket heartbeat = makePacket(MessageType::HEARTBEAT); heartbeat.sequence = sequence_; sendLinked(heartbeat); lastHeartbeatMs_ = now; } } bool App::packetForCurrent(const ProtocolPacket &p) const { return p.session == session_ && p.stage == stageIndex_; } void App::handleRadio() { ReceivedPacket r; while (radio_.receive(r)) { const MessageType type = static_cast(r.packet.type); if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK && type != MessageType::PROGRESS && state_ != AppState::SLAVE_MEASURE) Log::printf("ESP-NOW", "RX %s session=%08lX stage=%u seq=%u", messageName(type), r.packet.session, r.packet.stage, r.packet.sequence); if ((state_ == AppState::SLAVE_READY || state_ == AppState::SLAVE_WAIT_START) && type == MessageType::DISCOVER && (!havePeer_ || !memcmp(peer_, r.mac, 6))) { leaveIdlePowerSave(); pwm_.stop(); setActivePerformance(true); radio_.setWindowedReceive(false); memcpy(peer_, r.mac, 6); havePeer_ = true; session_ = r.packet.session; stageIndex_ = 0; sequence_ = r.packet.sequence; lastPeerSeenMs_ = millis(); ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; sendLinked(ack); state_ = AppState::SLAVE_WAIT_START; display_.show(UiText::MASTER_SEEN, UiText::ACK_SENT); continue; } if (state_ == AppState::MASTER_DISCOVER && type == MessageType::DISCOVER_ACK && r.packet.session == session_) { opticalWakeActive_ = false; pwm_.stop(); memcpy(peer_, r.mac, 6); havePeer_ = true; lastPeerSeenMs_ = lastHeartbeatMs_ = millis(); requestedHz_ = params_.frequencyHz; requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_); sendCurrent(MessageType::PREPARE); state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; char mac[20]; Radio::macText(peer_, mac, sizeof(mac)); Log::printf("ESP-NOW", "Slave selected %s", mac); continue; } if (havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_) lastPeerSeenMs_ = millis(); if (havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_ && type == MessageType::HEARTBEAT) { continue; // Radio's priority heartbeat task has already sent the ACK. } if (type == MessageType::HEARTBEAT_ACK) continue; if (havePeer_ && !memcmp(peer_, r.mac, 6) && type == MessageType::RESULT && r.packet.session == session_ && r.packet.stage < stageIndex_) { ProtocolPacket ack = {}; ack.type = static_cast(MessageType::ACK); ack.session = session_; ack.stage = r.packet.stage; ack.sequence = r.packet.sequence; sendLinked(ack); continue; // idempotent ACK for a retried old result } const bool matchingPeer = havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_; if (matchingPeer && type == MessageType::PREPARE) { const bool expected = state_ == AppState::SLAVE_WAIT_START && r.packet.stage == stageIndex_; const bool implicitAck = state_ == AppState::SLAVE_WAIT_ACK && pendingPacket_.passed && r.packet.stage == static_cast(pendingPacket_.stage + 1U); if (expected || implicitAck) { stageIndex_ = r.packet.stage; sequence_ = r.packet.sequence; state_ = AppState::SLAVE_WAIT_START; params_.testTimeMs = r.packet.testTimeMs; params_.accuracyPct = r.packet.accuracyX100 / 100.0f; 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_ = {}; ProtocolPacket ready = makePacket(MessageType::READY); ready.sequence = r.packet.sequence; sendLinked(ready); } continue; } if (!havePeer_ || memcmp(peer_, r.mac, 6) || !packetForCurrent(r.packet)) continue; if (type == MessageType::ABORT) { const FailReason reason = r.packet.reason > static_cast(FailReason::NONE) && r.packet.reason <= static_cast(FailReason::ABORTED) ? static_cast(r.packet.reason) : FailReason::ABORTED; if (r.packet.requestedHz) requestedHz_ = r.packet.requestedHz; if (r.packet.requestedPulseNs) requestedPulseNs_ = r.packet.requestedPulseNs; actual_.actualHz = r.packet.actualHz ? r.packet.actualHz : requestedHz_; actual_.actualPulseNs = r.packet.actualPulseNs ? r.packet.actualPulseNs : requestedPulseNs_; actual_.actualDutyPct = dutyFromPulse(actual_.actualHz, actual_.actualPulseNs); measurement_.abort(); finish(false, reason); continue; } if (state_ == AppState::MASTER_WAIT_READY && type == MessageType::READY) { if (!prepareStage(false)) continue; sendCurrent(MessageType::START_STAGE); state_ = AppState::MASTER_WAIT_RESULT; stageStartConfirmed_ = false; retries_ = 0; deadlineMs_ = millis() + LINK_RETRY_INTERVAL_MS; } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::READY && r.packet.sequence == pendingPacket_.sequence) { if (!stageStartConfirmed_) showStageProgress(); stageStartConfirmed_ = true; deadlineMs_ = millis() + stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS + 20; } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::PROGRESS) { stageStartConfirmed_ = true; deadlineMs_ = millis() + stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS; showRemoteResult(r.packet); } else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::RESULT) { ProtocolPacket ack = makePacket(MessageType::ACK); ack.sequence = r.packet.sequence; ack.passed = r.packet.passed && stageIndex_ + 1U >= stageCount_; sendLinked(ack); pwm_.stop(); showRemoteResult(r.packet); if (!r.packet.passed) { finish(false, static_cast(r.packet.reason), true); } else if (ack.passed) { pendingPacket_ = ack; state_ = AppState::MASTER_FINALIZE; retries_ = 0; deadlineMs_ = millis() + FINAL_ACK_RETRY_INTERVAL_MS; } else stagePassed(); } else if (state_ == AppState::MASTER_FINALIZE && type == MessageType::RESULT) { // The Slave did not receive the final ACK and repeated RESULT. sendLinked(pendingPacket_); } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) { sequence_ = r.packet.sequence; actual_.actualHz = r.packet.actualHz; actual_.actualPulseNs = r.packet.actualPulseNs; actual_.actualDutyPct = dutyFromPulse(actual_.actualHz, actual_.actualPulseNs); if (!startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); continue; } showStageProgress(); state_ = AppState::SLAVE_MEASURE; deadlineMs_ = millis() + stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS; ProtocolPacket started = makePacket(MessageType::READY); started.sequence = r.packet.sequence; sendLinked(started); } else if (state_ == AppState::SLAVE_MEASURE && type == MessageType::START_STAGE) { // START_STAGE or its acknowledgement was lost. Do not restart the // measurement; only confirm the already running stage again. ProtocolPacket started = makePacket(MessageType::READY); started.sequence = r.packet.sequence; sendLinked(started); } else if (state_ == AppState::SLAVE_WAIT_ACK && type == MessageType::ACK && r.packet.sequence == pendingPacket_.sequence) { if (pendingPacket_.passed) { // Master sends ACK only after stopping its PWM. Disable capture for // every completed stage, so the next start can clear its queue without // racing the ISR (not only after the final stage). receiver_.stop(); if (r.packet.passed) { radio_.end(); pendingReason_ = FailReason::NONE; if (armSlave(true)) display_.show(UiText::PASS_WORD, UiText::WAIT_MASTER); } else { stageIndex_ = static_cast(r.packet.stage) + 1U; state_ = AppState::SLAVE_WAIT_START; } } else finish(false, static_cast(pendingPacket_.reason), true); } } } void App::updateMaster() { const uint32_t now = millis(); if (state_ == AppState::MASTER_DISCOVER) { if (now - lastOpticalWakeToggleMs_ >= OPTICAL_WAKE_HALF_PERIOD_MS) { opticalWakeActive_ = !opticalWakeActive_; if (opticalWakeActive_) pwm_.lightOn(); else pwm_.stop(); lastOpticalWakeToggleMs_ = now; } if (now - lastSendMs_ >= DISCOVERY_RETRY_INTERVAL_MS) { if (++retries_ % 50U == 0U) Log::event("ESP-NOW", "DISCOVER burst continues"); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = now; } return; } if (state_ == AppState::MASTER_FINALIZE) { if (now < deadlineMs_) return; if (retries_++ < FINAL_ACK_RETRIES) { sendLinked(pendingPacket_); deadlineMs_ = now + FINAL_ACK_RETRY_INTERVAL_MS; } else finish(true, FailReason::NONE); return; } updateHeartbeat(); if (state_ == AppState::FINISHED) return; if (now < deadlineMs_) return; if (retries_ >= LINK_PACKET_RETRIES) { finish(false, FailReason::LINK_LOST); return; } Log::printf("ESP-NOW", "%s retry=%u", messageName(static_cast(pendingPacket_.type)), retries_ + 1); sendLinked(pendingPacket_); ++retries_; deadlineMs_ = now + (state_ == AppState::MASTER_WAIT_RESULT ? (stageStartConfirmed_ ? stageWallTimeMs(params_.testTimeMs, actual_.actualHz) + LINK_REPLY_TIMEOUT_MS : LINK_RETRY_INTERVAL_MS) : LINK_REPLY_TIMEOUT_MS); } void App::updateSlave() { updateHeartbeat(); if (state_ == AppState::FINISHED) return; if (state_ == AppState::SLAVE_MEASURE) { if (static_cast(millis() - localMeasurementDeadlineMs_) >= 0) { Log::event("MEASURE", "Slave local stage watchdog expired"); measurement_.forceFail(FailReason::LOST_EDGE); } const MeasureState ms = measurement_.update(); if (measurement_.takeProgressUpdate()) { StageStats live = {}; if (measurement_.statsSnapshot(live)) { ProtocolPacket progress = makePacket(MessageType::PROGRESS); progress.progressStep = measurement_.progressStep(); fillMeasuredResult(progress, live); progress.sequence = sequence_; sendLinked(progress); showStageResult(live); } return; } if (ms != MeasureState::PASS && ms != MeasureState::FAIL) { return; } printStageStats(measurement_.stats(), actual_.actualHz); showStageResult(measurement_.stats()); pendingPacket_ = makePacket(MessageType::RESULT); pendingPacket_.progressStep = ms == MeasureState::PASS ? MEASUREMENT_PROGRESS_STEPS : measurement_.progressStep(); pendingPacket_.passed = ms == MeasureState::PASS && measurement_.reason() == FailReason::NONE; pendingPacket_.reason = static_cast(measurement_.reason()); pendingPacket_.periods = measurement_.stats().periods; fillMeasuredResult(pendingPacket_, measurement_.stats()); pendingPacket_.minPeriodTicks = measurement_.stats().minPeriod; pendingPacket_.maxPeriodTicks = measurement_.stats().maxPeriod; pendingPacket_.sequence = ++sequence_; sendLinked(pendingPacket_); Log::printf("TEST", "Slave result prepared: %s reason=%s periods=%lu", pendingPacket_.passed ? "PASS" : "FAIL", failName(static_cast(pendingPacket_.reason)), pendingPacket_.periods); state_ = AppState::SLAVE_WAIT_ACK; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } else if (state_ == AppState::SLAVE_WAIT_ACK && millis() >= deadlineMs_) { if (retries_++ >= LINK_PACKET_RETRIES) finish(false, FailReason::LINK_LOST); else { Log::printf("ESP-NOW", "RESULT retry=%u", retries_); sendLinked(pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } } } void App::sendAbort(FailReason reason) { if (!havePeer_) return; ++sequence_; ProtocolPacket packet = makePacket(MessageType::ABORT); packet.reason = static_cast(reason); if (!packet.actualPulseNs) packet.actualPulseNs = requestedPulseNs_; sendLinked(packet); } void App::abortTest() { Log::event("ACTION", "abort requested: sending ABORT, stopping receiver and PWM"); sendAbort(FailReason::ABORTED); measurement_.abort(); driverTest_.abort(); finish(false, FailReason::ABORTED); } void App::finish(bool pass, FailReason reason, bool preserveDisplay) { Log::printf("TEST", "finishing result=%s reason=%s", pass ? "PASS" : "FAIL", failName(reason)); const AppState failedState = state_; const bool masterLinkLost = reason == FailReason::LINK_LOST && (failedState == AppState::MASTER_DISCOVER || failedState == AppState::MASTER_WAIT_READY || failedState == AppState::MASTER_WAIT_RESULT || failedState == AppState::MASTER_FINALIZE); const bool masterActive = failedState == AppState::MASTER_DISCOVER || failedState == AppState::MASTER_WAIT_READY || failedState == AppState::MASTER_WAIT_RESULT || failedState == AppState::MASTER_FINALIZE; const bool slaveLinkLost = reason == FailReason::LINK_LOST && (failedState == AppState::SLAVE_READY || failedState == AppState::SLAVE_WAIT_START || failedState == AppState::SLAVE_MEASURE || failedState == AppState::SLAVE_WAIT_ACK); pwm_.stop(); receiver_.stop(); if (masterLinkLost) { Log::event("ESP-NOW", "link lost; returning to continuous discovery"); startMasterDiscovery(); return; } if (!pass && masterActive && havePeer_ && reason != FailReason::ABORTED) sendAbort(reason); if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end(); state_ = AppState::FINISHED; pendingReason_ = reason; setStandbyOpticalOutput(); setActivePerformance(false); lastUserActivityMs_ = millis(); if (slaveLinkLost) { char target[32], one[64]; formatTarget(requestedHz_, requestedPulseNs_, target, sizeof(target)); snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target); display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_, roleCorner(Role::SLAVE)); armSlave(true); return; } // The result has already been acknowledged before a normal measurement // failure reaches here. Re-arm ESP-NOW immediately so a quick retry from // Master is not hidden behind the former two-second delay; preserve the // failure screen while listening. if (static_cast(settings_.role) == Role::SLAVE) { armSlave(true); return; } if (preserveDisplay) return; char one[64]; if (pass) { const Role role = static_cast(settings_.role); snprintf(one, sizeof(one), "%s %s", uiRoleName(role), UiText::PASS_WORD); display_.show(one, role == Role::SLAVE ? UiText::WAIT_MASTER : UiText::START_AGAIN); } else if (requestedHz_) { char target[32]; formatTarget(requestedHz_, requestedPulseNs_, target, sizeof(target)); snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target); display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_, roleCorner(static_cast(settings_.role))); } else { display_.show(UiText::TEST_FAILED, uiFailName(reason), 0, 0, roleCorner(static_cast(settings_.role))); } } bool App::idlePowerSaveAllowed() const { // Never enter blocking light sleep while the settings screen is open. A // wake-up press is deliberately consumed by the button state machine, which // is useful in IDLE but makes menu navigation appear frozen. return !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() { // 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 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); } void App::leaveIdlePowerSave(bool wakeDisplay) { if (!idlePowerSave_) { if (wakeDisplay) display_.setPower(true); return; } idlePowerSave_ = false; lastUserActivityMs_ = millis(); setStandbyOpticalOutput(); if (idleSleepRadioStopped_) { idleSleepRadioStopped_ = false; if (radio_.begin()) { radio_.setWindowedReceive(true); radio_.flush(); Log::event("POWER", "Slave ESP-NOW restored after external wake"); } else Log::event("POWER", "Slave ESP-NOW restore FAILED after external wake"); } if (wakeDisplay) display_.setPower(true); Log::event("POWER", "idle light sleep ended"); } void App::serviceIdlePowerSave() { if (!idlePowerSaveAllowed()) { leaveIdlePowerSave(false); return; } const uint32_t now = millis(); if (!idlePowerSave_) { if (now - lastUserActivityMs_ < IDLE_POWER_SAVE_TIMEOUT_MS) { delay(1); // allow the FreeRTOS idle task to halt the CPU between UI polls return; } idlePowerSave_ = true; pwm_.stop(); if (state_ == AppState::SLAVE_READY) { radio_.end(); idleSleepRadioStopped_ = true; } display_.setPower(false); Log::event("POWER", "idle timeout; preparing light sleep"); Serial.flush(); delay(2); } gpio_wakeup_enable(static_cast(GPIO_BUTTON_START), BUTTON_ACTIVE_LEVEL == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); gpio_wakeup_enable(static_cast(GPIO_BUTTON_MODE), BUTTON_ACTIVE_LEVEL == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); if (static_cast(settings_.role) == Role::SLAVE) { const bool currentRxHigh = gpio_get_level(static_cast(GPIO_RX)) != 0; gpio_wakeup_enable(static_cast(GPIO_RX), currentRxHigh ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); } else gpio_wakeup_disable(static_cast(GPIO_RX)); esp_sleep_enable_gpio_wakeup(); const esp_err_t result = esp_light_sleep_start(); if (result != ESP_OK) { leaveIdlePowerSave(true); delay(1); return; } const esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); const bool buttonWake = digitalRead(GPIO_BUTTON_START) == BUTTON_ACTIVE_LEVEL || digitalRead(GPIO_BUTTON_MODE) == BUTTON_ACTIVE_LEVEL; if (buttonWake) { startButton_.suppressUntilRelease(); modeButton_.suppressUntilRelease(); } // GPIO wake worked, so disarm all level sources before peripherals and the // button state machines are brought back up. gpio_wakeup_disable(static_cast(GPIO_BUTTON_START)); gpio_wakeup_disable(static_cast(GPIO_BUTTON_MODE)); gpio_wakeup_disable(static_cast(GPIO_RX)); // Native USB and I2C can retain stale driver state across light sleep even // though their clocks have stopped. A full end/begin cycle prevents the // several-second button stalls and restores Serial output after wake. setActivePerformance(false); Serial.end(); delay(2); Serial.begin(SERIAL_BAUD); #if ARDUINO_USB_CDC_ON_BOOT Serial.setTxTimeoutMs(SERIAL_TX_TIMEOUT_MS); #endif Wire.end(); Wire.begin(GPIO_SDA, GPIO_SCL); Wire.setClock(400000); Wire.setTimeOut(30); // This also restores ESP-NOW when Slave stopped it before sleeping. leaveIdlePowerSave(true); Log::printf("POWER", "light sleep wake cause=%u button=%s; peripherals restored", static_cast(cause), buttonWake ? "YES" : "NO"); if (buttonWake) Log::event("POWER", "wake button consumed; next press will perform the action"); } void App::printConfiguration() { if (SERIAL_MINIMAL_LOG) return; const char *board = TARGET_IS_C3 ? "ESP32-C3" : "ESP32-S3"; uint8_t mac[6] = {}; esp_read_mac(mac, ESP_MAC_WIFI_STA); Serial.printf("\nOptical Channel Tester | %s | mode=%s/%s | light=%s\n", board, roleName(static_cast(settings_.role)), testKindName(static_cast(settings_.testKind)), configuredLevelName(settings_)); 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); if (static_cast(settings_.testKind) == TestKind::DRIVER) { const char *code = lightCodeName(static_cast(settings_.lightCode)); 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, code[0], code[1]); } else { Serial.printf("Test %lu Hz, pulse %lu..%lu ns, accuracy %.2f%%, %lums, optical polarity=AUTO\n", params_.frequencyHz, params_.maxPulseNs, params_.minPulseNs, params_.accuracyPct, params_.testTimeMs); } stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs); Serial.printf("Pulse widths descending (%lu): ", stageCount_); for (uint32_t i = 0; i < stageCount_; ++i) Serial.printf("%lu%s", pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, i), i + 1 == stageCount_ ? " ns\n" : ","); Serial.printf("ALL nominal: %llu us | RX=%s\n", actualNominalTotalUs(), receiver_.highRateBackend() ? "MCPWM 80MHz" : "GPIO cycle counter"); } uint64_t App::actualNominalTotalUs() { // ALL is only an estimate. Do not attach/detach LEDC for every frequency: // large sweeps can perform hundreds of unnecessary driver reconfigurations // immediately before the real test and leave no observable PWM on failure. return nominalTotalUs(params_, PWM_SETTLE_CYCLES); } void App::printStageStats(const StageStats &s, uint32_t hz) { if (!s.periods) return; const float measuredHz = static_cast(receiver_.tickHz()) * s.periods / s.periodSum; const uint32_t measuredPulseNs = static_cast(lround( static_cast(s.activeSum) * 1000000000.0 / (static_cast(receiver_.pulseTickHz()) * s.periods))); char requestedText[12], measuredText[12]; Display::formatFrequency(hz, requestedText, sizeof(requestedText)); Display::formatFrequency(measuredHz, measuredText, sizeof(measuredText)); const char *status = s.reason == FailReason::NONE ? "PASS" : "FAIL"; Log::printf("RESULT", "%s/%luns %s periods=%lu measured=%s/%luns skipped=%lu%s%s", requestedText, requestedPulseNs_, status, s.periods, measuredText, measuredPulseNs, s.droppedItems, s.reason == FailReason::NONE ? "" : " reason=", s.reason == FailReason::NONE ? "" : failName(s.reason)); } void App::showStageResult(const StageStats &s) { char one[64], two[64]; formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); if (s.reason != FailReason::NONE) { formatFailure(s.reason, requestedHz_, requestedPulseNs_, one, sizeof(one)); if (s.reason == FailReason::PERIOD_OUT && s.badFrequency > 0.0f) { char frequency[12]; Display::formatFrequency(s.badFrequency, frequency, sizeof(frequency)); snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency); } else if (s.reason == FailReason::DUTY_OUT && s.badFrequency > 0.0f) { char pulse[12]; Display::formatPulse(pulseFromDuty(s.badFrequency, s.badDuty), pulse, sizeof(pulse), true); snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, pulse); } else { snprintf(two, sizeof(two), "%s", uiFailName(s.reason)); } display_.show(one, two, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_), roleCorner(static_cast(settings_.role))); return; } if (!s.periods || !s.periodSum) { display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_)); return; } const float measuredHz = static_cast(receiver_.tickHz()) * s.periods / s.periodSum; const uint32_t measuredPulseNs = static_cast(lround( static_cast(s.activeSum) * 1000000000.0 / (static_cast(receiver_.pulseTickHz()) * s.periods))); formatMeasured(measuredHz, measuredPulseNs, two, sizeof(two)); display_.show(one, two, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_)); } void App::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] = "---", errorPulse[12] = "---"; if (s.errorTriggerValid) { const uint64_t elapsedNs = (static_cast(s.errorTriggerTicks) * 1000000000ULL + driverTest_.tickHz() / 2U) / driverTest_.tickHz(); formatElapsedNs(elapsedNs, elapsed, sizeof(elapsed)); } if (s.errorPulseValid) { const uint64_t errorPulseNs = (static_cast(s.errorPulseTicks) * 1000000000ULL + driverTest_.tickHz() / 2U) / driverTest_.tickHz(); formatElapsedNs(errorPulseNs, errorPulse, sizeof(errorPulse)); } if (s.errorPulseValid) snprintf(two, sizeof(two), "T:%s P:%s", elapsed, errorPulse); else snprintf(two, sizeof(two), "T:%s", elapsed); } else { formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one)); snprintf(two, sizeof(two), UiText::DRIVER_MEASUREMENT_FORMAT, delay, response); } const bool finished = testPassed || s.reason != FailReason::NONE; const uint8_t progressSteps = driverTest_.progressSteps(); display_.show(one, two, finished ? 0U : overallProgress( stageIndex_, driverTest_.progressStep(), progressSteps), finished ? 0U : overallProgressTotal(stageCount_, progressSteps), 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; char one[64], two[64]; formatTestTarget(packet.requestedHz, packet.requestedPulseNs, one, sizeof(one)); if (reason == FailReason::NONE) { if (packet.measuredHzX10) { formatMeasured(packet.measuredHzX10 / 10.0f, packet.measuredPulseNs, two, sizeof(two)); } else snprintf(two, sizeof(two), "%s", UiText::NO_MEASUREMENT); } else if (reason == FailReason::PERIOD_OUT && packet.measuredHzX10) { char frequency[12]; Display::formatFrequency(packet.measuredHzX10 / 10.0f, frequency, sizeof(frequency)); snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency); } else if (reason == FailReason::DUTY_OUT && packet.measuredPulseNs) { char pulse[12]; Display::formatPulse(packet.measuredPulseNs, pulse, sizeof(pulse), true); snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, pulse); } else { snprintf(two, sizeof(two), "%s", uiFailName(reason)); } if (reason != FailReason::NONE) formatFailure(reason, packet.requestedHz, packet.requestedPulseNs, one, sizeof(one)); display_.show(one, two, overallProgress(stageIndex_, packet.progressStep), overallProgressTotal(stageCount_), reason == FailReason::NONE ? nullptr : roleCorner(static_cast(settings_.role))); } void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) const { packet.reason = static_cast(stats.reason); packet.periods = stats.periods; if (!stats.periods || !stats.periodSum) return; const bool badPeriod = (stats.reason == FailReason::PERIOD_OUT || stats.reason == FailReason::DUTY_OUT) && stats.badFrequency > 0.0f; const float measuredHz = badPeriod ? stats.badFrequency : static_cast(receiver_.tickHz()) * stats.periods / stats.periodSum; packet.measuredHzX10 = static_cast(lroundf(measuredHz * 10.0f)); packet.measuredPulseNs = badPeriod ? pulseFromDuty(measuredHz, stats.badDuty) : static_cast(lround(static_cast(stats.activeSum) * 1000000000.0 / (static_cast(receiver_.pulseTickHz()) * stats.periods))); } void App::showStageProgress() { 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), overallProgressTotal(stageCount_)); }