Глобальная переделка. тест сделан по длине импульса и заданной частоте шим, а не меандру

This commit is contained in:
2026-08-12 18:10:05 +03:00
parent 1db89fca79
commit a17e8962b4
19 changed files with 958 additions and 642 deletions

View File

@@ -8,10 +8,13 @@
#include <esp_system.h>
#include <esp32-hal-cpu.h>
#include <driver/gpio.h>
#include <Wire.h>
#include <math.h>
#include <string.h>
namespace {
const char *uiFailName(FailReason reason);
const char *appStateName(AppState state) {
static const char *names[] = {"IDLE", "MENU", "SOLO_MEASURE", "MASTER_DISCOVER",
"MASTER_WAIT_READY", "MASTER_WAIT_RESULT", "MASTER_FINALIZE", "SLAVE_READY", "SLAVE_WAIT_START",
@@ -26,9 +29,40 @@ const char *buttonEventName(ButtonEvent event) {
return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN";
}
void formatErrorDuty(float duty, char *out, size_t size) {
if (fabsf(duty - roundf(duty)) < 0.05f) snprintf(out, size, "%.0f%%", duty);
else snprintf(out, size, "%.1f%%", duty);
uint32_t pulseFromDuty(float hz, float dutyPct) {
return hz > 0.0f ? static_cast<uint32_t>(lroundf(dutyPct * 10000000.0f / hz)) : 0U;
}
float dutyFromPulse(uint32_t hz, uint32_t pulseNs) {
return static_cast<float>(static_cast<double>(hz) * pulseNs / 10000000.0);
}
void formatTarget(uint32_t hz, uint32_t pulseNs, char *out, size_t size) {
char frequency[16], pulse[12];
Display::formatPwmFrequency(hz, frequency, sizeof(frequency));
Display::formatPulse(pulseNs, pulse, sizeof(pulse));
snprintf(out, size, UiText::TEST_FORMAT, frequency, pulse);
}
void formatMeasured(float hz, uint32_t pulseNs, char *out, size_t size) {
char frequency[12], pulse[12];
Display::formatFrequency(hz, frequency, sizeof(frequency));
Display::formatPulse(pulseNs, pulse, sizeof(pulse), true);
snprintf(out, size, "F:%s, P:%s", frequency, pulse);
}
void formatTestTarget(uint32_t hz, uint32_t pulseNs, char *out, size_t size) {
char target[32];
formatTarget(hz, pulseNs, target, sizeof(target));
snprintf(out, size, "TEST: %s", target);
}
void formatFailure(FailReason reason, uint32_t hz, uint32_t pulseNs,
char *out, size_t size) {
(void)reason;
char target[32];
formatTarget(hz, pulseNs, target, sizeof(target));
snprintf(out, size, "FAIL AT %s", target);
}
size_t utf8CharacterCount(const char *text) {
@@ -81,12 +115,94 @@ uint32_t overallProgressTotal(uint32_t stageCount) {
uint32_t stageWallTimeMs(uint32_t testTimeMs, uint32_t frequencyHz) {
return static_cast<uint32_t>((nominalStageUs(frequencyHz, testTimeMs, PWM_SETTLE_CYCLES) + 999ULL) / 1000ULL);
}
uint8_t lastValidMaxPulseIndex(uint32_t frequencyHz) {
uint8_t last = static_cast<uint8_t>(countOf(MAX_PULSE_OPTIONS_NS) - 1U);
while (last && static_cast<uint64_t>(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<uint8_t>(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<uint32_t>(
(static_cast<uint64_t>(pulseNs) * MCPWM_RESOLUTION_HZ + 500000000ULL) / 1000000000ULL);
if (!activeTicks || activeTicks >= periodTicks) return false;
actualHz = hz;
actualPulseNs = static_cast<uint32_t>(
(static_cast<uint64_t>(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<uint8_t>(value + 1U);
return value <= first ? last : static_cast<uint8_t>(value - 1U);
}
}
App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), measurement_(receiver_) {}
void App::begin() {
Serial.begin(SERIAL_BAUD);
#if ARDUINO_USB_CDC_ON_BOOT
Serial.setTxTimeoutMs(SERIAL_TX_TIMEOUT_MS);
#endif
Log::printf("BOOT", "firmware start, Serial=%lu baud", SERIAL_BAUD);
startButton_.begin(); modeButton_.begin(); pwm_.begin();
bootCheckStartedMs_ = millis();
@@ -103,11 +219,12 @@ void App::finishInitialization(bool factoryReset) {
} else if (!store_.load(settings_)) {
store_.save(settings_); Log::event("BOOT", "NVS invalid/missing: defaults loaded");
}
sanitizeRange();
params_ = store_.params(settings_);
if (!display_.begin()) Log::event("BOOT", "OLED unavailable; Serial UI remains fully operational");
initialized_ = true;
if (!receiver_.begin()) { Log::event("BOOT", "FATAL: capture peripheral init failed"); finish(false, FailReason::UNSUPPORTED); return; }
Log::printf("BOOT", "capture initialized: %s", receiver_.highRateBackend() ? "RMT DMA" : "RMT ping-pong");
Log::printf("BOOT", "capture initialized: %s", receiver_.highRateBackend() ? "MCPWM 80MHz" : "GPIO cycle counter");
lastUserActivityMs_ = millis();
setActivePerformance(false);
printConfiguration();
@@ -166,7 +283,7 @@ void App::update() {
}
if (state_ == AppState::MENU) {
if (modeEvent == ButtonEvent::SHORT) {
menuItem_ = (menuItem_ + 1U) % 4U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu();
menuItem_ = (menuItem_ + 1U) % 5U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu();
}
else if (modeEvent == ButtonEvent::LONG) {
sanitizeRange(); const bool saved = store_.save(settings_); params_ = store_.params(settings_);
@@ -179,20 +296,25 @@ void App::update() {
return;
}
if (state_ == AppState::SOLO_MEASURE) {
if (static_cast<int32_t>(now - localMeasurementDeadlineMs_) >= 0) {
Log::event("MEASURE", "local stage watchdog expired");
measurement_.forceFail(FailReason::LOST_EDGE);
}
const MeasureState ms = measurement_.update();
if (ms == MeasureState::FAIL) {
pwm_.stop();
printStageStats(measurement_.stats(), actual_.actualHz);
showStageResult(measurement_.stats());
finish(false, measurement_.reason(), true);
}
else if (ms == MeasureState::PASS) {
pwm_.stop();
printStageStats(measurement_.stats(), actual_.actualHz);
showStageResult(measurement_.stats());
stagePassed();
} else if (ms == MeasureState::STEP_READY) {
} else if (measurement_.takeProgressUpdate()) {
StageStats live = {};
if (measurement_.statsSnapshot(live)) showStageResult(live);
measurement_.continueAfterDisplay();
}
} else if (state_ == AppState::MASTER_DISCOVER || state_ == AppState::MASTER_WAIT_READY ||
state_ == AppState::MASTER_WAIT_RESULT || state_ == AppState::MASTER_FINALIZE) {
@@ -212,8 +334,21 @@ void App::showIdle() {
}
void App::sanitizeRange() {
settings_.startIndex %= countOf(START_FREQ_OPTIONS_HZ);
settings_.endIndex %= countOf(END_FREQ_OPTIONS_HZ);
if (settings_.role > static_cast<uint8_t>(Role::SLAVE))
settings_.role = static_cast<uint8_t>(Role::SOLO);
settings_.frequencyIndex %= countOf(PWM_FREQUENCY_OPTIONS_HZ);
settings_.maxPulseIndex %= countOf(MAX_PULSE_OPTIONS_NS);
settings_.minPulseIndex %= countOf(MIN_PULSE_OPTIONS_NS);
settings_.accuracyIndex %= countOf(ACCURACY_OPTIONS_PCT);
settings_.timeIndex %= countOf(TEST_TIME_OPTIONS_MS);
const uint32_t hz = PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex];
const uint8_t lastValid = lastValidMaxPulseIndex(hz);
if (settings_.maxPulseIndex > lastValid) settings_.maxPulseIndex = lastValid;
const uint8_t lastMin = lastMinPulseIndexAtMost(MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]);
if (settings_.minPulseIndex > lastMin) settings_.minPulseIndex = lastMin;
const uint8_t firstMin = firstMinPulseIndexAtLeast(
minimumPulseForAccuracy(hz, ACCURACY_OPTIONS_PCT[settings_.accuracyIndex]), lastMin);
if (settings_.minPulseIndex < firstMin) settings_.minPulseIndex = firstMin;
}
void App::serviceRxPinStateLog() {
@@ -231,17 +366,36 @@ void App::serviceRxPinStateLog() {
void App::changeMenu(int d) {
sanitizeRange();
uint8_t *value = nullptr; size_t count = 0;
switch (menuItem_) {
case 0: value = &settings_.startIndex; count = countOf(START_FREQ_OPTIONS_HZ); break;
case 1: value = &settings_.endIndex; count = countOf(END_FREQ_OPTIONS_HZ); break;
case 2: value = &settings_.accuracyIndex; count = countOf(ACCURACY_OPTIONS_PCT); break;
case 3: value = &settings_.timeIndex; count = countOf(TEST_TIME_OPTIONS_MS); break;
default: return;
if (menuItem_ == 1) {
const uint8_t last = lastValidMaxPulseIndex(
PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex]);
const uint8_t first = firstMaxPulseIndexAtLeast(
MIN_PULSE_OPTIONS_NS[settings_.minPulseIndex], last);
settings_.maxPulseIndex = cycleIndex(settings_.maxPulseIndex,
first, last, d);
} else if (menuItem_ == 2) {
const uint8_t last = lastMinPulseIndexAtMost(
MAX_PULSE_OPTIONS_NS[settings_.maxPulseIndex]);
const uint8_t first = firstMinPulseIndexAtLeast(
minimumPulseForAccuracy(PWM_FREQUENCY_OPTIONS_HZ[settings_.frequencyIndex],
ACCURACY_OPTIONS_PCT[settings_.accuracyIndex]), last);
settings_.minPulseIndex = cycleIndex(settings_.minPulseIndex,
first, last, d);
} else {
uint8_t *value = nullptr; size_t count = 0;
switch (menuItem_) {
case 0: value = &settings_.frequencyIndex; count = countOf(PWM_FREQUENCY_OPTIONS_HZ); break;
case 3: value = &settings_.accuracyIndex; count = countOf(ACCURACY_OPTIONS_PCT); break;
case 4: value = &settings_.timeIndex; count = countOf(TEST_TIME_OPTIONS_MS); break;
default: return;
}
*value = cycleIndex(*value, 0, static_cast<uint8_t>(count - 1U), d);
}
*value = static_cast<uint8_t>((*value + count + d) % count);
Log::printf("ACTION", "menu item=%u changed direction=%+d new-index=%u", menuItem_, d, *value);
sanitizeRange(); params_ = store_.params(settings_); showMenu();
sanitizeRange(); params_ = store_.params(settings_);
Log::printf("ACTION", "menu item=%u changed direction=%+d frequency=%u max-pulse=%u min-pulse=%u accuracy=%u time=%u",
menuItem_, d, settings_.frequencyIndex, settings_.maxPulseIndex,
settings_.minPulseIndex, settings_.accuracyIndex, settings_.timeIndex);
showMenu();
}
void App::showMenu() {
@@ -250,23 +404,26 @@ void App::showMenu() {
Display::formatDuration(actualNominalTotalUs(), all, sizeof(all));
switch (menuItem_) {
case 0:
Display::formatTestFrequency(params_.startHz, value, sizeof(value));
strncat(value, UiText::FREQUENCY_UNIT, sizeof(value) - strlen(value) - 1U);
label = UiText::MENU_START_FREQUENCY;
Display::formatPwmFrequency(params_.frequencyHz, value, sizeof(value));
label = UiText::MENU_FREQUENCY;
break;
case 1:
Display::formatTestFrequency(params_.endHz, value, sizeof(value));
strncat(value, UiText::FREQUENCY_UNIT, sizeof(value) - strlen(value) - 1U);
label = UiText::MENU_END_FREQUENCY;
Display::formatPulse(params_.maxPulseNs, value, sizeof(value));
label = UiText::MENU_MAX_PULSE;
break;
case 2:
Display::formatPulse(params_.minPulseNs, value, sizeof(value));
label = UiText::MENU_MIN_PULSE;
break;
case 3:
snprintf(value, sizeof(value), "+/-%g%%", params_.accuracyPct);
label = UiText::MENU_ACCURACY;
break;
default:
case 4:
snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f);
label = UiText::MENU_TEST_TIME;
break;
default: return;
}
formatMenuLine(label, value, one, sizeof(one));
formatMenuLine(UiText::MENU_TOTAL_TIME, all, total, sizeof(total));
@@ -277,18 +434,17 @@ void App::startTest() {
leaveIdlePowerSave();
pwm_.stop();
setActivePerformance(true);
params_ = store_.params(settings_); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz);
stageIndex_ = 0; requestedHz_ = 0; pendingReason_ = FailReason::NONE;
params_ = store_.params(settings_); stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs);
stageIndex_ = 0; requestedHz_ = params_.frequencyHz; requestedPulseNs_ = 0; pendingReason_ = FailReason::NONE;
havePeer_ = false; lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0;
if (!stageCount_) { finish(false, FailReason::UNSUPPORTED); return; }
Log::printf("TEST", "starting role=%s stages=%lu", roleName(static_cast<Role>(settings_.role)), stageCount_);
if (SERIAL_MINIMAL_LOG) {
char startText[12], endText[12];
Display::formatFrequency(params_.startHz, startText, sizeof(startText));
Display::formatFrequency(params_.endHz, endText, sizeof(endText));
Log::printf("CONFIG", "mode=%s range=%s..%s adjacent accuracy=%.2f%% time=%lums duty=%u%% stages=%lu",
roleName(static_cast<Role>(settings_.role)), startText, endText,
params_.accuracyPct, params_.testTimeMs, params_.dutyPct, stageCount_);
Log::printf("CONFIG", "mode=%s frequency=%luHz pulse=%lu..%luns accuracy=%.2f%% time=%lums TX=%s RX=AUTO stages=%lu",
roleName(static_cast<Role>(settings_.role)), params_.frequencyHz,
params_.maxPulseNs, params_.minPulseNs, params_.accuracyPct, params_.testTimeMs,
PWM_ACTIVE_LEVEL == HIGH ? "HIGH" : "LOW",
stageCount_);
}
printConfiguration();
const Role role = static_cast<Role>(settings_.role);
@@ -305,8 +461,8 @@ bool App::armSlave(bool preserveDisplay) {
pwm_.stop();
lastUserActivityMs_ = millis();
params_ = store_.params(settings_);
stageIndex_ = 0; stageCount_ = frequencyPointCount(params_.startHz, params_.endHz);
requestedHz_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false;
stageIndex_ = 0; stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs);
requestedHz_ = params_.frequencyHz; requestedPulseNs_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false;
lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0; retries_ = 0; slaveRearmAtMs_ = 0;
if (!radio_.begin()) {
state_ = AppState::FINISHED; pendingReason_ = FailReason::LINK_LOST;
@@ -323,29 +479,40 @@ bool App::armSlave(bool preserveDisplay) {
}
bool App::prepareStage(bool showProgress) {
requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_);
requestedHz_ = params_.frequencyHz;
requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_);
actual_ = {};
const uint32_t maxHz = TARGET_IS_C3 ? C3_STRICT_MAX_HZ :
(receiver_.highRateBackend() ? S3_STRICT_MAX_HZ : C3_STRICT_MAX_HZ);
if (requestedHz_ > maxHz) { finish(false, FailReason::UNSUPPORTED); return false; }
Log::printf("PWM", "starting GPIO=%u requested=%luHz duty=%u%%", GPIO_PWM, requestedHz_, params_.dutyPct);
if (!pwm_.start(requestedHz_, params_.dutyPct, actual_)) {
Log::printf("PWM", "START FAILED GPIO=%u requested=%luHz; LEDC attach/write/read failed",
GPIO_PWM, requestedHz_);
Log::printf("PWM", "starting GPIO=%u requested=%luHz pulse=%luns", GPIO_PWM, requestedHz_, requestedPulseNs_);
if (!pwm_.start(requestedHz_, requestedPulseNs_, actual_)) {
Log::printf("PWM", "START FAILED GPIO=%u requested=%luHz pulse=%luns; PWM setup failed",
GPIO_PWM, requestedHz_, requestedPulseNs_);
finish(false, FailReason::RESOLUTION); return false;
}
if (!periodWithin(actual_.actualHz, requestedHz_, params_.accuracyPct) ||
!periodWithin(actual_.actualPulseNs, requestedPulseNs_, params_.accuracyPct)) {
Log::printf("PWM", "requested point cannot be generated within tolerance: requested=%luHz/%luns actual=%luHz/%luns tolerance=%.2f%%",
requestedHz_, requestedPulseNs_, actual_.actualHz, actual_.actualPulseNs,
params_.accuracyPct);
finish(false, FailReason::RESOLUTION); return false;
}
const uint32_t plannedRxHz = receiver_.plannedTickHz(actual_.actualHz, actual_.actualDutyPct);
const uint32_t plannedPulseRxHz = receiver_.plannedPulseTickHz(
actual_.actualHz, actual_.actualDutyPct);
const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct,
plannedRxHz, actual_.bits,
plannedRxHz, plannedPulseRxHz, actual_.bits,
MEASUREMENT_AVERAGING_PERIODS);
if (resolution != FailReason::NONE) {
Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u RXclock=%luHz tolerance=%.3f%%",
actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz,
Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u period-capture=%luHz pulse-capture=%luHz tolerance=%.3f%%",
actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz, plannedPulseRxHz,
effectiveTolerancePct(params_.accuracyPct));
finish(false, resolution); return false;
}
Log::printf("PWM", "stage=%lu/%lu requested=%luHz actual=%luHz duty=%.2f%% bits=%u STARTED",
stageIndex_ + 1, stageCount_, requestedHz_, actual_.actualHz, actual_.actualDutyPct, actual_.bits);
Log::printf("PWM", "stage=%lu/%lu requested=%luHz/%luns actual=%luHz/%luns duty=%.3f%% bits=%u STARTED",
stageIndex_ + 1, stageCount_, requestedHz_, requestedPulseNs_, actual_.actualHz,
actual_.actualPulseNs, actual_.actualDutyPct, actual_.bits);
if (showProgress) showStageProgress();
if (static_cast<Role>(settings_.role) == Role::SOLO && !startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) {
finish(false, FailReason::UNSUPPORTED); return false;
@@ -354,23 +521,33 @@ bool App::prepareStage(bool showProgress) {
}
bool App::startLocalMeasurement(float hz, float duty) {
Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% RX=%luHz settle=%u cycles window=%lums average=%u periods; per-pulse logging suspended",
Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% period-capture=%luHz pulse-capture=%luHz settle=%u cycles window=%lums; every pulse validated",
hz, duty, effectiveTolerancePct(params_.accuracyPct), receiver_.plannedTickHz(static_cast<uint32_t>(hz + 0.5f), duty),
PWM_SETTLE_CYCLES, params_.testTimeMs, MEASUREMENT_AVERAGING_PERIODS);
receiver_.plannedPulseTickHz(static_cast<uint32_t>(hz + 0.5f), duty),
PWM_SETTLE_CYCLES, params_.testTimeMs);
const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs,
MEASUREMENT_AVERAGING_PERIODS, PWM_SETTLE_CYCLES);
Log::printf("MEASURE", "receiver start %s, RMT chunk=%u symbols", ok ? "OK" : "FAILED",
receiver_.receiveChunkSymbols());
const uint32_t nominalMs = stageWallTimeMs(params_.testTimeMs,
static_cast<uint32_t>(hz + 0.5f));
const uint64_t watchdogMs = static_cast<uint64_t>(nominalMs) * 2ULL + 2000ULL;
localMeasurementDeadlineMs_ = millis() + static_cast<uint32_t>(
watchdogMs > UINT32_MAX ? UINT32_MAX : watchdogMs);
Log::printf("MEASURE", "receiver start %s, continuous edge capture", ok ? "OK" : "FAILED");
return ok;
}
void App::stagePassed() {
Log::printf("TEST", "stage %lu/%lu PASS; PWM stopping", stageIndex_ + 1, stageCount_);
pwm_.stop();
// With PWM already quiet it is safe to stop capture before clearing its
// queue for the next pulse width. Never reset a FreeRTOS queue concurrently
// with the capture ISR.
if (static_cast<Role>(settings_.role) == Role::SOLO) receiver_.stop();
if (++stageIndex_ >= stageCount_) { finish(true, FailReason::NONE); return; }
if (static_cast<Role>(settings_.role) == Role::SOLO) { if (prepareStage()) state_ = AppState::SOLO_MEASURE; }
else if (static_cast<Role>(settings_.role) == Role::MASTER) {
requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_);
requestedHz_ = params_.frequencyHz;
requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_);
actual_ = {};
stageStartConfirmed_ = false;
pendingPacket_ = makePacket(MessageType::PREPARE); sendCurrent(MessageType::PREPARE);
@@ -380,7 +557,8 @@ void App::stagePassed() {
void App::startMasterDiscovery() {
session_ = esp_random(); if (!session_) session_ = 1;
sequence_ = 1; stageIndex_ = 0; requestedHz_ = 0; havePeer_ = false; radio_.flush();
sequence_ = 1; stageIndex_ = 0; requestedHz_ = params_.frequencyHz;
requestedPulseNs_ = 0; havePeer_ = false; radio_.flush();
opticalWakeActive_ = true;
lastOpticalWakeToggleMs_ = millis();
pwm_.active();
@@ -394,9 +572,8 @@ ProtocolPacket App::makePacket(MessageType type) const {
ProtocolPacket p = {};
p.type = static_cast<uint8_t>(type); p.session = session_; p.stage = stageIndex_;
p.stageCount = static_cast<uint16_t>(stageCount_); p.sequence = sequence_;
p.requestedHz = requestedHz_; p.actualHz = actual_.actualHz;
const float packetDuty = actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct;
p.actualDutyX100 = static_cast<uint16_t>(packetDuty * 100.0f + 0.5f);
p.requestedHz = requestedHz_; p.requestedPulseNs = requestedPulseNs_;
p.actualHz = actual_.actualHz; p.actualPulseNs = actual_.actualPulseNs;
p.testTimeMs = params_.testTimeMs;
p.accuracyX100 = static_cast<uint16_t>(params_.accuracyPct * 100.0f + 0.5f); p.settleCycles = PWM_SETTLE_CYCLES;
return p;
@@ -458,7 +635,8 @@ void App::handleRadio() {
opticalWakeActive_ = false;
pwm_.stop();
memcpy(peer_, r.mac, 6); havePeer_ = true; lastPeerSeenMs_ = lastHeartbeatMs_ = millis();
requestedHz_ = frequencyAt(params_.startHz, params_.endHz, stageIndex_);
requestedHz_ = params_.frequencyHz;
requestedPulseNs_ = pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, stageIndex_);
sendCurrent(MessageType::PREPARE); state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS;
char mac[20]; Radio::macText(peer_, mac, sizeof(mac)); Log::printf("ESP-NOW", "Slave selected %s", mac); continue;
}
@@ -484,7 +662,8 @@ void App::handleRadio() {
sequence_ = r.packet.sequence;
state_ = AppState::SLAVE_WAIT_START;
params_.testTimeMs = r.packet.testTimeMs;
params_.accuracyPct = r.packet.accuracyX100 / 100.0f; requestedHz_ = r.packet.requestedHz;
params_.accuracyPct = r.packet.accuracyX100 / 100.0f;
requestedHz_ = r.packet.requestedHz; requestedPulseNs_ = r.packet.requestedPulseNs;
stageCount_ = r.packet.stageCount;
actual_ = {};
ProtocolPacket ready = makePacket(MessageType::READY);
@@ -498,8 +677,10 @@ void App::handleRadio() {
r.packet.reason <= static_cast<uint8_t>(FailReason::ABORTED)
? static_cast<FailReason>(r.packet.reason) : FailReason::ABORTED;
if (r.packet.requestedHz) requestedHz_ = r.packet.requestedHz;
if (r.packet.requestedPulseNs) requestedPulseNs_ = r.packet.requestedPulseNs;
actual_.actualHz = r.packet.actualHz ? r.packet.actualHz : requestedHz_;
actual_.actualDutyPct = r.packet.actualDutyX100 ? r.packet.actualDutyX100 / 100.0f : params_.dutyPct;
actual_.actualPulseNs = r.packet.actualPulseNs ? r.packet.actualPulseNs : requestedPulseNs_;
actual_.actualDutyPct = dutyFromPulse(actual_.actualHz, actual_.actualPulseNs);
measurement_.abort(); finish(false, reason); continue;
}
if (state_ == AppState::MASTER_WAIT_READY && type == MessageType::READY) {
@@ -535,7 +716,8 @@ void App::handleRadio() {
sendLinked(pendingPacket_);
} else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) {
sequence_ = r.packet.sequence;
actual_.actualHz = r.packet.actualHz; actual_.actualDutyPct = r.packet.actualDutyX100 / 100.0f;
actual_.actualHz = r.packet.actualHz; actual_.actualPulseNs = r.packet.actualPulseNs;
actual_.actualDutyPct = dutyFromPulse(actual_.actualHz, actual_.actualPulseNs);
if (!startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); continue; }
showStageProgress();
state_ = AppState::SLAVE_MEASURE;
@@ -549,6 +731,10 @@ void App::handleRadio() {
started.sequence = r.packet.sequence; sendLinked(started);
} else if (state_ == AppState::SLAVE_WAIT_ACK && type == MessageType::ACK && r.packet.sequence == pendingPacket_.sequence) {
if (pendingPacket_.passed) {
// Master sends ACK only after stopping its PWM. Disable capture for
// every completed stage, so the next start can clear its queue without
// racing the ISR (not only after the final stage).
receiver_.stop();
if (r.packet.passed) {
radio_.end(); pendingReason_ = FailReason::NONE;
if (armSlave(true)) display_.show(UiText::PASS_WORD, UiText::WAIT_MASTER);
@@ -601,8 +787,12 @@ void App::updateSlave() {
updateHeartbeat();
if (state_ == AppState::FINISHED) return;
if (state_ == AppState::SLAVE_MEASURE) {
if (static_cast<int32_t>(millis() - localMeasurementDeadlineMs_) >= 0) {
Log::event("MEASURE", "Slave local stage watchdog expired");
measurement_.forceFail(FailReason::LOST_EDGE);
}
const MeasureState ms = measurement_.update();
if (ms == MeasureState::STEP_READY) {
if (measurement_.takeProgressUpdate()) {
StageStats live = {};
if (measurement_.statsSnapshot(live)) {
ProtocolPacket progress = makePacket(MessageType::PROGRESS);
@@ -611,7 +801,6 @@ void App::updateSlave() {
progress.sequence = sequence_; sendLinked(progress);
showStageResult(live);
}
measurement_.continueAfterDisplay();
return;
}
if (ms != MeasureState::PASS && ms != MeasureState::FAIL) {
@@ -641,7 +830,7 @@ void App::sendAbort(FailReason reason) {
++sequence_;
ProtocolPacket packet = makePacket(MessageType::ABORT);
packet.reason = static_cast<uint8_t>(reason);
if (!packet.actualDutyX100) packet.actualDutyX100 = params_.dutyPct * 100U;
if (!packet.actualPulseNs) packet.actualPulseNs = requestedPulseNs_;
sendLinked(packet);
}
@@ -675,10 +864,9 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) {
setActivePerformance(false);
lastUserActivityMs_ = millis();
if (slaveLinkLost) {
char target[12], one[64];
Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, target, sizeof(target));
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target,
actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct);
char target[32], one[64];
formatTarget(requestedHz_, requestedPulseNs_, target, sizeof(target));
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target);
display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_,
roleCorner(Role::SLAVE));
armSlave(true);
@@ -700,10 +888,9 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) {
display_.show(one, role == Role::SLAVE ? UiText::WAIT_MASTER : UiText::START_AGAIN);
}
else if (requestedHz_) {
char frequency[12];
Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, frequency, sizeof(frequency));
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, frequency,
actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct);
char target[32];
formatTarget(requestedHz_, requestedPulseNs_, target, sizeof(target));
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target);
display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_,
roleCorner(static_cast<Role>(settings_.role)));
} else {
@@ -713,7 +900,10 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) {
}
bool App::idlePowerSaveAllowed() const {
return initialized_ && (state_ == AppState::IDLE || state_ == AppState::MENU ||
// Never enter blocking light sleep while the settings screen is open. A
// wake-up press is deliberately consumed by the button state machine, which
// is useful in IDLE but makes menu navigation appear frozen.
return initialized_ && (state_ == AppState::IDLE ||
state_ == AppState::FINISHED || state_ == AppState::SLAVE_READY);
}
@@ -767,7 +957,9 @@ void App::serviceIdlePowerSave() {
idleSleepRadioStopped_ = true;
}
display_.setPower(false);
Log::event("POWER", "idle timeout; OLED off and light sleep started");
Log::event("POWER", "idle timeout; preparing light sleep");
Serial.flush();
delay(2);
}
gpio_wakeup_enable(static_cast<gpio_num_t>(GPIO_BUTTON_START),
@@ -775,10 +967,6 @@ void App::serviceIdlePowerSave() {
gpio_wakeup_enable(static_cast<gpio_num_t>(GPIO_BUTTON_MODE),
BUTTON_ACTIVE_LEVEL == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL);
if (static_cast<Role>(settings_.role) == Role::SLAVE) {
// Light-sleep GPIO wake is level-triggered in ESP-IDF. Arm the level
// opposite to the one sampled immediately before sleep, which makes a
// transition (either edge) necessary and prevents a steady RX level from
// waking Slave continuously.
const bool currentRxHigh = gpio_get_level(static_cast<gpio_num_t>(GPIO_RX)) != 0;
gpio_wakeup_enable(static_cast<gpio_num_t>(GPIO_RX),
currentRxHigh ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL);
@@ -786,25 +974,46 @@ void App::serviceIdlePowerSave() {
esp_sleep_enable_gpio_wakeup();
const esp_err_t result = esp_light_sleep_start();
if (result != ESP_OK) {
leaveIdlePowerSave(true);
delay(1);
return;
}
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO) {
const bool buttonWake = digitalRead(GPIO_BUTTON_START) == BUTTON_ACTIVE_LEVEL ||
digitalRead(GPIO_BUTTON_MODE) == BUTTON_ACTIVE_LEVEL;
if (buttonWake) {
// The wake-up press is deliberately consumed. Holding or releasing it
// must not later turn into a SHORT, LONG, or REPEAT event.
startButton_.suppressUntilRelease();
modeButton_.suppressUntilRelease();
leaveIdlePowerSave();
Log::event("POWER", "button wake consumed; next press will perform the action");
} else if (static_cast<Role>(settings_.role) == Role::SLAVE) {
leaveIdlePowerSave();
Log::event("POWER", "optical input woke Slave");
}
const esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
const bool buttonWake = digitalRead(GPIO_BUTTON_START) == BUTTON_ACTIVE_LEVEL ||
digitalRead(GPIO_BUTTON_MODE) == BUTTON_ACTIVE_LEVEL;
if (buttonWake) {
startButton_.suppressUntilRelease();
modeButton_.suppressUntilRelease();
}
// GPIO wake worked, so disarm all level sources before peripherals and the
// button state machines are brought back up.
gpio_wakeup_disable(static_cast<gpio_num_t>(GPIO_BUTTON_START));
gpio_wakeup_disable(static_cast<gpio_num_t>(GPIO_BUTTON_MODE));
gpio_wakeup_disable(static_cast<gpio_num_t>(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<unsigned>(cause), buttonWake ? "YES" : "NO");
if (buttonWake)
Log::event("POWER", "wake button consumed; next press will perform the action");
}
void App::printConfiguration() {
@@ -815,12 +1024,16 @@ void App::printConfiguration() {
Serial.printf("MAC=%02X:%02X:%02X:%02X:%02X:%02X\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
Serial.printf("GPIO PWM=%u RX=%u START=%u MODE=%u SDA=%u SCL=%u\n", GPIO_PWM, GPIO_RX,
GPIO_BUTTON_START, GPIO_BUTTON_MODE, GPIO_SDA, GPIO_SCL);
Serial.printf("Test %lu..%lu Hz (adjacent exact frequencies), accuracy %.2f%%, %lums, duty %u%%\n",
params_.startHz, params_.endHz, params_.accuracyPct, params_.testTimeMs, params_.dutyPct);
stageCount_ = frequencyPointCount(params_.startHz, params_.endHz);
Serial.printf("Frequencies (%lu): ", stageCount_);
for (uint32_t i = 0; i < stageCount_; ++i) Serial.printf("%lu%s", frequencyAt(params_.startHz, params_.endHz, i), i + 1 == stageCount_ ? "\n" : ",");
Serial.printf("ALL nominal: %llu us | RX=%s\n", actualNominalTotalUs(), receiver_.highRateBackend() ? "RMT DMA" : "RMT ping-pong");
Serial.printf("Test %lu Hz, pulse %lu..%lu ns, accuracy %.2f%%, %lums, RX AUTO\n",
params_.frequencyHz, params_.maxPulseNs, params_.minPulseNs,
params_.accuracyPct, params_.testTimeMs);
stageCount_ = pulseWidthPointCount(params_.maxPulseNs, params_.minPulseNs);
Serial.printf("Pulse widths descending (%lu): ", stageCount_);
for (uint32_t i = 0; i < stageCount_; ++i)
Serial.printf("%lu%s", pulseWidthAt(params_.maxPulseNs, params_.minPulseNs, i),
i + 1 == stageCount_ ? " ns\n" : ",");
Serial.printf("ALL nominal: %llu us | RX=%s\n", actualNominalTotalUs(),
receiver_.highRateBackend() ? "MCPWM 80MHz" : "GPIO cycle counter");
}
uint64_t App::actualNominalTotalUs() {
@@ -833,28 +1046,32 @@ uint64_t App::actualNominalTotalUs() {
void App::printStageStats(const StageStats &s, uint32_t hz) {
if (!s.periods) return;
const float measuredHz = static_cast<float>(receiver_.tickHz()) * s.periods / s.periodSum;
const float measuredDuty = 100.0f * s.activeSum / s.periodSum;
const uint32_t measuredPulseNs = static_cast<uint32_t>(lround(
static_cast<double>(s.activeSum) * 1000000000.0 /
(static_cast<uint64_t>(receiver_.pulseTickHz()) * s.periods)));
char requestedText[12], measuredText[12];
Display::formatFrequency(hz, requestedText, sizeof(requestedText));
Display::formatFrequency(measuredHz, measuredText, sizeof(measuredText));
const char *status = s.reason == FailReason::NONE ? "PASS" : "FAIL";
Log::printf("RESULT", "%s %s periods=%lu measured=%s duty=%.2f%% skipped=%lu%s%s",
requestedText, status, s.periods, measuredText, measuredDuty, s.droppedItems,
Log::printf("RESULT", "%s/%luns %s periods=%lu measured=%s/%luns skipped=%lu%s%s",
requestedText, requestedPulseNs_, status, s.periods, measuredText, measuredPulseNs, s.droppedItems,
s.reason == FailReason::NONE ? "" : " reason=", s.reason == FailReason::NONE ? "" : failName(s.reason));
}
void App::showStageResult(const StageStats &s) {
char one[64], two[64];
char target[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target));
formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one));
if (s.reason != FailReason::NONE) {
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, actual_.actualDutyPct);
formatFailure(s.reason, requestedHz_, requestedPulseNs_, one, sizeof(one));
if (s.reason == FailReason::PERIOD_OUT && s.badFrequency > 0.0f) {
char frequency[12];
Display::formatTestFrequency(static_cast<uint32_t>(lroundf(s.badFrequency)), frequency, sizeof(frequency));
Display::formatFrequency(s.badFrequency, frequency, sizeof(frequency));
snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency);
} else if (s.reason == FailReason::DUTY_OUT && s.badFrequency > 0.0f) {
char duty[10]; formatErrorDuty(s.badDuty, duty, sizeof(duty));
snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty);
char pulse[12];
Display::formatPulse(pulseFromDuty(s.badFrequency, s.badDuty), pulse,
sizeof(pulse), true);
snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, pulse);
} else {
snprintf(two, sizeof(two), "%s", uiFailName(s.reason));
}
@@ -862,17 +1079,16 @@ void App::showStageResult(const StageStats &s) {
overallProgressTotal(stageCount_), roleCorner(static_cast<Role>(settings_.role)));
return;
}
char stage[12]; snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_);
snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, actual_.actualDutyPct, stage);
if (!s.periods || !s.periodSum) {
display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, measurement_.progressStep()),
overallProgressTotal(stageCount_));
return;
}
const float measuredHz = static_cast<float>(receiver_.tickHz()) * s.periods / s.periodSum;
const float measuredDuty = 100.0f * s.activeSum / s.periodSum;
char frequency[12]; Display::formatFrequency(measuredHz, frequency, sizeof(frequency));
snprintf(two, sizeof(two), "F:%-8s D:%4.1f%%", frequency, measuredDuty);
const uint32_t measuredPulseNs = static_cast<uint32_t>(lround(
static_cast<double>(s.activeSum) * 1000000000.0 /
(static_cast<uint64_t>(receiver_.pulseTickHz()) * s.periods)));
formatMeasured(measuredHz, measuredPulseNs, two, sizeof(two));
display_.show(one, two, overallProgress(stageIndex_, measurement_.progressStep()),
overallProgressTotal(stageCount_));
}
@@ -880,31 +1096,25 @@ void App::showStageResult(const StageStats &s) {
void App::showRemoteResult(const ProtocolPacket &packet) {
const FailReason reason = packet.reason <= static_cast<uint8_t>(FailReason::ABORTED)
? static_cast<FailReason>(packet.reason) : FailReason::UNSUPPORTED;
char target[12], one[64], two[64];
Display::formatTestFrequency(packet.actualHz ? packet.actualHz : packet.requestedHz,
target, sizeof(target));
char one[64], two[64];
formatTestTarget(packet.requestedHz, packet.requestedPulseNs, one, sizeof(one));
if (reason == FailReason::NONE) {
char stage[12]; snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_);
snprintf(one, sizeof(one), UiText::TEST_FORMAT,
target, packet.actualDutyX100 / 100.0f, stage);
if (packet.measuredHzX10) {
char measured[12];
Display::formatFrequency(packet.measuredHzX10 / 10.0f, measured, sizeof(measured));
snprintf(two, sizeof(two), "F:%-8s D:%4.1f%%", measured, packet.measuredDutyX10 / 10.0f);
formatMeasured(packet.measuredHzX10 / 10.0f, packet.measuredPulseNs, two, sizeof(two));
} else snprintf(two, sizeof(two), "%s", UiText::NO_MEASUREMENT);
} else if (reason == FailReason::PERIOD_OUT && packet.measuredHzX10) {
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f);
char measured[12];
Display::formatTestFrequency((packet.measuredHzX10 + 5U) / 10U, measured, sizeof(measured));
snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, measured);
} else if (reason == FailReason::DUTY_OUT && packet.measuredDutyX10) {
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f);
char duty[10]; formatErrorDuty(packet.measuredDutyX10 / 10.0f, duty, sizeof(duty));
snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty);
char frequency[12];
Display::formatFrequency(packet.measuredHzX10 / 10.0f, frequency, sizeof(frequency));
snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency);
} else if (reason == FailReason::DUTY_OUT && packet.measuredPulseNs) {
char pulse[12];
Display::formatPulse(packet.measuredPulseNs, pulse, sizeof(pulse), true);
snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, pulse);
} else {
snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f);
snprintf(two, sizeof(two), "%s", uiFailName(reason));
}
if (reason != FailReason::NONE)
formatFailure(reason, packet.requestedHz, packet.requestedPulseNs, one, sizeof(one));
display_.show(one, two, overallProgress(stageIndex_, packet.progressStep),
overallProgressTotal(stageCount_), reason == FailReason::NONE ? nullptr :
roleCorner(static_cast<Role>(settings_.role)));
@@ -918,16 +1128,15 @@ void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) co
stats.badFrequency > 0.0f;
const float measuredHz = badPeriod ? stats.badFrequency :
static_cast<float>(receiver_.tickHz()) * stats.periods / stats.periodSum;
const float measuredDuty = badPeriod ? stats.badDuty : 100.0f * stats.activeSum / stats.periodSum;
packet.measuredHzX10 = static_cast<uint32_t>(lroundf(measuredHz * 10.0f));
packet.measuredDutyX10 = static_cast<uint16_t>(lroundf(measuredDuty * 10.0f));
packet.measuredPulseNs = badPeriod ? pulseFromDuty(measuredHz, stats.badDuty) :
static_cast<uint32_t>(lround(static_cast<double>(stats.activeSum) * 1000000000.0 /
(static_cast<uint64_t>(receiver_.pulseTickHz()) * stats.periods)));
}
void App::showStageProgress() {
char target[12], one[64], stage[12];
Display::formatTestFrequency(actual_.actualHz, target, sizeof(target));
snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_);
snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, actual_.actualDutyPct, stage);
char one[64];
formatTestTarget(requestedHz_, requestedPulseNs_, one, sizeof(one));
display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, 0),
overallProgressTotal(stageCount_));
}

View File

@@ -66,7 +66,7 @@ class App {
AppState state_ = AppState::IDLE;
uint8_t menuItem_ = 0;
uint32_t stageIndex_ = 0, stageCount_ = 0;
uint32_t requestedHz_ = 0;
uint32_t requestedHz_ = 0, requestedPulseNs_ = 0;
ActualPwm actual_ = {};
FailReason pendingReason_ = FailReason::NONE;
uint32_t session_ = 0;
@@ -74,6 +74,7 @@ class App {
uint8_t peer_[6] = {};
bool havePeer_ = false;
uint32_t deadlineMs_ = 0, lastSendMs_ = 0;
uint32_t localMeasurementDeadlineMs_ = 0;
uint32_t lastHeartbeatMs_ = 0, lastPeerSeenMs_ = 0;
uint8_t retries_ = 0;
ProtocolPacket pendingPacket_ = {};

View File

@@ -17,8 +17,8 @@
constexpr uint32_t PWM_OUTPUT_TEST_FREQUENCY_HZ = 1000;
constexpr uint32_t PWM_OUTPUT_TEST_SWEEP_PERIOD_MS = 2000;
constexpr uint32_t PWM_OUTPUT_TEST_UPDATE_MS = 10;
constexpr uint8_t PWM_OUTPUT_TEST_MIN_DUTY_PCT = 5;
constexpr uint8_t PWM_OUTPUT_TEST_MAX_DUTY_PCT = 95;
constexpr uint32_t PWM_OUTPUT_TEST_MIN_PULSE_NS = 1000;
constexpr uint32_t PWM_OUTPUT_TEST_MAX_PULSE_NS = 10000;
#if CONFIG_IDF_TARGET_ESP32C3
constexpr bool TARGET_IS_C3 = true;
@@ -66,17 +66,21 @@ constexpr uint8_t OLED_ROTATION = 0;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint8_t ESPNOW_WIFI_CHANNEL = 6;
constexpr uint32_t SERIAL_BAUD = 115200;
// Native USB CDC may keep a stale "connected" state after light sleep. Keep
// logging non-blocking so a missing host can never delay button polling.
constexpr uint32_t SERIAL_TX_TIMEOUT_MS = 2;
constexpr bool SERIAL_ACTION_LOG = true;
constexpr bool SERIAL_LOG_TIMESTAMPS = true;
constexpr bool SERIAL_MINIMAL_LOG = true;
#define BUTTON_ACTIVE_LEVEL LOW
// Raw GPIO_RX level that means the optical receiver is active.
#define RX_ACTIVE_LEVEL LOW
// PWM_SAFE_LEVEL must switch the optical transmitter fully off and is used
// during tests whenever PWM is stopped, and while the controller sleeps.
// PWM_ACTIVE_LEVEL intentionally keeps the transmitter active while the
// controller is awake and no test is in progress.
#define RX_ACTIVE_LEVEL HIGH
// PWM_ACTIVE_LEVEL is the electrical level of the active test pulse and is
// also used for the constant active output while awake outside a test. During
// the remainder of a running PWM period the output is !PWM_ACTIVE_LEVEL.
// PWM_SAFE_LEVEL is used only while PWM is stopped and during sleep; it is
// independent of the PWM inactive level and may equal PWM_ACTIVE_LEVEL.
#define PWM_SAFE_LEVEL HIGH
#define PWM_ACTIVE_LEVEL LOW
#define PWM_SETTLE_CYCLES 5U
@@ -99,14 +103,10 @@ constexpr uint32_t LINK_HEARTBEAT_TIMEOUT_MS = 2500;
constexpr uint32_t FINAL_ACK_RETRY_INTERVAL_MS = 50;
constexpr uint8_t FINAL_ACK_RETRIES = 2;
constexpr uint8_t NO_SIGNAL_TIMEOUT_PERIODS = 8;
constexpr uint16_t RMT_MIN_RECEIVE_SYMBOLS = 48;
constexpr uint16_t RMT_MAX_RECEIVE_SYMBOLS = 512;
constexpr uint32_t RMT_TARGET_CHUNK_US = 5000;
constexpr uint8_t RMT_QUEUE_BLOCKS = 8;
constexpr uint16_t PERIOD_BATCH_SIZE = 128;
// Frequency and duty are validated only by their averages over this many
// complete periods. Individual tick variation is retained for diagnostics but
// is not itself a test failure.
// Retained as the minimum statistical depth used by the hardware-resolution
// calculation and diagnostics. PASS/FAIL is evaluated for every complete
// pulse independently; accumulated values are used only for display.
constexpr uint16_t MEASUREMENT_AVERAGING_PERIODS = 100;
static_assert(MEASUREMENT_AVERAGING_PERIODS > 0,
"Averaging window must contain at least one period");
@@ -123,38 +123,40 @@ constexpr uint32_t RX_PROCESSING_PERIODS_PER_SECOND = 300000;
constexpr uint32_t C3_STRICT_MAX_HZ = 1000000;
constexpr uint32_t S3_STRICT_MAX_HZ = 1000000;
// RMT stores each HIGH/LOW duration in 15 bits. Select the fastest clock that
// still fits both levels of the current PWM signal: 20, 40 or 80 MHz.
constexpr uint32_t CAPTURE_RESOLUTION_OPTIONS_HZ[] = {20000000, 40000000, 80000000};
constexpr uint32_t RMT_MAX_LEVEL_TICKS = 32766;
// S3 MCPWM Capture uses one 32-bit 80 MHz timer for both edges. Unlike RMT,
// its width does not constrain long LOW/HIGH intervals, so capture precision
// stays at 12.5 ns for every selectable PWM frequency and pulse length.
constexpr uint32_t MCPWM_CAPTURE_RESOLUTION_HZ = 80000000;
// C3 uses the 40 MHz crystal as the LEDC clock.
// Keep this explicit so the resolution calculation never asks LEDC for an
// impossible frequency/resolution combination.
constexpr uint32_t LEDC_SOURCE_CLOCK_HZ = 40000000;
constexpr uint8_t LEDC_CHANNEL = 0;
constexpr uint8_t LEDC_MAX_BITS = 14;
// S3 uses the dedicated MCPWM peripheral. A 40 MHz timer clock keeps the
// longest 1 kHz period within the S3's 16-bit MCPWM counter and makes every
// frequency in TEST_FREQUENCIES_HZ exact.
constexpr uint32_t MCPWM_RESOLUTION_HZ = 40000000;
// S3 uses the dedicated MCPWM peripheral. A 20 MHz timer clock keeps the
// selectable 500 Hz period within the S3's 16-bit counter while retaining
// 50 ns pulse resolution and exact periods for every menu frequency.
constexpr uint32_t MCPWM_RESOLUTION_HZ = 20000000;
constexpr uint32_t MCPWM_MAX_PERIOD_TICKS = 65535;
// -------------------------- Menu value arrays -----------------------------
// START and END deliberately have separate, independently cycling menu lists.
// Every value is exactly achievable from a 40 MHz timer clock. The test walks
// TEST_FREQUENCIES_HZ between the selected endpoints, so there is no
// separately configurable step.
constexpr uint32_t START_FREQ_OPTIONS_HZ[] = {1000, 10000, 100000};
constexpr uint32_t END_FREQ_OPTIONS_HZ[] = {100000, 500000, 1000000};
// All achievable whole-number frequencies in the supported 1 kHz..1 MHz
// range, used for adjacent test stages rather than direct menu selection.
constexpr uint32_t TEST_FREQUENCIES_HZ[] = {
1000, 2000, 5000, 10000, 25000, 50000,
100000, 200000, 312500, 400000, 500000, 625000, 800000, 1000000
// The test uses one selected PWM frequency and walks the pulse-width list from
// the selected maximum down to the selected minimum. Widths are stored in
// nanoseconds so sub-microsecond pulses remain representable without floats.
constexpr uint32_t PWM_FREQUENCY_OPTIONS_HZ[] = {
500, 1000, 2000, 5000, 10000, 25000,
};
constexpr uint32_t MAX_PULSE_OPTIONS_NS[] = {
20000, 50000, 100000, 200000, 500000
};
constexpr uint32_t MIN_PULSE_OPTIONS_NS[] = {
250, 500, 1000, 2000, 5000, 10000
};
constexpr uint32_t TEST_PULSE_WIDTHS_NS[] = {
250, 500, 1000, 2000, 5000, 10000, 20000, 50000,
100000, 200000, 500000, 1000000
};
constexpr float ACCURACY_OPTIONS_PCT[] = {1.0f, 2.0f, 5.0f, 10.0f};
constexpr uint32_t TEST_TIME_OPTIONS_MS[] = {100, 250, 500, 1000, 2000, 5000};
constexpr uint8_t TEST_DUTY_PCT = 50;
template <typename T, size_t N> constexpr size_t countOf(const T (&)[N]) { return N; }

View File

@@ -21,7 +21,7 @@ constexpr const char *FAIL_NAMES[] = {
"НЕТ ОШИБКИ",
"НЕТ СИГНАЛА",
"ПЕРИОД ВНЕ ДОПУСКА",
"ЗАПОЛН. ВНЕ ДОПУСКА",
"ИМПУЛЬС ВНЕ ДОПУСКА",
"ЛИШНИЙ ФРОНТ",
"ИМПУЛЬСНАЯ ПОМЕХА",
"ПРОПУЩЕН ФРОНТ",
@@ -35,8 +35,9 @@ constexpr const char *FAIL_NAMES[] = {
constexpr const char *MODE_PREFIX = "РЕЖИМ: ";
constexpr const char *START_RUN = "ГОТОВ К ЗАПУСКУ";
constexpr const char *MENU_START_FREQUENCY = "ЧАСТОТА ОТ:";
constexpr const char *MENU_END_FREQUENCY = "ЧАСТОТА ДО:";
constexpr const char *MENU_FREQUENCY = "ЧАСТОТА ШИМ:";
constexpr const char *MENU_MAX_PULSE = "МАКС. ИМПУЛЬС:";
constexpr const char *MENU_MIN_PULSE = "МИН. ИМПУЛЬС:";
constexpr const char *MENU_ACCURACY = "ТОЧНОСТЬ:";
constexpr const char *MENU_TEST_TIME = "ВРЕМЯ ВЫБОРКИ:";
constexpr const char *MENU_TOTAL_TIME = "ОБЩЕЕ ВРЕМЯ:";
@@ -54,11 +55,11 @@ constexpr const char *START_AGAIN = "ГОТОВ К ЗАПУСКУ";
constexpr const char *TEST_FAILED = "ТЕСТ НЕ ПРОЙДЕН";
constexpr const char *PASS_WORD = "ТЕСТ ПРОЙДЕН";
constexpr const char *FAIL_FORMAT = "СБОЙ %s %.0f%%";
constexpr const char *TEST_FORMAT = "Тест:%-6s %2.0f%% %5s";
constexpr const char *PERIOD_OUT_FORMAT = "ОШИБКА ЧАСТОТЫ %s";
constexpr const char *DUTY_OUT_FORMAT = "ОШИБКА ЗАПОЛН. %s";
constexpr const char *NO_MEASUREMENT = "F:--- D:---%";
constexpr const char *FAIL_FORMAT = "СБОЙ %s";
constexpr const char *TEST_FORMAT = "%s, %s";
constexpr const char *PERIOD_OUT_FORMAT = "FREQ OUT %s";
constexpr const char *DUTY_OUT_FORMAT = "PULSE OUT %s";
constexpr const char *NO_MEASUREMENT = "F:---, P:---";
#elif UI_LANGUAGE == UI_LANGUAGE_EN
@@ -70,7 +71,7 @@ constexpr const char *FAIL_NAMES[] = {
"NONE",
"NO SIGNAL",
"PERIOD OUT",
"DUTY OUT",
"PULSE OUT",
"EXTRA EDGE",
"GLITCH",
"LOST EDGE",
@@ -84,8 +85,9 @@ constexpr const char *FAIL_NAMES[] = {
constexpr const char *MODE_PREFIX = "MODE: ";
constexpr const char *START_RUN = "READY TO START";
constexpr const char *MENU_START_FREQUENCY = "START FREQ:";
constexpr const char *MENU_END_FREQUENCY = "END FREQ:";
constexpr const char *MENU_FREQUENCY = "PWM FREQUENCY:";
constexpr const char *MENU_MAX_PULSE = "MAX PULSE:";
constexpr const char *MENU_MIN_PULSE = "MIN PULSE:";
constexpr const char *MENU_ACCURACY = "ACCURACY:";
constexpr const char *MENU_TEST_TIME = "TEST TIME:";
constexpr const char *MENU_TOTAL_TIME = "TOTAL TIME:";
@@ -103,11 +105,11 @@ constexpr const char *START_AGAIN = "READY TO START";
constexpr const char *TEST_FAILED = "TEST FAILED";
constexpr const char *PASS_WORD = "TEST PASS";
constexpr const char *FAIL_FORMAT = "FAIL %s %.0f%%";
constexpr const char *TEST_FORMAT = "Test:%-6s %2.0f%% %5s";
constexpr const char *PERIOD_OUT_FORMAT = "PERIOD OUT %s";
constexpr const char *DUTY_OUT_FORMAT = "DUTY OUT %s";
constexpr const char *NO_MEASUREMENT = "F:--- D:---%";
constexpr const char *FAIL_FORMAT = "FAIL %s";
constexpr const char *TEST_FORMAT = "%s, %s";
constexpr const char *PERIOD_OUT_FORMAT = "FREQ OUT %s";
constexpr const char *DUTY_OUT_FORMAT = "PULSE OUT %s";
constexpr const char *NO_MEASUREMENT = "F:---, P:---";
#else
#error "UI_LANGUAGE must be UI_LANGUAGE_EN or UI_LANGUAGE_RU"

View File

@@ -10,7 +10,7 @@ const char *roleName(Role r) {
}
const char *failName(FailReason r) {
static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "DUTY OUT",
static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "PULSE OUT",
"EXTRA EDGE", "GLITCH", "LOST EDGE", "DATA LOSS ERROR", "LINK LOST",
"UNSUPPORTED", "RESOLUTION", "ABORTED"};
const uint8_t i = static_cast<uint8_t>(r);
@@ -31,19 +31,19 @@ uint32_t settingsChecksum(const Settings &s) {
return hash;
}
uint32_t frequencyPointCount(uint32_t startHz, uint32_t endHz) {
if (!startHz || endHz <= startHz) return 0;
uint32_t pulseWidthPointCount(uint32_t maxPulseNs, uint32_t minPulseNs) {
if (!minPulseNs || maxPulseNs < minPulseNs) return 0;
uint32_t count = 0;
for (size_t i = 0; i < countOf(TEST_FREQUENCIES_HZ); ++i)
if (TEST_FREQUENCIES_HZ[i] >= startHz && TEST_FREQUENCIES_HZ[i] <= endHz) ++count;
for (size_t i = 0; i < countOf(TEST_PULSE_WIDTHS_NS); ++i)
if (TEST_PULSE_WIDTHS_NS[i] >= minPulseNs && TEST_PULSE_WIDTHS_NS[i] <= maxPulseNs) ++count;
return count;
}
uint32_t frequencyAt(uint32_t startHz, uint32_t endHz, uint32_t index) {
for (size_t i = 0; i < countOf(TEST_FREQUENCIES_HZ); ++i) {
const uint32_t frequency = TEST_FREQUENCIES_HZ[i];
if (frequency < startHz || frequency > endHz) continue;
if (!index--) return frequency;
uint32_t pulseWidthAt(uint32_t maxPulseNs, uint32_t minPulseNs, uint32_t index) {
for (size_t i = countOf(TEST_PULSE_WIDTHS_NS); i > 0; --i) {
const uint32_t pulseNs = TEST_PULSE_WIDTHS_NS[i - 1U];
if (pulseNs < minPulseNs || pulseNs > maxPulseNs) continue;
if (!index--) return pulseNs;
}
return 0;
}
@@ -58,26 +58,17 @@ uint64_t nominalStageUs(uint32_t frequencyHz, uint32_t sampleTimeMs, uint32_t se
RX_PROCESSING_PERIODS_PER_SECOND - 1U) / RX_PROCESSING_PERIODS_PER_SECOND;
const uint64_t samplingWallUs = processingUs > sampleUs ? processingUs : sampleUs;
uint64_t chunkSymbols =
(static_cast<uint64_t>(frequencyHz) * RMT_TARGET_CHUNK_US + 999999ULL) / 1000000ULL;
if (chunkSymbols < RMT_MIN_RECEIVE_SYMBOLS) chunkSymbols = RMT_MIN_RECEIVE_SYMBOLS;
if (chunkSymbols > RMT_MAX_RECEIVE_SYMBOLS) chunkSymbols = RMT_MAX_RECEIVE_SYMBOLS;
const uint64_t batchWaitUs =
((chunkSymbols * 1000000ULL + frequencyHz - 1U) / frequencyHz) * MEASUREMENT_PROGRESS_STEPS;
const uint64_t settleUs =
(1000000ULL * settleCycles * MEASUREMENT_PROGRESS_STEPS + frequencyHz - 1U) / frequencyHz;
// Initial stage screen, nine intermediate screens and the final result.
const uint64_t displayUs = static_cast<uint64_t>(OLED_PROGRESS_UPDATE_MS) * 1000ULL *
(MEASUREMENT_PROGRESS_STEPS + 1U);
return samplingWallUs + batchWaitUs + settleUs + displayUs;
return samplingWallUs + settleUs + displayUs;
}
uint64_t nominalTotalUs(const TestParams &p, uint32_t settleCycles) {
uint64_t total = 0;
const uint32_t count = frequencyPointCount(p.startHz, p.endHz);
for (uint32_t i = 0; i < count; ++i)
total += nominalStageUs(frequencyAt(p.startHz, p.endHz, i), p.testTimeMs, settleCycles);
return total;
return static_cast<uint64_t>(pulseWidthPointCount(p.maxPulseNs, p.minPulseNs)) *
nominalStageUs(p.frequencyHz, p.testTimeMs, settleCycles);
}
bool periodWithin(float measured, float expected, float tolerance) {
@@ -116,49 +107,54 @@ uint8_t chooseStablePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz,
return fallback;
}
bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz,
uint8_t maxBits, uint8_t dutyPct,
IntegerPwmConfig &config) {
if (!requestedHz || !sourceClockHz || !maxBits || dutyPct > 100U) return false;
bool choosePwmConfig(uint32_t requestedHz, uint32_t requestedPulseNs,
uint32_t sourceClockHz, uint8_t maxBits,
IntegerPwmConfig &config) {
if (!requestedHz || !requestedPulseNs || !sourceClockHz || !maxBits) return false;
constexpr uint32_t FRACTION_SCALE = 256U;
constexpr uint32_t MAX_DIVIDER_RAW = 1024U * FRACTION_SCALE - 1U;
bool found = false;
uint32_t bestErrorHz = 0;
uint32_t bestDutyError = 0;
uint32_t bestLevels = 1;
uint32_t bestFrequencyError = UINT32_MAX;
uint32_t bestPulseError = UINT32_MAX;
for (uint8_t bits = 1; bits <= maxBits && bits < 31; ++bits) {
const uint32_t levels = 1UL << bits;
for (uint32_t divider = 1; divider <= 1023U; ++divider) {
const uint32_t denominator = levels * divider;
// A fixed integer divider gives identical PWM periods. Requiring an
// exact division also guarantees that the physical frequency is a
// whole number of hertz rather than a rounded value.
if (sourceClockHz % denominator) continue;
const uint32_t actualHz = sourceClockHz / denominator;
const uint32_t errorHz = actualHz > requestedHz
? actualHz - requestedHz : requestedHz - actualHz;
const uint32_t dutyCount = (static_cast<uint64_t>(levels) * dutyPct + 50U) / 100U;
const uint32_t representedDuty = dutyCount * 100U;
const uint32_t requestedDuty = levels * dutyPct;
const uint32_t dutyError = representedDuty > requestedDuty
? representedDuty - requestedDuty : requestedDuty - representedDuty;
const uint64_t dividerNumerator = static_cast<uint64_t>(sourceClockHz) * FRACTION_SCALE;
const uint64_t dividerDenominator = static_cast<uint64_t>(requestedHz) * levels;
const uint32_t dividerFloor = static_cast<uint32_t>(dividerNumerator / dividerDenominator);
const uint32_t candidates[] = {dividerFloor, dividerFloor + 1U};
for (uint32_t dividerRaw : candidates) {
if (dividerRaw < FRACTION_SCALE || dividerRaw > MAX_DIVIDER_RAW) continue;
const uint64_t frequencyDenominator = static_cast<uint64_t>(levels) * dividerRaw;
const uint32_t actualHz = static_cast<uint32_t>(
(dividerNumerator + frequencyDenominator / 2U) / frequencyDenominator);
if (!actualHz) continue;
const bool frequencyBetter = !found || errorHz < bestErrorHz;
const bool frequencyEqual = found && errorHz == bestErrorHz;
const bool dutyBetter = frequencyEqual &&
static_cast<uint64_t>(dutyError) * bestLevels <
static_cast<uint64_t>(bestDutyError) * levels;
const bool dutyEqual = frequencyEqual &&
static_cast<uint64_t>(dutyError) * bestLevels ==
static_cast<uint64_t>(bestDutyError) * levels;
if (!frequencyBetter && !dutyBetter && !(dutyEqual && bits > config.bits)) continue;
const uint64_t dutyNumerator = static_cast<uint64_t>(requestedPulseNs) *
sourceClockHz * FRACTION_SCALE;
const uint64_t dutyDenominator = static_cast<uint64_t>(dividerRaw) * 1000000000ULL;
uint32_t dutyCount = static_cast<uint32_t>((dutyNumerator + dutyDenominator / 2U) /
dutyDenominator);
if (!dutyCount) dutyCount = 1U;
if (dutyCount >= levels) dutyCount = levels - 1U;
if (!dutyCount) continue;
config.actualHz = actualHz;
config.divider = static_cast<uint16_t>(divider);
config.bits = bits;
bestErrorHz = errorHz;
bestDutyError = dutyError;
bestLevels = levels;
const uint32_t actualPulseNs = static_cast<uint32_t>(
(static_cast<uint64_t>(dutyCount) * dividerRaw * 1000000000ULL +
static_cast<uint64_t>(sourceClockHz) * FRACTION_SCALE / 2U) /
(static_cast<uint64_t>(sourceClockHz) * FRACTION_SCALE));
const uint32_t frequencyError = actualHz > requestedHz ? actualHz - requestedHz : requestedHz - actualHz;
const uint32_t pulseError = actualPulseNs > requestedPulseNs
? actualPulseNs - requestedPulseNs : requestedPulseNs - actualPulseNs;
if (found && (frequencyError > bestFrequencyError ||
(frequencyError == bestFrequencyError && pulseError > bestPulseError) ||
(frequencyError == bestFrequencyError && pulseError == bestPulseError && bits <= config.bits)))
continue;
config = {actualHz, dividerRaw, dutyCount, actualPulseNs, bits};
bestFrequencyError = frequencyError;
bestPulseError = pulseError;
found = true;
}
}
@@ -166,40 +162,70 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz,
}
FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct,
uint32_t captureHz, uint8_t pwmBits,
uint32_t periodCaptureHz, uint32_t pulseCaptureHz,
uint8_t pwmBits,
uint16_t averagingPeriods) {
if (!frequencyHz || !captureHz || !pwmBits || !averagingPeriods)
if (!frequencyHz || !periodCaptureHz || !pulseCaptureHz || !pwmBits || !averagingPeriods)
return FailReason::RESOLUTION;
const float periodTicks = static_cast<float>(captureHz) / frequencyHz;
const float activeTicks = periodTicks * dutyPct / 100.0f;
const float inactiveTicks = periodTicks - activeTicks;
if (periodTicks < 4.0f || activeTicks < 2.0f || inactiveTicks < 2.0f) return FailReason::RESOLUTION;
const float averagedTicks = periodTicks * averagingPeriods;
const float timerPeriodError = 100.0f / averagedTicks;
const float timerDutyError = 100.0f / averagedTicks;
const float periodTicks = static_cast<float>(periodCaptureHz) / frequencyHz;
const float activeTicks = static_cast<float>(pulseCaptureHz) * dutyPct /
(100.0f * frequencyHz);
if (periodTicks < 4.0f || activeTicks < 2.0f) return FailReason::RESOLUTION;
const float timerPeriodError = 100.0f / periodTicks;
const float timerPulseError = 100.0f / activeTicks;
// Measurement uses the duty actually programmed into LEDC. A coarse PWM
// step is not itself an error when the requested value (e.g. 50%) is exactly
// representable; only the selected value's actual quantization matters.
const float effectiveAccuracy = effectiveTolerancePct(accuracyPct);
return (timerPeriodError > effectiveAccuracy || timerDutyError > effectiveAccuracy)
return (timerPeriodError > effectiveAccuracy || timerPulseError > effectiveAccuracy)
? FailReason::RESOLUTION : FailReason::NONE;
}
FailReason evaluatePeriod(const PulsePeriod &p, uint32_t tickHz, float expectedHz,
float expectedDuty, float tolerance, uint8_t repeat,
StageStats &s) {
if (!p.periodTicks || p.activeTicks >= p.periodTicks) return FailReason::EXTRA_EDGE;
const uint32_t pulseTickHz = p.activeTickHz ? p.activeTickHz : tickHz;
if (!p.periodTicks || !p.activeTicks || !tickHz || !pulseTickHz || expectedHz <= 0.0f)
return FailReason::EXTRA_EDGE;
const float hz = static_cast<float>(tickHz) / p.periodTicks;
const float duty = 100.0f * p.activeTicks / p.periodTicks;
const float duty = static_cast<float>(100.0 * p.activeTicks * tickHz /
(static_cast<double>(pulseTickHz) * p.periodTicks));
++s.periods;
s.periodSum += p.periodTicks; s.activeSum += p.activeTicks;
if (p.periodTicks < s.minPeriod) s.minPeriod = p.periodTicks;
if (p.periodTicks > s.maxPeriod) s.maxPeriod = p.periodTicks;
if (p.activeTicks < s.minActive) s.minActive = p.activeTicks;
if (p.activeTicks > s.maxActive) s.maxActive = p.activeTicks;
FailReason reason = FailReason::NONE;
if (!periodWithin(hz, expectedHz, tolerance)) reason = FailReason::PERIOD_OUT;
else if (!dutyWithin(duty, expectedDuty, tolerance)) reason = FailReason::DUTY_OUT;
// Validate every complete period independently. A single capture tick is the
// unavoidable endpoint uncertainty, so only that one tick may be corrected
// toward the expected value. It cannot hide a larger isolated distortion.
bool frequencyOk = periodWithin(hz, expectedHz, tolerance);
if (!frequencyOk) {
uint32_t correctedPeriod = p.periodTicks;
if (hz > expectedHz) ++correctedPeriod;
else if (correctedPeriod > 1U) --correctedPeriod;
frequencyOk = periodWithin(static_cast<float>(tickHz) / correctedPeriod,
expectedHz, tolerance);
}
const double expectedPulseTicks = static_cast<double>(pulseTickHz) * expectedDuty /
(100.0 * expectedHz);
bool pulseOk = expectedPulseTicks > 0.0 &&
fabs(static_cast<double>(p.activeTicks) - expectedPulseTicks) * 100.0 /
expectedPulseTicks <= tolerance + 0.0001;
if (!pulseOk) {
uint32_t correctedActive = p.activeTicks;
if (correctedActive > expectedPulseTicks) {
if (correctedActive) --correctedActive;
} else {
++correctedActive;
}
pulseOk = fabs(static_cast<double>(correctedActive) - expectedPulseTicks) *
100.0 / expectedPulseTicks <= tolerance + 0.0001;
}
const FailReason reason = !frequencyOk ? FailReason::PERIOD_OUT :
(!pulseOk ? FailReason::DUTY_OUT : FailReason::NONE);
if (reason != FailReason::NONE && s.reason == FailReason::NONE) {
s.reason = reason; s.firstBadPeriod = s.periods; s.firstBadRepeat = repeat;
s.badFrequency = hz; s.badDuty = duty;
@@ -275,7 +301,7 @@ FailReason evaluatePeriodWindow(uint64_t periodSum, uint64_t activeSum,
100.0 * static_cast<double>(activeSum) / periodSum);
bool frequencyOk = periodWithin(hz, expectedHz, tolerance);
if (!frequencyOk && maxPeriod == minPeriod + 1U) {
// At a tolerance boundary, alternating adjacent RMT counts prove that the
// At a tolerance boundary, alternating adjacent capture counts prove that the
// result is quantization-limited. Accept only when a one-tick correction
// toward the expected value returns the averaged frequency into tolerance.
// Consecutive periods telescope into one first-to-last edge interval, so
@@ -289,24 +315,28 @@ FailReason evaluatePeriodWindow(uint64_t periodSum, uint64_t activeSum,
frequencyOk = periodWithin(correctedHz, expectedHz, tolerance);
}
bool dutyOk = dutyWithin(duty, expectedDuty, tolerance);
if (!dutyOk) {
const double expectedPulseTicks = static_cast<double>(tickHz) * expectedDuty /
(100.0 * expectedHz);
const double measuredPulseTicks = static_cast<double>(activeSum) / periodCount;
bool pulseOk = expectedPulseTicks > 0.0 &&
fabs(measuredPulseTicks - expectedPulseTicks) * 100.0 / expectedPulseTicks <= tolerance + 0.0001;
if (!pulseOk) {
// Unlike full periods, active intervals do not telescope: every pulse is
// bounded by a different rising/falling edge pair. With slowly drifting
// asynchronous clocks an entire short window can therefore quantize to
// the same adjacent count (e.g. 41/80 for a true 50% duty). Apply one tick
// per active interval even when minActive == maxActive.
const bool dutyHigh = duty > expectedDuty;
const bool dutyHigh = measuredPulseTicks > expectedPulseTicks;
const uint64_t correctedActive = dutyHigh
? (activeSum > periodCount ? activeSum - periodCount : 0U)
: activeSum + periodCount;
const float correctedDuty = static_cast<float>(
100.0 * static_cast<double>(correctedActive) / periodSum);
dutyOk = dutyWithin(correctedDuty, expectedDuty, tolerance);
const double correctedPulseTicks = static_cast<double>(correctedActive) / periodCount;
pulseOk = fabs(correctedPulseTicks - expectedPulseTicks) * 100.0 /
expectedPulseTicks <= tolerance + 0.0001;
}
FailReason reason = !frequencyOk ? FailReason::PERIOD_OUT :
(!dutyOk ? FailReason::DUTY_OUT : FailReason::NONE);
(!pulseOk ? FailReason::DUTY_OUT : FailReason::NONE);
if (reason != FailReason::NONE && s.reason == FailReason::NONE) {
s.reason = reason;
s.firstBadPeriod = s.periods >= periodCount ? s.periods - periodCount + 1U : 1U;

View File

@@ -15,26 +15,27 @@ const char *failName(FailReason reason);
struct Settings {
uint16_t version;
uint8_t role;
uint8_t startIndex;
uint8_t endIndex;
uint8_t frequencyIndex;
uint8_t maxPulseIndex;
uint8_t minPulseIndex;
uint8_t accuracyIndex;
uint8_t timeIndex;
uint8_t reserved;
uint32_t checksum;
};
struct TestParams {
uint32_t startHz;
uint32_t endHz;
uint32_t frequencyHz;
uint32_t maxPulseNs;
uint32_t minPulseNs;
float accuracyPct;
uint32_t testTimeMs;
uint8_t dutyPct;
};
struct PulsePeriod {
uint64_t startTick;
uint32_t periodTicks;
uint32_t activeTicks;
uint32_t activeTickHz;
};
struct StageStats {
@@ -63,13 +64,15 @@ struct PeriodLimits {
struct IntegerPwmConfig {
uint32_t actualHz;
uint16_t divider;
uint32_t dividerRaw;
uint32_t dutyCount;
uint32_t actualPulseNs;
uint8_t bits;
};
uint32_t settingsChecksum(const Settings &s);
uint32_t frequencyPointCount(uint32_t startHz, uint32_t endHz);
uint32_t frequencyAt(uint32_t startHz, uint32_t endHz, uint32_t index);
uint32_t pulseWidthPointCount(uint32_t maxPulseNs, uint32_t minPulseNs);
uint32_t pulseWidthAt(uint32_t maxPulseNs, uint32_t minPulseNs, uint32_t index);
uint64_t nominalStageUs(uint32_t frequencyHz, uint32_t sampleTimeMs, uint32_t settleCycles);
uint64_t nominalTotalUs(const TestParams &p, uint32_t settleCycles);
bool periodWithin(float measuredHz, float expectedHz, float tolerancePct);
@@ -79,11 +82,12 @@ uint8_t choosePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz,
uint8_t maxBits);
uint8_t chooseStablePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz,
uint8_t maxBits, uint8_t dutyPct);
bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz,
uint8_t maxBits, uint8_t dutyPct,
bool choosePwmConfig(uint32_t requestedHz, uint32_t requestedPulseNs,
uint32_t sourceClockHz, uint8_t maxBits,
IntegerPwmConfig &config);
FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct,
uint32_t captureResolutionHz, uint8_t pwmBits,
uint32_t periodResolutionHz, uint32_t pulseResolutionHz,
uint8_t pwmBits,
uint16_t averagingPeriods);
FailReason evaluatePeriod(const PulsePeriod &period, uint32_t tickHz, float expectedHz,
float expectedDuty, float tolerancePct, uint8_t repeat,

View File

@@ -26,6 +26,7 @@ Display::Display() : oled_(128, 32, &Wire, -1) {}
bool Display::begin() {
Wire.begin(GPIO_SDA, GPIO_SCL);
Wire.setClock(400000); // keeps a full 128x32 framebuffer update near 15 ms
Wire.setTimeOut(30); // a faulty/stretched I2C bus must not stall button polling for seconds
// An absent optional OLED produces a large burst of ESP-IDF NACK messages.
// Probe it once and keep the I2C driver quiet when no display is connected.
esp_log_level_set("i2c.master", ESP_LOG_NONE);
@@ -103,7 +104,7 @@ void Display::show(const char *a, const char *b, uint32_t progress,
void Display::formatFrequency(float hz, char *out, size_t n) {
float value = hz; const char *suffix = "Hz";
if (hz >= 999950.0f) { value = hz / 1000000.0f; suffix = "M"; }
else if (hz >= 1000.0f) { value = hz / 1000.0f; suffix = "k"; }
else if (hz >= 999.5f) { value = hz / 1000.0f; suffix = "k"; }
if (suffix[0] == 'M' && fabsf(value - roundf(value)) < 0.0005f)
snprintf(out, n, "%.0f%s", value, suffix);
else if (value >= 100.0f) snprintf(out, n, "%.1f%s", value, suffix);
@@ -125,6 +126,30 @@ void Display::formatTestFrequency(uint32_t hz, char *out, size_t n) {
snprintf(out, n, "%lu", hz);
}
void Display::formatPwmFrequency(uint32_t hz, char *out, size_t n) {
if (hz >= 1000000U && hz % 1000000U == 0U)
snprintf(out, n, "%luMHz", hz / 1000000U);
else if (hz >= 1000U && hz % 1000U == 0U)
snprintf(out, n, "%lukHz", hz / 1000U);
else if (hz >= 1000U)
snprintf(out, n, "%.3gkHz", hz / 1000.0f);
else
snprintf(out, n, "%luHz", hz);
}
void Display::formatPulse(uint32_t pulseNs, char *out, size_t n, bool measured) {
if (pulseNs >= 1000U) {
const float us = pulseNs / 1000.0f;
if (measured) snprintf(out, n, "%.2fu", us);
else if (pulseNs % 1000U == 0U) snprintf(out, n, "%luus", pulseNs / 1000U);
else snprintf(out, n, "%.2fus", us);
} else if (measured) {
snprintf(out, n, "%.3fu", pulseNs / 1000.0f);
} else {
snprintf(out, n, "%luns", pulseNs);
}
}
void Display::formatDuration(uint64_t us, char *out, size_t n) {
const uint64_t totalSeconds = (us + 999999ULL) / 1000000ULL;
const uint64_t minutes = totalSeconds / 60ULL;

View File

@@ -15,7 +15,9 @@ class Display {
bool powered() const { return powered_; }
static void formatFrequency(float hz, char *out, size_t size);
static void formatTestFrequency(uint32_t hz, char *out, size_t size);
static void formatDuration(uint64_t us, char *out, size_t size);
static void formatPwmFrequency(uint32_t hz, char *out, size_t size);
static void formatPulse(uint32_t pulseNs, char *out, size_t size, bool measured = false);
static void formatDuration(uint64_t us, char *out, size_t size);
private:
void drawTextLine(const char *text, int16_t y, int16_t startX = 0);
Adafruit_SSD1306 oled_;

View File

@@ -7,7 +7,8 @@ namespace Log {
void event(const char *component, const char *message) {
if (!SERIAL_ACTION_LOG) return;
if (SERIAL_MINIMAL_LOG && strcmp(component, "INPUT") && strcmp(component, "UI") &&
strcmp(component, "CONFIG") && strcmp(component, "RESULT") && strcmp(component, "ESP-NOW")) return;
strcmp(component, "CONFIG") && strcmp(component, "RESULT") && strcmp(component, "CAPTURE") &&
strcmp(component, "ESP-NOW")) return;
if (SERIAL_LOG_TIMESTAMPS) Serial.printf("[%10lu][%-8s] %s\n", millis(), component, message);
else Serial.printf("[%-8s] %s\n", component, message);
}

View File

@@ -12,13 +12,12 @@ bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs,
!receiver_.start(expectedHz_, expectedDutyPct_)) return false;
settleCycles_ = settleCycles; settleLeft_ = settleCycles;
tolerancePct_ = tolerance;
averagingPeriods_ = averagingPeriods;
resetAveragingWindow();
stepTimeMs_ = (timeMs + MEASUREMENT_PROGRESS_STEPS - 1U) / MEASUREMENT_PROGRESS_STEPS;
stepTicks_ = static_cast<uint64_t>(receiver_.tickHz()) * timeMs /
(1000ULL * MEASUREMENT_PROGRESS_STEPS);
if (!stepTicks_) stepTicks_ = 1;
currentStep_ = 0;
__atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE);
stats_.reset();
publishStats();
measurementStartTick_ = deadlineTick_ = 0; startedMs_ = millis();
@@ -30,13 +29,6 @@ bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs,
return true;
}
void Measurement::resetAveragingWindow() {
windowPeriodCount_ = 0;
windowPeriodSum_ = windowActiveSum_ = 0;
windowMinPeriod_ = UINT32_MAX;
windowMaxPeriod_ = 0;
}
void Measurement::taskEntry(void *context) {
static_cast<Measurement *>(context)->taskLoop();
}
@@ -50,30 +42,33 @@ void Measurement::taskLoop() {
void Measurement::fail(FailReason reason) {
if (stats_.reason == FailReason::NONE) stats_.reason = reason;
__atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE);
publishStats();
receiver_.stop(); state_ = MeasureState::FAIL;
// App stops PWM first and only then disables capture. Disabling MCPWM from
// this RX task while input edges are still arriving can race its ISR.
state_ = MeasureState::FAIL;
}
void Measurement::completeMeasurement() {
receiver_.stop();
stats_.droppedItems += receiver_.takeDroppedItems();
if (receiver_.overflowed()) { fail(FailReason::GLITCH); return; }
if (windowPeriodCount_) {
const FailReason result = evaluatePeriodWindow(
windowPeriodSum_, windowActiveSum_, windowPeriodCount_, receiver_.tickHz(),
expectedHz_, expectedDutyPct_, tolerancePct_,
windowMinPeriod_, windowMaxPeriod_,
currentStep_ + 1U, stats_);
if (result != FailReason::NONE) { fail(result); return; }
}
const uint32_t dropped = receiver_.takeDroppedItems();
stats_.droppedItems += dropped;
if (dropped) { fail(FailReason::DATA_LOSS); return; }
publishStats();
if (++currentStep_ < MEASUREMENT_PROGRESS_STEPS) {
state_ = MeasureState::STEP_READY;
// Capture and validation continue while the main task draws OLED. Pausing
// here would overflow the edge queue at higher PWM frequencies; stopping
// MCPWM Capture can race an edge ISR. Publish a snapshot, then advance the
// edge-based window without interrupting the RX pipeline.
__atomic_store_n(&progressUpdatePending_, true, __ATOMIC_RELEASE);
measurementStartTick_ = deadlineTick_;
deadlineTick_ += stepTicks_;
measurementStartMs_ = lastPeriodMs_ = millis();
return;
}
if (!stats_.periods) {
fail(FailReason::DATA_LOSS); return;
}
__atomic_store_n(&progressUpdatePending_, false, __ATOMIC_RELEASE);
state_ = MeasureState::PASS;
}
@@ -92,11 +87,12 @@ bool Measurement::statsSnapshot(StageStats &out) const {
MeasureState Measurement::processOnce() {
if (state_ != MeasureState::SETTLING && state_ != MeasureState::RUNNING) return state_;
if (receiver_.overflowed()) { fail(FailReason::GLITCH); return state_; }
bool receivedPeriod = false;
for (;;) {
const size_t periodCount = receiver_.readPeriods(periodBatch_, PERIOD_BATCH_SIZE, pdMS_TO_TICKS(2));
stats_.droppedItems += receiver_.takeDroppedItems();
const uint32_t dropped = receiver_.takeDroppedItems();
stats_.droppedItems += dropped;
if (dropped) { fail(FailReason::DATA_LOSS); return state_; }
if (!periodCount) break;
receivedPeriod = true;
for (size_t periodIndex = 0; periodIndex < periodCount; ++periodIndex) {
@@ -111,55 +107,32 @@ MeasureState Measurement::processOnce() {
}
continue;
}
const uint64_t endTick = period.startTick + period.periodTicks;
if (period.startTick < measurementStartTick_) continue; // leading incomplete period
if (endTick > deadlineTick_) { completeMeasurement(); return state_; } // trailing incomplete period
if (!period.periodTicks || period.activeTicks >= period.periodTicks) {
while (period.startTick >= deadlineTick_) {
// Progress boundaries never discard a pulse. A complete period is
// assigned by its start edge, then validated exactly once. The nine
// intermediate boundaries only publish UI snapshots.
completeMeasurement();
if (state_ != MeasureState::RUNNING) return state_;
}
if (!period.periodTicks || !period.activeTicks || !period.activeTickHz) {
fail(FailReason::EXTRA_EDGE); return state_;
}
++stats_.periods;
stats_.periodSum += period.periodTicks;
stats_.activeSum += period.activeTicks;
if (period.periodTicks < stats_.minPeriod) stats_.minPeriod = period.periodTicks;
if (period.periodTicks > stats_.maxPeriod) stats_.maxPeriod = period.periodTicks;
if (period.activeTicks < stats_.minActive) stats_.minActive = period.activeTicks;
if (period.activeTicks > stats_.maxActive) stats_.maxActive = period.activeTicks;
if (period.periodTicks < windowMinPeriod_) windowMinPeriod_ = period.periodTicks;
if (period.periodTicks > windowMaxPeriod_) windowMaxPeriod_ = period.periodTicks;
++windowPeriodCount_;
windowPeriodSum_ += period.periodTicks;
windowActiveSum_ += period.activeTicks;
if (windowPeriodCount_ >= averagingPeriods_) {
const FailReason result = evaluatePeriodWindow(
windowPeriodSum_, windowActiveSum_, windowPeriodCount_, receiver_.tickHz(),
expectedHz_, expectedDutyPct_, tolerancePct_,
windowMinPeriod_, windowMaxPeriod_,
currentStep_ + 1U, stats_);
resetAveragingWindow();
if (result != FailReason::NONE) { fail(result); return state_; }
}
const FailReason result = evaluatePeriod(period, receiver_.tickHz(),
expectedHz_, expectedDutyPct_, tolerancePct_, currentStep_ + 1U, stats_);
if (result != FailReason::NONE) { fail(result); return state_; }
}
}
if (receivedPeriod && state_ == MeasureState::RUNNING) lastPeriodMs_ = millis();
const uint64_t edgeBasedTimeout =
static_cast<uint64_t>(PWM_SETTLE_CYCLES + NO_SIGNAL_TIMEOUT_PERIODS) * expectedPeriodMs_ + 20;
const uint64_t rmtBatchTimeout =
static_cast<uint64_t>(RMT_MIN_RECEIVE_SYMBOLS + NO_SIGNAL_TIMEOUT_PERIODS) * expectedPeriodMs_ + 20;
const uint64_t settleTimeout = edgeBasedTimeout > rmtBatchTimeout ? edgeBasedTimeout : rmtBatchTimeout;
const uint64_t settleTimeout = edgeBasedTimeout;
if (state_ == MeasureState::SETTLING && millis() - startedMs_ > settleTimeout) fail(FailReason::NO_SIGNAL);
if (state_ == MeasureState::RUNNING && measurementStartTick_) {
const uint32_t now = millis();
// RMT reports a block only after its user buffer has filled. At 1 kHz the
// minimum 48-symbol C3 block contains roughly 48 PWM periods and therefore
// arrives much later than the old 8-period timeout. Do not call that
// normal batching delay a lost edge.
const uint32_t batchPeriods = receiver_.receiveChunkSymbols();
const uint32_t batchTimeoutMs = expectedPeriodMs_ * (batchPeriods + NO_SIGNAL_TIMEOUT_PERIODS) + 2U;
const uint32_t edgeTimeoutMs = expectedPeriodMs_ * NO_SIGNAL_TIMEOUT_PERIODS + 2U;
const uint32_t receiveTimeoutMs = batchTimeoutMs > edgeTimeoutMs ? batchTimeoutMs : edgeTimeoutMs;
if (now - measurementStartMs_ < stepTimeMs_ && now - lastPeriodMs_ > receiveTimeoutMs) {
if (now - measurementStartMs_ < stepTimeMs_ && now - lastPeriodMs_ > edgeTimeoutMs) {
fail(FailReason::LOST_EDGE); return state_;
}
if (now - measurementStartMs_ > stepTimeMs_ + expectedPeriodMs_ + 2) completeMeasurement();
@@ -169,24 +142,16 @@ MeasureState Measurement::processOnce() {
MeasureState Measurement::update() { return state_; }
bool Measurement::continueAfterDisplay() {
if (state_ != MeasureState::STEP_READY) return false;
if (!receiver_.start(expectedHz_, expectedDutyPct_)) {
fail(FailReason::UNSUPPORTED);
return false;
}
// receiver_.start() starts a new RMT timebase and therefore a new sampling
// phase. Start a fresh averaging window for the new continuous capture.
resetAveragingWindow();
settleLeft_ = settleCycles_;
measurementStartTick_ = deadlineTick_ = 0;
startedMs_ = millis(); measurementStartMs_ = lastPeriodMs_ = 0;
state_ = MeasureState::SETTLING;
xTaskNotifyGive(task_);
return true;
bool Measurement::takeProgressUpdate() {
return __atomic_exchange_n(&progressUpdatePending_, false, __ATOMIC_ACQ_REL);
}
void Measurement::abort() {
if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING ||
state_ == MeasureState::STEP_READY) fail(FailReason::ABORTED);
if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING)
fail(FailReason::ABORTED);
}
void Measurement::forceFail(FailReason reason) {
if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING)
fail(reason);
}

View File

@@ -1,7 +1,7 @@
#pragma once
#include "Receiver.h"
enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, STEP_READY, PASS, FAIL };
enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, PASS, FAIL };
class Measurement {
public:
@@ -10,8 +10,9 @@ class Measurement {
uint32_t testTimeMs, uint16_t averagingPeriods,
uint8_t settleCycles);
MeasureState update();
bool continueAfterDisplay();
bool takeProgressUpdate();
void abort();
void forceFail(FailReason reason);
MeasureState state() const { return state_; }
FailReason reason() const { return stats_.reason; }
const StageStats &stats() const { return stats_; }
@@ -24,7 +25,6 @@ class Measurement {
void fail(FailReason reason);
void completeMeasurement();
void publishStats();
void resetAveragingWindow();
PulseReceiver &receiver_;
volatile MeasureState state_ = MeasureState::IDLE;
TaskHandle_t task_ = nullptr;
@@ -33,14 +33,11 @@ class Measurement {
mutable portMUX_TYPE statsMux_ = portMUX_INITIALIZER_UNLOCKED;
uint32_t expectedHz_ = 0;
float expectedDutyPct_ = 0.0f, tolerancePct_ = 0.0f;
uint16_t averagingPeriods_ = 1;
uint32_t windowPeriodCount_ = 0;
uint64_t windowPeriodSum_ = 0, windowActiveSum_ = 0;
uint32_t windowMinPeriod_ = UINT32_MAX, windowMaxPeriod_ = 0;
uint8_t settleCycles_ = 0, settleLeft_ = 0;
uint64_t measurementStartTick_ = 0, deadlineTick_ = 0, stepTicks_ = 0;
uint32_t startedMs_ = 0, measurementStartMs_ = 0, lastPeriodMs_ = 0;
uint32_t stepTimeMs_ = 1, expectedPeriodMs_ = 1;
volatile uint8_t currentStep_ = 0;
volatile bool progressUpdatePending_ = false;
PulsePeriod periodBatch_[PERIOD_BATCH_SIZE] = {};
};

View File

@@ -4,24 +4,24 @@
#ifdef PWM_OUTPUT_TEST
PwmGenerator pwmOutputTest;
uint8_t pwmOutputTestDuty = 50;
uint32_t pwmOutputTestPulseNs = PWM_OUTPUT_TEST_MIN_PULSE_NS;
uint32_t pwmOutputTestUpdatedMs = 0;
void setup() {
Serial.begin(SERIAL_BAUD);
delay(200);
Serial.printf("\nPWM OUTPUT TEST: GPIO=%u requested=%luHz duty=%u..%u%% sine=%lums safe=%s\n",
Serial.printf("\nPWM OUTPUT TEST: GPIO=%u requested=%luHz pulse=%lu..%luns sine=%lums safe=%s\n",
GPIO_PWM, PWM_OUTPUT_TEST_FREQUENCY_HZ,
PWM_OUTPUT_TEST_MIN_DUTY_PCT, PWM_OUTPUT_TEST_MAX_DUTY_PCT,
PWM_OUTPUT_TEST_MIN_PULSE_NS, PWM_OUTPUT_TEST_MAX_PULSE_NS,
PWM_OUTPUT_TEST_SWEEP_PERIOD_MS,
PWM_SAFE_LEVEL == HIGH ? "HIGH" : "LOW");
pwmOutputTest.begin();
ActualPwm actual = {};
if (pwmOutputTest.start(PWM_OUTPUT_TEST_FREQUENCY_HZ,
pwmOutputTestDuty, actual)) {
Serial.printf("PWM OUTPUT TEST STARTED: actual=%luHz duty=%.2f%% bits=%u\n",
actual.actualHz, actual.actualDutyPct, actual.bits);
pwmOutputTestPulseNs, actual)) {
Serial.printf("PWM OUTPUT TEST STARTED: actual=%luHz pulse=%luns bits=%u\n",
actual.actualHz, actual.actualPulseNs, actual.bits);
} else {
Serial.println("PWM OUTPUT TEST FAILED");
}
@@ -35,16 +35,16 @@ void loop() {
constexpr float PWM_TWO_PI = 6.28318530718f;
const float phase = PWM_TWO_PI * (now % PWM_OUTPUT_TEST_SWEEP_PERIOD_MS) /
PWM_OUTPUT_TEST_SWEEP_PERIOD_MS;
const float center = (PWM_OUTPUT_TEST_MIN_DUTY_PCT + PWM_OUTPUT_TEST_MAX_DUTY_PCT) * 0.5f;
const float amplitude = (PWM_OUTPUT_TEST_MAX_DUTY_PCT - PWM_OUTPUT_TEST_MIN_DUTY_PCT) * 0.5f;
const uint8_t duty = static_cast<uint8_t>(center + amplitude * sinf(phase) + 0.5f);
if (duty == pwmOutputTestDuty) return;
const float center = (PWM_OUTPUT_TEST_MIN_PULSE_NS + PWM_OUTPUT_TEST_MAX_PULSE_NS) * 0.5f;
const float amplitude = (PWM_OUTPUT_TEST_MAX_PULSE_NS - PWM_OUTPUT_TEST_MIN_PULSE_NS) * 0.5f;
const uint32_t pulseNs = static_cast<uint32_t>(center + amplitude * sinf(phase) + 0.5f);
if (pulseNs == pwmOutputTestPulseNs) return;
ActualPwm actual = {};
if (pwmOutputTest.start(PWM_OUTPUT_TEST_FREQUENCY_HZ, duty, actual)) {
pwmOutputTestDuty = duty;
if (pwmOutputTest.start(PWM_OUTPUT_TEST_FREQUENCY_HZ, pulseNs, actual)) {
pwmOutputTestPulseNs = pulseNs;
} else {
Serial.printf("PWM OUTPUT TEST UPDATE FAILED: duty=%u%%\n", duty);
Serial.printf("PWM OUTPUT TEST UPDATE FAILED: pulse=%luns\n", pulseNs);
delay(100);
}
}

View File

@@ -2,7 +2,7 @@
#include "Core.h"
constexpr uint16_t PROTOCOL_MAGIC = 0x4F43;
constexpr uint8_t PROTOCOL_VERSION = 8;
constexpr uint8_t PROTOCOL_VERSION = 11;
enum class MessageType : uint8_t {
DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT,
@@ -21,8 +21,9 @@ struct ProtocolPacket {
uint16_t stageCount;
uint16_t sequence;
uint32_t requestedHz;
uint32_t requestedPulseNs;
uint32_t actualHz;
uint16_t actualDutyX100;
uint32_t actualPulseNs;
uint32_t testTimeMs;
uint16_t accuracyX100;
uint8_t settleCycles;
@@ -31,14 +32,14 @@ struct ProtocolPacket {
uint8_t reason;
uint32_t periods;
uint32_t measuredHzX10;
uint16_t measuredDutyX10;
uint32_t measuredPulseNs;
uint32_t minPeriodTicks;
uint32_t maxPeriodTicks;
uint16_t crc;
};
#pragma pack(pop)
static_assert(sizeof(ProtocolPacket) == 54, "Protocol layout changed");
static_assert(sizeof(ProtocolPacket) == 62, "Protocol layout changed");
uint16_t packetCrc(const ProtocolPacket &packet);
void finalizePacket(ProtocolPacket &packet);

View File

@@ -12,20 +12,20 @@ namespace {
constexpr ledc_mode_t PWM_SPEED_MODE = LEDC_LOW_SPEED_MODE;
constexpr ledc_timer_t PWM_TIMER = LEDC_TIMER_0;
void setIntegerDivider(uint16_t divider) {
void setDivider(uint32_t dividerRaw) {
ledc_dev_t *hardware = LEDC_LL_GET_HW();
ledc_ll_timer_pause(hardware, PWM_SPEED_MODE, PWM_TIMER);
ledc_ll_set_clock_divider(hardware, PWM_SPEED_MODE, PWM_TIMER,
static_cast<uint32_t>(divider) << LEDC_LL_FRACTIONAL_BITS);
dividerRaw);
ledc_ll_timer_rst(hardware, PWM_SPEED_MODE, PWM_TIMER);
ledc_ll_ls_timer_update(hardware, PWM_SPEED_MODE, PWM_TIMER);
ledc_ll_timer_resume(hardware, PWM_SPEED_MODE, PWM_TIMER);
}
bool integerDividerIsSet(uint16_t expected) {
bool dividerIsSet(uint32_t expectedRaw) {
uint32_t rawDivider = 0;
ledc_ll_get_clock_divider(LEDC_LL_GET_HW(), PWM_SPEED_MODE, PWM_TIMER, &rawDivider);
return rawDivider == (static_cast<uint32_t>(expected) << LEDC_LL_FRACTIONAL_BITS);
return rawDivider == expectedRaw;
}
#elif CONFIG_IDF_TARGET_ESP32S3
mcpwm_timer_handle_t mcpwmTimer = nullptr;
@@ -34,6 +34,11 @@ mcpwm_cmpr_handle_t mcpwmComparator = nullptr;
mcpwm_gen_handle_t mcpwmGenerator = nullptr;
uint32_t mcpwmFrequencyHz = 0;
constexpr mcpwm_generator_action_t PWM_ACTIVE_ACTION =
PWM_ACTIVE_LEVEL == HIGH ? MCPWM_GEN_ACTION_HIGH : MCPWM_GEN_ACTION_LOW;
constexpr mcpwm_generator_action_t PWM_INACTIVE_ACTION =
PWM_ACTIVE_LEVEL == HIGH ? MCPWM_GEN_ACTION_LOW : MCPWM_GEN_ACTION_HIGH;
void releaseMcpwm() {
if (mcpwmGenerator) {
mcpwm_del_generator(mcpwmGenerator);
@@ -94,10 +99,10 @@ void PwmGenerator::begin() {
timerConfig.period_ticks / 2U) == ESP_OK;
ok = ok && mcpwm_generator_set_action_on_timer_event(mcpwmGenerator,
MCPWM_GEN_TIMER_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
MCPWM_TIMER_EVENT_EMPTY, MCPWM_GEN_ACTION_HIGH)) == ESP_OK;
MCPWM_TIMER_EVENT_EMPTY, PWM_ACTIVE_ACTION)) == ESP_OK;
ok = ok && mcpwm_generator_set_action_on_compare_event(mcpwmGenerator,
MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
mcpwmComparator, MCPWM_GEN_ACTION_LOW)) == ESP_OK;
mcpwmComparator, PWM_INACTIVE_ACTION)) == ESP_OK;
ok = ok && mcpwm_timer_enable(mcpwmTimer) == ESP_OK;
if (!ok) {
releaseMcpwm();
@@ -109,23 +114,29 @@ void PwmGenerator::begin() {
#endif
}
bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) {
bool PwmGenerator::start(uint32_t hz, uint32_t pulseNs, ActualPwm &a) {
#if CONFIG_IDF_TARGET_ESP32C3
IntegerPwmConfig config = {};
if (!chooseIntegerPwmConfig(hz, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, dutyPct, config)) return false;
if (!choosePwmConfig(hz, pulseNs, LEDC_SOURCE_CLOCK_HZ, LEDC_MAX_BITS, config)) return false;
const uint8_t bits = config.bits;
const uint32_t levels = 1UL << bits;
const uint32_t duty = (static_cast<uint64_t>(levels) * dutyPct + 50U) / 100U;
const uint32_t duty = config.dutyCount;
for (uint8_t attempt = 0; attempt < 2; ++attempt) {
stop();
const bool attached = ledcAttachChannel(GPIO_PWM, config.actualHz, bits, LEDC_CHANNEL);
if (attached) {
// Arduino's LEDC API normally chooses an 8-bit fractional divider.
// Force the fractional byte to zero so every PWM period contains the
// same integer number of 40 MHz source-clock ticks.
setIntegerDivider(config.divider);
// Native LEDC produces a HIGH pulse. Invert the GPIO matrix output when
// the configured active pulse level is LOW.
if (!ledcOutputInvert(GPIO_PWM, PWM_ACTIVE_LEVEL == LOW)) {
ledcDetach(GPIO_PWM);
delay(2);
continue;
}
// Apply the selected Q10.8 divider explicitly. This lets pulse width,
// rather than duty percentage, drive the hardware quantization.
setDivider(config.dividerRaw);
}
if (attached && integerDividerIsSet(config.divider) && ledcWriteChannel(LEDC_CHANNEL, duty)) {
if (attached && dividerIsSet(config.dividerRaw) && ledcWriteChannel(LEDC_CHANNEL, duty)) {
// On the first configuration after power-up the duty update is latched
// on a timer edge. Reading immediately can therefore return zero.
uint32_t settleUs = static_cast<uint32_t>((2000000ULL + hz - 1U) / hz);
@@ -133,7 +144,8 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) {
delayMicroseconds(settleUs);
const uint32_t actualHz = ledcReadFreq(GPIO_PWM);
if (actualHz == config.actualHz) {
a = {hz, actualHz, 100.0f * duty / levels, bits};
a = {hz, actualHz, pulseNs, config.actualPulseNs,
100.0f * duty / levels, bits};
running_ = true;
return true;
}
@@ -144,11 +156,13 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) {
pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL);
return false;
#elif CONFIG_IDF_TARGET_ESP32S3
if (!mcpwmTimer || !mcpwmComparator || !mcpwmGenerator || !hz || dutyPct > 100U ||
if (!mcpwmTimer || !mcpwmComparator || !mcpwmGenerator || !hz || !pulseNs ||
MCPWM_RESOLUTION_HZ % hz) return false;
const uint32_t periodTicks = MCPWM_RESOLUTION_HZ / hz;
if (periodTicks < 2U || periodTicks > MCPWM_MAX_PERIOD_TICKS) return false;
uint32_t activeTicks = (static_cast<uint64_t>(periodTicks) * dutyPct + 50U) / 100U;
uint32_t activeTicks = static_cast<uint32_t>(
(static_cast<uint64_t>(pulseNs) * MCPWM_RESOLUTION_HZ + 500000000ULL) /
1000000000ULL);
if (activeTicks == 0U) activeTicks = 1U;
if (activeTicks >= periodTicks) activeTicks = periodTicks - 1U;
@@ -165,7 +179,11 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) {
return false;
}
a = {hz, hz, 100.0f * activeTicks / periodTicks, periodResolutionBits(periodTicks)};
const uint32_t actualPulseNs = static_cast<uint32_t>(
(static_cast<uint64_t>(activeTicks) * 1000000000ULL + MCPWM_RESOLUTION_HZ / 2U) /
MCPWM_RESOLUTION_HZ);
a = {hz, hz, pulseNs, actualPulseNs, 100.0f * activeTicks / periodTicks,
periodResolutionBits(periodTicks)};
mcpwmFrequencyHz = hz;
running_ = true;
return true;
@@ -189,8 +207,8 @@ void PwmGenerator::stop() {
}
void PwmGenerator::active() {
// First detach/stop the PWM peripheral, then select the independently
// configured active level. The active and safe levels may be equal.
// First detach/stop the PWM peripheral, then apply the same active level
// that denotes the pulse during a running test.
stop();
#if CONFIG_IDF_TARGET_ESP32C3
digitalWrite(GPIO_PWM, PWM_ACTIVE_LEVEL);

View File

@@ -1,12 +1,19 @@
#pragma once
#include <Arduino.h>
struct ActualPwm { uint32_t requestedHz; uint32_t actualHz; float actualDutyPct; uint8_t bits; };
struct ActualPwm {
uint32_t requestedHz;
uint32_t actualHz;
uint32_t requestedPulseNs;
uint32_t actualPulseNs;
float actualDutyPct;
uint8_t bits;
};
class PwmGenerator {
public:
void begin();
bool start(uint32_t frequencyHz, uint8_t dutyPct, ActualPwm &actual);
bool start(uint32_t frequencyHz, uint32_t pulseNs, ActualPwm &actual);
void active();
void stop();
bool running() const { return running_; }

View File

@@ -1,13 +1,13 @@
#include "Receiver.h"
#include "Config.h"
#include <string.h>
#if !OPTICAL_USE_RMT_DMA
#include "Log.h"
#include <math.h>
#if !OPTICAL_USE_MCPWM_CAPTURE
#include <esp_cpu.h>
#include <esp32-hal-cpu.h>
#endif
uint32_t PulseReceiver::tickHz() const {
#if OPTICAL_USE_RMT_DMA
#if OPTICAL_USE_MCPWM_CAPTURE
return captureResolutionHz_;
#else
return cpuTickHz_;
@@ -15,31 +15,48 @@ uint32_t PulseReceiver::tickHz() const {
}
uint32_t PulseReceiver::plannedTickHz(uint32_t expectedHz, float expectedDutyPct) const {
#if OPTICAL_USE_RMT_DMA
if (!expectedHz || expectedDutyPct <= 0.0f || expectedDutyPct >= 100.0f)
return CAPTURE_RESOLUTION_OPTIONS_HZ[0];
uint32_t dutyX100 = static_cast<uint32_t>(expectedDutyPct * 100.0f + 0.5f);
if (dutyX100 < 5000U) dutyX100 = 10000U - dutyX100;
for (int i = static_cast<int>(countOf(CAPTURE_RESOLUTION_OPTIONS_HZ)) - 1; i >= 0; --i) {
const uint32_t resolution = CAPTURE_RESOLUTION_OPTIONS_HZ[i];
const uint64_t levelTicksX100 = static_cast<uint64_t>(resolution) * dutyX100;
const uint64_t limitX100 = static_cast<uint64_t>(expectedHz) * 10000ULL * RMT_MAX_LEVEL_TICKS;
if (levelTicksX100 <= limitX100) return resolution;
}
return CAPTURE_RESOLUTION_OPTIONS_HZ[0];
if (!expectedHz || expectedDutyPct <= 0.0f || expectedDutyPct >= 100.0f) return 0;
#if OPTICAL_USE_MCPWM_CAPTURE
return captureResolutionHz_ ? captureResolutionHz_ :
MCPWM_CAPTURE_RESOLUTION_HZ;
#else
(void)expectedHz; (void)expectedDutyPct;
return cpuTickHz_;
#endif
}
bool PulseReceiver::begin() {
#if OPTICAL_USE_RMT_DMA
queue_ = xQueueCreate(RMT_QUEUE_BLOCKS, sizeof(SymbolBlock));
return queue_ && configureRmt(CAPTURE_RESOLUTION_OPTIONS_HZ[0]);
#else
queue_ = xQueueCreate(256, sizeof(Edge));
queue_ = xQueueCreate(512, sizeof(Edge));
if (!queue_) return false;
#if OPTICAL_USE_MCPWM_CAPTURE
// PWM generation uses MCPWM group 0. Group 1 is dedicated to input capture,
// so RX cannot exhaust or conflict with the generator's resources.
mcpwm_capture_timer_config_t timerConfig = {};
timerConfig.group_id = 1;
timerConfig.clk_src = MCPWM_CAPTURE_CLK_SRC_DEFAULT;
timerConfig.resolution_hz = MCPWM_CAPTURE_RESOLUTION_HZ;
if (mcpwm_new_capture_timer(&timerConfig, &captureTimer_) != ESP_OK) return false;
if (mcpwm_capture_timer_get_resolution(captureTimer_, &captureResolutionHz_) != ESP_OK ||
!captureResolutionHz_) return false;
mcpwm_capture_channel_config_t channelConfig = {};
channelConfig.gpio_num = GPIO_RX;
channelConfig.prescale = 1;
channelConfig.flags.pos_edge = true;
channelConfig.flags.neg_edge = false;
if (mcpwm_new_capture_channel(captureTimer_, &channelConfig, &risingChannel_) != ESP_OK)
return false;
mcpwm_capture_event_callbacks_t callbacks = {};
callbacks.on_cap = onCapture;
if (mcpwm_capture_channel_register_event_callbacks(
risingChannel_, &callbacks, this) != ESP_OK) return false;
channelConfig.flags.pos_edge = false;
channelConfig.flags.neg_edge = true;
if (mcpwm_new_capture_channel(captureTimer_, &channelConfig, &fallingChannel_) != ESP_OK)
return false;
return mcpwm_capture_channel_register_event_callbacks(
fallingChannel_, &callbacks, this) == ESP_OK;
#else
pinMode(GPIO_RX, INPUT);
cpuTickHz_ = getCpuFrequencyMhz() * 1000000UL;
attachInterruptArg(GPIO_RX, onGpio, this, CHANGE);
@@ -47,178 +64,195 @@ bool PulseReceiver::begin() {
#endif
}
#if OPTICAL_USE_RMT_DMA
bool PulseReceiver::configureRmt(uint32_t resolutionHz) {
if (channel_ && captureResolutionHz_ == resolutionHz) return true;
stop();
if (channel_) {
if (rmt_del_channel(channel_) != ESP_OK) return false;
channel_ = nullptr;
}
rmt_rx_channel_config_t cfg = {};
cfg.clk_src = RMT_CLK_SRC_DEFAULT; cfg.resolution_hz = resolutionHz;
cfg.gpio_num = static_cast<gpio_num_t>(GPIO_RX);
// Internally the measurement code always treats HIGH as the active phase.
cfg.flags.invert_in = RX_ACTIVE_LEVEL == LOW;
#if CONFIG_IDF_TARGET_ESP32S3
cfg.mem_block_symbols = 512;
cfg.flags.with_dma = true;
#else
// C3 has 48 RMT symbols per channel and no RMT DMA. A request for 512
// consumes all available blocks and fails with "no free rx channels".
cfg.mem_block_symbols = RMT_MIN_RECEIVE_SYMBOLS;
cfg.flags.with_dma = false; // C3 uses hardware RMT ping-pong partial reception
#endif
if (rmt_new_rx_channel(&cfg, &channel_) != ESP_OK) return false;
rmt_rx_event_callbacks_t callbacks = {}; callbacks.on_recv_done = onRmt;
if (rmt_rx_register_event_callbacks(channel_, &callbacks, this) != ESP_OK) {
rmt_del_channel(channel_); channel_ = nullptr; return false;
}
captureResolutionHz_ = resolutionHz;
return true;
}
#endif
bool PulseReceiver::start(uint32_t expectedHz, float expectedDutyPct) {
#if OPTICAL_USE_RMT_DMA
const uint32_t resolutionHz = plannedTickHz(expectedHz, expectedDutyPct);
if (!configureRmt(resolutionHz)) return false;
#else
(void)expectedHz; (void)expectedDutyPct;
if (!plannedTickHz(expectedHz, expectedDutyPct)) return false;
expectedHz_ = expectedHz;
expectedDutyPct_ = expectedDutyPct;
#if !OPTICAL_USE_MCPWM_CAPTURE
cpuTickHz_ = getCpuFrequencyMhz() * 1000000UL;
if (!cpuTickHz_) return false;
#endif
resetStream();
#if OPTICAL_USE_RMT_DMA
// In partial RX mode the callback is delivered when this user buffer fills.
// Keep chunks near 5 ms so low-frequency input is reported before NO SIGNAL.
uint64_t symbols = (static_cast<uint64_t>(expectedHz) * RMT_TARGET_CHUNK_US + 999999ULL) / 1000000ULL;
if (symbols < RMT_MIN_RECEIVE_SYMBOLS) symbols = RMT_MIN_RECEIVE_SYMBOLS;
if (symbols > RMT_MAX_RECEIVE_SYMBOLS) symbols = RMT_MAX_RECEIVE_SYMBOLS;
receiveChunkSymbols_ = static_cast<uint16_t>(symbols);
if (rmt_enable(channel_) != ESP_OK) return false;
rmt_receive_config_t cfg = {};
cfg.signal_range_min_ns = 1000000000UL / captureResolutionHz_;
const uint64_t maxNs = 4000000000ULL / (expectedHz ? expectedHz : 1);
// A duration field is 15 bits. Keep the driver's end-of-signal threshold
// strictly below that hardware limit (IDF rejects larger values).
const uint64_t hardwareMaxNs = static_cast<uint64_t>(RMT_MAX_LEVEL_TICKS) * 1000000000ULL / captureResolutionHz_;
cfg.signal_range_max_ns = static_cast<uint32_t>(maxNs > hardwareMaxNs ? hardwareMaxNs : maxNs);
cfg.flags.en_partial_rx = true;
if (rmt_receive(channel_, receiveBuffer_,
receiveChunkSymbols_ * sizeof(receiveBuffer_[0]), &cfg) != ESP_OK) {
rmt_disable(channel_); return false;
#if OPTICAL_USE_MCPWM_CAPTURE
// Progress updates keep one capture session alive. Pulse-width stages stop
// capture only after PWM is quiet, so resetStream never races the ISR.
if (running_) return true;
if (mcpwm_capture_timer_enable(captureTimer_) != ESP_OK) return false;
if (mcpwm_capture_channel_enable(risingChannel_) != ESP_OK) {
mcpwm_capture_timer_disable(captureTimer_);
return false;
}
if (mcpwm_capture_channel_enable(fallingChannel_) != ESP_OK) {
mcpwm_capture_channel_disable(risingChannel_);
mcpwm_capture_timer_disable(captureTimer_);
return false;
}
running_ = true;
if (mcpwm_capture_timer_start(captureTimer_) != ESP_OK) {
running_ = false;
mcpwm_capture_channel_disable(fallingChannel_);
mcpwm_capture_channel_disable(risingChannel_);
mcpwm_capture_timer_disable(captureTimer_);
return false;
}
#else
running_ = true;
#endif
running_ = true; return true;
return true;
}
void PulseReceiver::stop() {
#if OPTICAL_USE_RMT_DMA
if (running_) rmt_disable(channel_);
#endif
const bool wasRunning = running_;
running_ = false;
#if OPTICAL_USE_MCPWM_CAPTURE
if (wasRunning) {
// Mask both edge interrupts before stopping the shared capture timer.
mcpwm_capture_channel_disable(fallingChannel_);
mcpwm_capture_channel_disable(risingChannel_);
mcpwm_capture_timer_stop(captureTimer_);
mcpwm_capture_timer_disable(captureTimer_);
}
#else
(void)wasRunning;
#endif
}
void PulseReceiver::resetStream() {
if (queue_) xQueueReset(queue_);
overflow_ = false; droppedItems_ = 0; haveRise_ = haveFall_ = haveRawTick_ = false;
lastRawTick_ = 0; tickEpoch_ = rise_ = fall_ = 0;
#if OPTICAL_USE_RMT_DMA
block_ = {}; blockIndex_ = 0; phase_ = 0; haveLevel_ = false; level_ = false; rmtTick_ = 0;
#endif
haveReorderEdge_ = false;
droppedItems_ = 0;
polarityKnown_ = false;
activeStartRising_ = false;
syncEdgeCount_ = 0;
waitingForActiveEnd_ = true;
activeStart_ = activeEnd_ = 0;
haveRawTick_ = false;
lastRawTick_ = 0;
tickEpoch_ = 0;
}
bool PulseReceiver::consumeEdge(const Edge &e, PulsePeriod &out) {
PulseReceiver::TimedEdge PulseReceiver::extendEdge(const Edge &e) {
if (haveRawTick_ && e.tick < lastRawTick_ && lastRawTick_ - e.tick > 0x80000000UL)
tickEpoch_ += 0x100000000ULL;
haveRawTick_ = true; lastRawTick_ = e.tick;
const uint64_t tick = tickEpoch_ + e.tick;
if (e.rising) {
if (!haveRise_) { rise_ = tick; haveRise_ = true; haveFall_ = false; return false; }
if (!haveFall_) { overflow_ = true; rise_ = tick; return false; }
const uint32_t period = static_cast<uint32_t>(tick - rise_);
const uint32_t active = fall_ - rise_;
out = {rise_, period, active}; rise_ = tick; haveFall_ = false;
return true;
}
// Reception can begin in the middle of a HIGH pulse. In that case the first
// observable edge is falling and there is no complete period to validate.
// Ignore only this leading partial pulse and synchronize on the next rise.
if (!haveRise_) return false;
if (haveFall_) { overflow_ = true; return false; }
fall_ = tick; haveFall_ = true; return false;
return {tickEpoch_ + e.tick, e.rising != 0};
}
bool PulseReceiver::overflowed() {
const bool value = overflow_; overflow_ = false; return value;
bool PulseReceiver::consumeEdge(const Edge &rawEdge, PulsePeriod &out) {
const TimedEdge edge = extendEdge(rawEdge);
if (polarityKnown_) {
// Deliberately ignore edge type after synchronization. A PWM waveform is
// just alternating intervals: active, inactive, active, inactive. An
// extra or missing edge therefore becomes a concrete wrong pulse/period
// instead of an ambiguous GLITCH state.
if (waitingForActiveEnd_) {
activeEnd_ = edge.tick;
waitingForActiveEnd_ = false;
return false;
}
const uint64_t periodTicks = edge.tick - activeStart_;
const uint64_t activeTicks = activeEnd_ - activeStart_;
out = {activeStart_, static_cast<uint32_t>(periodTicks),
static_cast<uint32_t>(activeTicks), tickHz()};
activeStart_ = edge.tick;
waitingForActiveEnd_ = true;
return true;
}
syncEdges_[syncEdgeCount_++] = edge;
if (syncEdgeCount_ < 3U) return false;
const uint64_t firstTicks = syncEdges_[1].tick - syncEdges_[0].tick;
const uint64_t secondTicks = syncEdges_[2].tick - syncEdges_[1].tick;
const double expectedTicks = static_cast<double>(tickHz()) * expectedDutyPct_ /
(100.0 * expectedHz_);
const double firstError = fabs(static_cast<double>(firstTicks) - expectedTicks);
const double secondError = fabs(static_cast<double>(secondTicks) - expectedTicks);
activeStartRising_ = firstError <= secondError ? syncEdges_[0].rising : syncEdges_[1].rising;
polarityKnown_ = true;
Log::printf("CAPTURE", "RX polarity auto: active starts on %s, first=%lluns second=%lluns",
activeStartRising_ ? "RISING" : "FALLING",
static_cast<unsigned long long>(firstTicks * 1000000000ULL / tickHz()),
static_cast<unsigned long long>(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<uint32_t>(periodTicks),
static_cast<uint32_t>(activeEnd_ - activeStart_), tickHz()};
activeStart_ = syncEdges_[2].tick;
waitingForActiveEnd_ = true;
produced = true;
} else {
activeStart_ = syncEdges_[1].tick;
activeEnd_ = syncEdges_[2].tick;
waitingForActiveEnd_ = false;
}
syncEdgeCount_ = 0;
return produced;
}
uint32_t PulseReceiver::takeDroppedItems() {
return __atomic_exchange_n(&droppedItems_, 0, __ATOMIC_RELAXED);
}
#if OPTICAL_USE_RMT_DMA
bool IRAM_ATTR PulseReceiver::onRmt(rmt_channel_handle_t, const rmt_rx_done_event_data_t *data, void *ctx) {
#if OPTICAL_USE_MCPWM_CAPTURE
bool IRAM_ATTR PulseReceiver::onCapture(mcpwm_cap_channel_handle_t,
const mcpwm_capture_event_data_t *data,
void *ctx) {
PulseReceiver *self = static_cast<PulseReceiver *>(ctx);
if (!self->running_) return false;
const bool rawRising = data->cap_edge == MCPWM_CAP_EDGE_POS;
const Edge edge = {data->cap_value, static_cast<uint8_t>(rawRising)};
BaseType_t wake = pdFALSE;
size_t offset = 0;
while (offset < data->num_symbols) {
SymbolBlock &b = self->isrBlock_;
b.count = static_cast<uint16_t>((data->num_symbols - offset) > BLOCK_SYMBOLS ?
BLOCK_SYMBOLS : (data->num_symbols - offset));
memcpy(b.symbols, data->received_symbols + offset, b.count * sizeof(rmt_symbol_word_t));
if (xQueueSendFromISR(self->queue_, &b, &wake) != pdTRUE)
__atomic_fetch_add(&self->droppedItems_, b.count, __ATOMIC_RELAXED);
offset += b.count;
}
if (xQueueSendFromISR(self->queue_, &edge, &wake) != pdTRUE)
__atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED);
return wake == pdTRUE;
}
bool PulseReceiver::nextRmtEdge(Edge &edge, TickType_t waitTicks) {
for (;;) {
if (blockIndex_ >= block_.count) {
if (xQueueReceive(queue_, &block_, waitTicks) != pdTRUE) return false;
blockIndex_ = 0; phase_ = 0;
waitTicks = 0;
}
const rmt_symbol_word_t &s = block_.symbols[blockIndex_];
const bool nextLevel = phase_ == 0 ? s.level0 : s.level1;
const uint32_t duration = phase_ == 0 ? s.duration0 : s.duration1;
phase_ ^= 1;
if (phase_ == 0) ++blockIndex_;
if (!duration) continue;
if (!haveLevel_) { haveLevel_ = true; level_ = nextLevel; rmtTick_ += duration; continue; }
if (nextLevel != level_) {
level_ = nextLevel; edge = {rmtTick_, static_cast<uint8_t>(nextLevel)};
rmtTick_ += duration; return true;
}
rmtTick_ += duration;
}
}
size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks) {
size_t count = 0;
Edge e;
while (count < capacity && nextRmtEdge(e, count ? 0 : waitTicks))
if (consumeEdge(e, periods[count])) ++count;
return count;
}
#else
void IRAM_ATTR PulseReceiver::onGpio(void *ctx) {
PulseReceiver *self = static_cast<PulseReceiver *>(ctx);
if (!self->running_) return;
bool level = gpio_get_level(static_cast<gpio_num_t>(GPIO_RX));
if (RX_ACTIVE_LEVEL == LOW) level = !level;
Edge e = {esp_cpu_get_cycle_count(), static_cast<uint8_t>(level)};
const Edge edge = {esp_cpu_get_cycle_count(), static_cast<uint8_t>(level)};
BaseType_t wake = pdFALSE;
if (xQueueSendFromISR(self->queue_, &e, &wake) != pdTRUE)
if (xQueueSendFromISR(self->queue_, &edge, &wake) != pdTRUE)
__atomic_fetch_add(&self->droppedItems_, 1U, __ATOMIC_RELAXED);
if (wake) portYIELD_FROM_ISR();
}
#endif
size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks) {
bool PulseReceiver::nextOrderedEdge(Edge &edge, TickType_t waitTicks) {
if (!haveReorderEdge_) {
if (xQueueReceive(queue_, &reorderEdge_, waitTicks) != pdTRUE) return false;
haveReorderEdge_ = true;
}
Edge next = {};
// Keep one-event look-ahead. If both channel interrupts were pending while
// OLED/I2C ran, the MCPWM driver may dispatch them by channel number rather
// than timestamp. The signed modular comparison restores their real order.
if (xQueueReceive(queue_, &next, waitTicks) != pdTRUE) return false;
if (static_cast<int32_t>(next.tick - reorderEdge_.tick) < 0) {
edge = next;
} else {
edge = reorderEdge_;
reorderEdge_ = next;
}
return true;
}
size_t PulseReceiver::readPeriods(PulsePeriod *periods, size_t capacity,
TickType_t waitTicks) {
size_t count = 0;
Edge e;
while (count < capacity && xQueueReceive(queue_, &e, count ? 0 : waitTicks) == pdTRUE)
if (consumeEdge(e, periods[count])) ++count;
Edge edge = {};
while (count < capacity && nextOrderedEdge(edge, count ? 0 : waitTicks)) {
if (consumeEdge(edge, periods[count])) {
periods[count].activeTickHz = tickHz();
++count;
}
}
return count;
}
#endif

View File

@@ -1,15 +1,15 @@
#pragma once
#include <Arduino.h>
#include <esp_idf_version.h>
#include <driver/gpio.h>
#include "Config.h"
#include "Core.h"
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
#define OPTICAL_USE_RMT_DMA 1
#include <driver/rmt_rx.h>
#if CONFIG_IDF_TARGET_ESP32S3 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
#define OPTICAL_USE_MCPWM_CAPTURE 1
#include <driver/mcpwm_cap.h>
#else
#define OPTICAL_USE_RMT_DMA 0
#include <driver/gpio.h>
#define OPTICAL_USE_MCPWM_CAPTURE 0
#endif
class PulseReceiver {
@@ -19,48 +19,46 @@ class PulseReceiver {
void stop();
void resetStream();
size_t readPeriods(PulsePeriod *periods, size_t capacity, TickType_t waitTicks = 0);
bool overflowed();
uint32_t takeDroppedItems();
uint32_t tickHz() const;
uint32_t pulseTickHz() const { return tickHz(); }
uint32_t plannedTickHz(uint32_t expectedHz, float expectedDutyPct) const;
uint16_t receiveChunkSymbols() const { return receiveChunkSymbols_; }
bool highRateBackend() const {
#if OPTICAL_USE_RMT_DMA && CONFIG_IDF_TARGET_ESP32S3
return true;
#else
return false;
#endif
uint32_t plannedPulseTickHz(uint32_t expectedHz, float expectedDutyPct) const {
return plannedTickHz(expectedHz, expectedDutyPct);
}
uint16_t receiveChunkSymbols() const { return 1; }
bool highRateBackend() const { return OPTICAL_USE_MCPWM_CAPTURE; }
private:
struct Edge { uint32_t tick; uint8_t rising; };
struct TimedEdge { uint64_t tick; bool rising; };
bool consumeEdge(const Edge &edge, PulsePeriod &period);
#if OPTICAL_USE_RMT_DMA
static constexpr size_t BLOCK_SYMBOLS = RMT_MAX_RECEIVE_SYMBOLS;
struct SymbolBlock { uint16_t count; rmt_symbol_word_t symbols[BLOCK_SYMBOLS]; };
static bool IRAM_ATTR onRmt(rmt_channel_handle_t, const rmt_rx_done_event_data_t *, void *);
bool configureRmt(uint32_t resolutionHz);
bool nextRmtEdge(Edge &edge, TickType_t waitTicks);
rmt_channel_handle_t channel_ = nullptr;
bool nextOrderedEdge(Edge &edge, TickType_t waitTicks);
TimedEdge extendEdge(const Edge &edge);
#if OPTICAL_USE_MCPWM_CAPTURE
static bool IRAM_ATTR onCapture(mcpwm_cap_channel_handle_t,
const mcpwm_capture_event_data_t *, void *);
mcpwm_cap_timer_handle_t captureTimer_ = nullptr;
mcpwm_cap_channel_handle_t risingChannel_ = nullptr;
mcpwm_cap_channel_handle_t fallingChannel_ = nullptr;
uint32_t captureResolutionHz_ = 0;
rmt_symbol_word_t receiveBuffer_[RMT_MAX_RECEIVE_SYMBOLS];
uint16_t receiveChunkSymbols_ = 0;
SymbolBlock isrBlock_ = {};
SymbolBlock block_ = {};
uint16_t blockIndex_ = 0;
uint8_t phase_ = 0;
bool haveLevel_ = false;
bool level_ = false;
uint32_t rmtTick_ = 0;
#else
static void IRAM_ATTR onGpio(void *ctx);
uint32_t cpuTickHz_ = 0;
#endif
QueueHandle_t queue_ = nullptr;
volatile bool overflow_ = false;
Edge reorderEdge_ = {};
bool haveReorderEdge_ = false;
volatile uint32_t droppedItems_ = 0;
bool running_ = false;
bool haveRise_ = false, haveFall_ = false, haveRawTick_ = false;
volatile bool running_ = false;
uint32_t expectedHz_ = 0;
float expectedDutyPct_ = 50.0f;
bool polarityKnown_ = false, activeStartRising_ = false;
TimedEdge syncEdges_[3] = {};
uint8_t syncEdgeCount_ = 0;
bool waitingForActiveEnd_ = true;
uint64_t activeStart_ = 0, activeEnd_ = 0;
bool haveRawTick_ = false;
uint32_t lastRawTick_ = 0;
uint64_t tickEpoch_ = 0, rise_ = 0, fall_ = 0;
uint64_t tickEpoch_ = 0;
};

View File

@@ -3,20 +3,25 @@
#include "Log.h"
#include <Preferences.h>
namespace { constexpr uint16_t SETTINGS_VERSION = 4; constexpr char NAMESPACE[] = "opt-test"; }
namespace { constexpr uint16_t SETTINGS_VERSION = 9; constexpr char NAMESPACE[] = "opt-test"; }
void SettingsStore::defaults(Settings &s) const {
s = {SETTINGS_VERSION, static_cast<uint8_t>(Role::SOLO), 0, 4, 2, 3, 0, 0};
// 2 kHz, 200 us .. 2 us, 5%, 1 s.
s = {SETTINGS_VERSION, static_cast<uint8_t>(Role::SOLO), 2, 3, 3, 2, 3, 0};
s.checksum = settingsChecksum(s);
}
bool SettingsStore::valid(const Settings &s) const {
return s.version == SETTINGS_VERSION && s.role <= static_cast<uint8_t>(Role::SLAVE) &&
s.startIndex < countOf(START_FREQ_OPTIONS_HZ) && s.endIndex < countOf(END_FREQ_OPTIONS_HZ) &&
s.frequencyIndex < countOf(PWM_FREQUENCY_OPTIONS_HZ) &&
s.maxPulseIndex < countOf(MAX_PULSE_OPTIONS_NS) &&
s.minPulseIndex < countOf(MIN_PULSE_OPTIONS_NS) &&
MAX_PULSE_OPTIONS_NS[s.maxPulseIndex] >= MIN_PULSE_OPTIONS_NS[s.minPulseIndex] &&
static_cast<uint64_t>(MAX_PULSE_OPTIONS_NS[s.maxPulseIndex]) *
PWM_FREQUENCY_OPTIONS_HZ[s.frequencyIndex] < 1000000000ULL &&
s.accuracyIndex < countOf(ACCURACY_OPTIONS_PCT) &&
s.timeIndex < countOf(TEST_TIME_OPTIONS_MS) &&
s.checksum == settingsChecksum(s) &&
END_FREQ_OPTIONS_HZ[s.endIndex] > START_FREQ_OPTIONS_HZ[s.startIndex];
s.checksum == settingsChecksum(s);
}
bool SettingsStore::load(Settings &s) {
@@ -39,7 +44,7 @@ bool SettingsStore::save(Settings &s) {
}
TestParams SettingsStore::params(const Settings &s) const {
return {START_FREQ_OPTIONS_HZ[s.startIndex], END_FREQ_OPTIONS_HZ[s.endIndex],
ACCURACY_OPTIONS_PCT[s.accuracyIndex], TEST_TIME_OPTIONS_MS[s.timeIndex],
TEST_DUTY_PCT};
return {PWM_FREQUENCY_OPTIONS_HZ[s.frequencyIndex],
MAX_PULSE_OPTIONS_NS[s.maxPulseIndex], MIN_PULSE_OPTIONS_NS[s.minPulseIndex],
ACCURACY_OPTIONS_PCT[s.accuracyIndex], TEST_TIME_OPTIONS_MS[s.timeIndex]};
}

File diff suppressed because one or more lines are too long