запущен тест между есп с перемычкой

This commit is contained in:
2026-08-07 16:04:30 +03:00
parent 4b88ac6a42
commit a8099bf2b8
14 changed files with 449 additions and 121 deletions

View File

@@ -4,12 +4,13 @@
#include <WiFi.h>
#include <esp_mac.h>
#include <esp_system.h>
#include <math.h>
#include <string.h>
namespace {
const char *appStateName(AppState state) {
static const char *names[] = {"IDLE", "MENU", "SOLO_MEASURE", "MASTER_DISCOVER",
"MASTER_WAIT_READY", "MASTER_WAIT_RESULT", "SLAVE_READY", "SLAVE_WAIT_START",
"MASTER_WAIT_READY", "MASTER_WAIT_RESULT", "MASTER_FINALIZE", "SLAVE_READY", "SLAVE_WAIT_START",
"SLAVE_MEASURE", "SLAVE_WAIT_ACK", "FINISHED"};
const uint8_t index = static_cast<uint8_t>(state);
return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN";
@@ -20,6 +21,11 @@ const char *buttonEventName(ButtonEvent event) {
const uint8_t index = static_cast<uint8_t>(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);
}
}
App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), measurement_(receiver_) {}
@@ -81,7 +87,7 @@ void App::update() {
state_ = AppState::MENU; menuItem_ = 0; Log::event("ACTION", "settings menu entered"); showMenu();
} else if (startEvent == ButtonEvent::SHORT) { Log::event("ACTION", "test start requested"); startTest(); }
else if (state_ == AppState::FINISHED && static_cast<Role>(settings_.role) == Role::SLAVE &&
now >= slaveRearmAtMs_) armSlave();
now >= slaveRearmAtMs_) armSlave(pendingReason_ != FailReason::NONE);
return;
}
if (state_ == AppState::SLAVE_READY && modeEvent != ButtonEvent::NONE) {
@@ -111,13 +117,22 @@ void App::update() {
}
if (state_ == AppState::SOLO_MEASURE) {
const MeasureState ms = measurement_.update();
if (ms == MeasureState::FAIL) { printStageStats(measurement_.stats(), actual_.actualHz); finish(false, measurement_.reason()); }
if (ms == MeasureState::FAIL) {
printStageStats(measurement_.stats(), actual_.actualHz);
showStageResult(measurement_.stats());
finish(false, measurement_.reason(), true);
}
else if (ms == MeasureState::PASS) {
printStageStats(measurement_.stats(), actual_.actualHz);
showStageResult(measurement_.stats());
stagePassed();
} else if (ms == MeasureState::STEP_READY) {
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_WAIT_RESULT || state_ == AppState::MASTER_FINALIZE) {
handleRadio(); updateMaster();
} else {
handleRadio(); updateSlave();
@@ -195,7 +210,7 @@ void App::startTest() {
else { state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave armed and waiting for Master"); display_.show("SLAVE READY", "WAIT MASTER"); }
}
bool App::armSlave() {
bool App::armSlave(bool preserveDisplay) {
params_ = store_.params(settings_);
stageIndex_ = 0; stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz);
requestedHz_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false;
@@ -208,12 +223,13 @@ bool App::armSlave() {
}
radio_.flush(); state_ = AppState::SLAVE_READY;
Log::event("TEST", "Slave automatically armed and waiting for Master");
display_.show("SLAVE READY", "WAIT MASTER");
if (!preserveDisplay) display_.show("SLAVE READY", "WAIT MASTER");
return true;
}
bool App::prepareStage() {
bool App::prepareStage(bool showProgress) {
requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, 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; }
@@ -232,9 +248,7 @@ bool App::prepareStage() {
}
Log::printf("PWM", "stage=%lu/%lu requested=%luHz actual=%luHz duty=%.2f%% bits=%u STARTED",
stageIndex_ + 1, stageCount_, requestedHz_, actual_.actualHz, actual_.actualDutyPct, actual_.bits);
char f[12], one[24], two[24]; Display::formatFrequency(actual_.actualHz, f, sizeof(f));
snprintf(one, sizeof(one), "F %s D %.1f%%", f, actual_.actualDutyPct);
snprintf(two, sizeof(two), "%lu/%lu RUN", stageIndex_ + 1, stageCount_); display_.show(one, two);
if (showProgress) showStageProgress();
if (static_cast<Role>(settings_.role) == Role::SOLO && !startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) {
finish(false, FailReason::UNSUPPORTED); return false;
}
@@ -257,6 +271,8 @@ void App::stagePassed() {
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, params_.stepHz, stageIndex_);
actual_ = {};
stageStartConfirmed_ = false;
pendingPacket_ = makePacket(MessageType::PREPARE); sendCurrent(MessageType::PREPARE);
state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS;
}
@@ -273,9 +289,11 @@ void App::startMasterDiscovery() {
ProtocolPacket App::makePacket(MessageType type) const {
ProtocolPacket p = {};
p.type = static_cast<uint8_t>(type); p.session = session_; p.stage = stageIndex_; p.sequence = sequence_;
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;
p.actualDutyX100 = static_cast<uint16_t>(actual_.actualDutyPct * 100.0f + 0.5f);
const float packetDuty = actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct;
p.actualDutyX100 = static_cast<uint16_t>(packetDuty * 100.0f + 0.5f);
p.testTimeMs = params_.testTimeMs; p.repeats = params_.repeats;
p.accuracyX100 = static_cast<uint16_t>(params_.accuracyPct * 100.0f + 0.5f); p.settleCycles = PWM_SETTLE_CYCLES;
return p;
@@ -283,13 +301,19 @@ ProtocolPacket App::makePacket(MessageType type) const {
void App::sendCurrent(MessageType type) {
++sequence_; pendingPacket_ = makePacket(type);
const bool ok = radio_.sendBroadcast(pendingPacket_); lastSendMs_ = millis();
const bool ok = sendLinked(pendingPacket_); lastSendMs_ = millis();
if (!ok) Log::printf("ESP-NOW", "sendCurrent %s FAILED", messageName(type));
}
bool App::sendLinked(ProtocolPacket packet) {
return havePeer_ ? radio_.sendTo(peer_, packet) : radio_.sendBroadcast(packet);
}
void App::updateHeartbeat() {
if (!havePeer_ || state_ == AppState::FINISHED) return;
const uint32_t now = millis();
const uint32_t radioRxMs = radio_.lastReceiveMs();
if (radioRxMs && now - radioRxMs < now - lastPeerSeenMs_) lastPeerSeenMs_ = radioRxMs;
if (now - lastPeerSeenMs_ >= LINK_HEARTBEAT_TIMEOUT_MS) {
Log::event("ESP-NOW", "peer heartbeat timeout");
finish(false, FailReason::LINK_LOST);
@@ -299,7 +323,7 @@ void App::updateHeartbeat() {
now - lastHeartbeatMs_ >= LINK_HEARTBEAT_INTERVAL_MS) {
ProtocolPacket heartbeat = makePacket(MessageType::HEARTBEAT);
heartbeat.sequence = sequence_;
radio_.sendBroadcast(heartbeat);
sendLinked(heartbeat);
lastHeartbeatMs_ = now;
}
}
@@ -312,7 +336,7 @@ void App::handleRadio() {
ReceivedPacket r;
while (radio_.receive(r)) {
const MessageType type = static_cast<MessageType>(r.packet.type);
if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK &&
if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK && type != MessageType::PROGRESS &&
state_ != AppState::SLAVE_MEASURE)
Log::printf("ESP-NOW", "RX %s session=%08lX stage=%u seq=%u",
messageName(type), r.packet.session, r.packet.stage, r.packet.sequence);
@@ -320,7 +344,7 @@ void App::handleRadio() {
type == MessageType::DISCOVER && (!havePeer_ || !memcmp(peer_, r.mac, 6))) {
memcpy(peer_, r.mac, 6); havePeer_ = true; session_ = r.packet.session; stageIndex_ = 0; sequence_ = r.packet.sequence;
lastPeerSeenMs_ = millis();
ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; radio_.sendBroadcast(ack);
ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; sendLinked(ack);
state_ = AppState::SLAVE_WAIT_START; display_.show("MASTER SEEN", "ACK SENT"); continue;
}
if (state_ == AppState::MASTER_DISCOVER && type == MessageType::DISCOVER_ACK && r.packet.session == session_) {
@@ -332,37 +356,97 @@ void App::handleRadio() {
if (havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_) lastPeerSeenMs_ = millis();
if (havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_ &&
type == MessageType::HEARTBEAT) {
ProtocolPacket ack = makePacket(MessageType::HEARTBEAT_ACK);
ack.sequence = r.packet.sequence; radio_.sendBroadcast(ack); continue;
continue; // Radio's priority heartbeat task has already sent the ACK.
}
if (type == MessageType::HEARTBEAT_ACK) continue;
if (havePeer_ && !memcmp(peer_, r.mac, 6) && type == MessageType::RESULT &&
r.packet.session == session_ && r.packet.stage < stageIndex_) {
ProtocolPacket ack = {}; ack.type = static_cast<uint8_t>(MessageType::ACK);
ack.session = session_; ack.stage = r.packet.stage; ack.sequence = r.packet.sequence;
radio_.sendBroadcast(ack); continue; // idempotent ACK for a retried old result
sendLinked(ack); continue; // idempotent ACK for a retried old result
}
if (!havePeer_ || memcmp(peer_, r.mac, 6) || !packetForCurrent(r.packet)) continue;
if (type == MessageType::ABORT) { finish(false, FailReason::ABORTED); continue; }
if (state_ == AppState::MASTER_WAIT_READY && type == MessageType::READY) {
if (!prepareStage()) continue;
sendCurrent(MessageType::START_STAGE); state_ = AppState::MASTER_WAIT_RESULT; retries_ = 0; deadlineMs_ = millis() +
params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS + (1000UL * PWM_SETTLE_CYCLES / actual_.actualHz) + 20;
} else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::RESULT) {
ProtocolPacket ack = makePacket(MessageType::ACK); ack.sequence = r.packet.sequence; radio_.sendBroadcast(ack); pwm_.stop();
if (!r.packet.passed) finish(false, static_cast<FailReason>(r.packet.reason)); else stagePassed();
} else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::PREPARE) {
const bool matchingPeer = havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_;
if (matchingPeer && type == MessageType::PREPARE) {
const bool expected = state_ == AppState::SLAVE_WAIT_START && r.packet.stage == stageIndex_;
const bool implicitAck = state_ == AppState::SLAVE_WAIT_ACK && pendingPacket_.passed &&
r.packet.stage == static_cast<uint16_t>(pendingPacket_.stage + 1U);
if (expected || implicitAck) {
stageIndex_ = r.packet.stage;
sequence_ = r.packet.sequence;
state_ = AppState::SLAVE_WAIT_START;
params_.testTimeMs = r.packet.testTimeMs; params_.repeats = r.packet.repeats;
params_.accuracyPct = r.packet.accuracyX100 / 100.0f; requestedHz_ = r.packet.requestedHz;
ProtocolPacket ready = makePacket(MessageType::READY); ready.sequence = r.packet.sequence; radio_.sendBroadcast(ready);
display_.show("SLAVE LINKED", "MASTER ONLINE");
stageCount_ = r.packet.stageCount;
actual_ = {};
ProtocolPacket ready = makePacket(MessageType::READY);
ready.sequence = r.packet.sequence; sendLinked(ready);
}
continue;
}
if (!havePeer_ || memcmp(peer_, r.mac, 6) || !packetForCurrent(r.packet)) continue;
if (type == MessageType::ABORT) {
const FailReason reason = r.packet.reason > static_cast<uint8_t>(FailReason::NONE) &&
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;
actual_.actualHz = r.packet.actualHz ? r.packet.actualHz : requestedHz_;
actual_.actualDutyPct = r.packet.actualDutyX100 ? r.packet.actualDutyX100 / 100.0f : params_.dutyPct;
measurement_.abort(); finish(false, reason); continue;
}
if (state_ == AppState::MASTER_WAIT_READY && type == MessageType::READY) {
if (!prepareStage(false)) continue;
sendCurrent(MessageType::START_STAGE); state_ = AppState::MASTER_WAIT_RESULT;
stageStartConfirmed_ = false; retries_ = 0;
deadlineMs_ = millis() + LINK_RETRY_INTERVAL_MS;
} else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::READY &&
r.packet.sequence == pendingPacket_.sequence) {
if (!stageStartConfirmed_) showStageProgress();
stageStartConfirmed_ = true;
deadlineMs_ = millis() + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS +
(1000UL * PWM_SETTLE_CYCLES / actual_.actualHz) + 20;
} else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::PROGRESS) {
stageStartConfirmed_ = true;
deadlineMs_ = millis() + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS;
showRemoteResult(r.packet);
} else if (state_ == AppState::MASTER_WAIT_RESULT && type == MessageType::RESULT) {
ProtocolPacket ack = makePacket(MessageType::ACK); ack.sequence = r.packet.sequence;
ack.passed = r.packet.passed && stageIndex_ + 1U >= stageCount_;
sendLinked(ack); pwm_.stop();
showRemoteResult(r.packet);
if (!r.packet.passed) {
finish(false, static_cast<FailReason>(r.packet.reason), true);
} else if (ack.passed) {
pendingPacket_ = ack;
state_ = AppState::MASTER_FINALIZE; retries_ = 0;
deadlineMs_ = millis() + FINAL_ACK_RETRY_INTERVAL_MS;
} else stagePassed();
} else if (state_ == AppState::MASTER_FINALIZE && type == MessageType::RESULT) {
// The Slave did not receive the final ACK and repeated RESULT.
sendLinked(pendingPacket_);
} else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) {
sequence_ = r.packet.sequence;
actual_.actualHz = r.packet.actualHz; actual_.actualDutyPct = r.packet.actualDutyX100 / 100.0f;
if (!startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); continue; }
showStageProgress();
state_ = AppState::SLAVE_MEASURE; deadlineMs_ = millis() + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS;
ProtocolPacket started = makePacket(MessageType::READY);
started.sequence = r.packet.sequence; sendLinked(started);
} else if (state_ == AppState::SLAVE_MEASURE && type == MessageType::START_STAGE) {
// START_STAGE or its acknowledgement was lost. Do not restart the
// measurement; only confirm the already running stage again.
ProtocolPacket started = makePacket(MessageType::READY);
started.sequence = r.packet.sequence; sendLinked(started);
} else if (state_ == AppState::SLAVE_WAIT_ACK && type == MessageType::ACK && r.packet.sequence == pendingPacket_.sequence) {
if (pendingPacket_.passed) { ++stageIndex_; state_ = AppState::SLAVE_WAIT_START; display_.show("SLAVE READY", "WAIT PREPARE"); }
else finish(false, static_cast<FailReason>(pendingPacket_.reason));
if (pendingPacket_.passed) {
if (r.packet.passed) {
radio_.end(); pendingReason_ = FailReason::NONE;
if (armSlave(true)) display_.show("SLAVE PASS", "WAIT MASTER");
} else {
stageIndex_ = static_cast<uint32_t>(r.packet.stage) + 1U;
state_ = AppState::SLAVE_WAIT_START;
}
}
else finish(false, static_cast<FailReason>(pendingPacket_.reason), true);
}
}
}
@@ -376,14 +460,23 @@ void App::updateMaster() {
}
return;
}
if (state_ == AppState::MASTER_FINALIZE) {
if (now < deadlineMs_) return;
if (retries_++ < FINAL_ACK_RETRIES) {
sendLinked(pendingPacket_);
deadlineMs_ = now + FINAL_ACK_RETRY_INTERVAL_MS;
} else finish(true, FailReason::NONE);
return;
}
updateHeartbeat();
if (state_ == AppState::FINISHED) return;
if (now < deadlineMs_) return;
if (retries_ >= LINK_PACKET_RETRIES) { finish(false, FailReason::LINK_LOST); return; }
Log::printf("ESP-NOW", "%s retry=%u", messageName(static_cast<MessageType>(pendingPacket_.type)), retries_ + 1);
radio_.sendBroadcast(pendingPacket_); ++retries_;
sendLinked(pendingPacket_); ++retries_;
deadlineMs_ = now + (state_ == AppState::MASTER_WAIT_RESULT ?
params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS : LINK_REPLY_TIMEOUT_MS);
(stageStartConfirmed_ ? params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS : LINK_RETRY_INTERVAL_MS) :
LINK_REPLY_TIMEOUT_MS);
}
void App::updateSlave() {
@@ -391,35 +484,62 @@ void App::updateSlave() {
if (state_ == AppState::FINISHED) return;
if (state_ == AppState::SLAVE_MEASURE) {
const MeasureState ms = measurement_.update();
if (ms != MeasureState::PASS && ms != MeasureState::FAIL) return;
if (ms == MeasureState::STEP_READY) {
StageStats live = {};
if (measurement_.statsSnapshot(live)) {
ProtocolPacket progress = makePacket(MessageType::PROGRESS);
fillMeasuredResult(progress, live);
progress.sequence = sequence_; sendLinked(progress);
// oled.display() is synchronous. Resume capture only after the full
// framebuffer has reached the display.
showStageResult(live);
}
measurement_.continueAfterDisplay();
return;
}
if (ms != MeasureState::PASS && ms != MeasureState::FAIL) {
return;
}
printStageStats(measurement_.stats(), actual_.actualHz);
showStageResult(measurement_.stats());
pendingPacket_ = makePacket(MessageType::RESULT);
pendingPacket_.passed = ms == MeasureState::PASS && measurement_.reason() == FailReason::NONE;
pendingPacket_.reason = static_cast<uint8_t>(measurement_.reason()); pendingPacket_.periods = measurement_.stats().periods;
fillMeasuredResult(pendingPacket_, measurement_.stats());
pendingPacket_.minPeriodTicks = measurement_.stats().minPeriod; pendingPacket_.maxPeriodTicks = measurement_.stats().maxPeriod;
pendingPacket_.sequence = ++sequence_; radio_.sendBroadcast(pendingPacket_);
pendingPacket_.sequence = ++sequence_; sendLinked(pendingPacket_);
Log::printf("TEST", "Slave result prepared: %s reason=%s periods=%lu",
pendingPacket_.passed ? "PASS" : "FAIL", failName(static_cast<FailReason>(pendingPacket_.reason)), pendingPacket_.periods);
state_ = AppState::SLAVE_WAIT_ACK; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS;
} else if (state_ == AppState::SLAVE_WAIT_ACK && millis() >= deadlineMs_) {
if (retries_++ >= LINK_PACKET_RETRIES) finish(false, FailReason::LINK_LOST);
else { Log::printf("ESP-NOW", "RESULT retry=%u", retries_); radio_.sendBroadcast(pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; }
else { Log::printf("ESP-NOW", "RESULT retry=%u", retries_); sendLinked(pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; }
}
}
void App::sendAbort() { if (havePeer_) sendCurrent(MessageType::ABORT); }
void App::sendAbort(FailReason reason) {
if (!havePeer_) return;
++sequence_;
ProtocolPacket packet = makePacket(MessageType::ABORT);
packet.reason = static_cast<uint8_t>(reason);
if (!packet.actualDutyX100) packet.actualDutyX100 = params_.dutyPct * 100U;
sendLinked(packet);
}
void App::abortTest() {
Log::event("ACTION", "abort requested: sending ABORT, stopping receiver and PWM");
sendAbort(); measurement_.abort(); finish(false, FailReason::ABORTED);
sendAbort(FailReason::ABORTED); measurement_.abort(); finish(false, FailReason::ABORTED);
}
void App::finish(bool pass, FailReason reason) {
void App::finish(bool pass, FailReason reason, bool preserveDisplay) {
Log::printf("TEST", "finishing result=%s reason=%s", pass ? "PASS" : "FAIL", failName(reason));
const AppState failedState = state_;
const bool masterLinkLost = reason == FailReason::LINK_LOST &&
(failedState == AppState::MASTER_DISCOVER || failedState == AppState::MASTER_WAIT_READY ||
failedState == AppState::MASTER_WAIT_RESULT);
failedState == AppState::MASTER_WAIT_RESULT || failedState == AppState::MASTER_FINALIZE);
const bool masterActive = failedState == AppState::MASTER_DISCOVER ||
failedState == AppState::MASTER_WAIT_READY || failedState == AppState::MASTER_WAIT_RESULT ||
failedState == AppState::MASTER_FINALIZE;
const bool slaveLinkLost = reason == FailReason::LINK_LOST &&
(failedState == AppState::SLAVE_READY || failedState == AppState::SLAVE_WAIT_START ||
failedState == AppState::SLAVE_MEASURE || failedState == AppState::SLAVE_WAIT_ACK);
@@ -429,18 +549,32 @@ void App::finish(bool pass, FailReason reason) {
startMasterDiscovery();
return;
}
if (!pass && masterActive && havePeer_ && reason != FailReason::ABORTED) sendAbort(reason);
if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end();
state_ = AppState::FINISHED; pendingReason_ = reason;
if (slaveLinkLost) {
if (armSlave()) display_.show("MASTER LOST", "WAIT MASTER");
char target[12], one[24];
Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, target, sizeof(target));
snprintf(one, sizeof(one), "FAIL %s %.0f%%", target,
actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct);
display_.show(one, failName(reason), stageIndex_ + 1, stageCount_);
armSlave(true);
return;
}
if (static_cast<Role>(settings_.role) == Role::SLAVE) slaveRearmAtMs_ = millis() + 2000;
if (preserveDisplay) return;
char one[24];
if (pass) display_.show("PASS", "REPEAT");
if (pass) {
const Role role = static_cast<Role>(settings_.role);
snprintf(one, sizeof(one), "%s PASS", roleName(role));
display_.show(one, role == Role::SLAVE ? "WAIT MASTER" : "START=REPEAT");
}
else if (requestedHz_) {
char frequency[12]; Display::formatFrequency(requestedHz_, frequency, sizeof(frequency));
snprintf(one, sizeof(one), "FAIL %s", frequency); display_.show(one, failName(reason));
char frequency[12];
Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, frequency, sizeof(frequency));
snprintf(one, sizeof(one), "FAIL %s %.0f%%", frequency,
actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct);
display_.show(one, failName(reason), stageIndex_ + 1, stageCount_);
} else {
display_.show("TEST FAILED", failName(reason));
}
@@ -481,3 +615,85 @@ void App::printStageStats(const StageStats &s, uint32_t hz) {
requestedText, status, s.periods, measuredText, measuredDuty, s.droppedItems,
s.reason == FailReason::NONE ? "" : " reason=", s.reason == FailReason::NONE ? "" : failName(s.reason));
}
void App::showStageResult(const StageStats &s) {
char one[24], two[24];
char target[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target));
if (s.reason != FailReason::NONE) {
snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, actual_.actualDutyPct);
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));
snprintf(two, sizeof(two), "PERIOD OUT %s", 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), "DUTY OUT %s", duty);
} else {
snprintf(two, sizeof(two), "%s", failName(s.reason));
}
display_.show(one, two, stageIndex_ + 1, stageCount_);
return;
}
snprintf(one, sizeof(one), "Test:%-6s %2.0f%% %2lu/%2lu",
target, actual_.actualDutyPct, stageIndex_ + 1, stageCount_);
if (!s.periods || !s.periodSum) {
display_.show(one, "F:--- D:---%", stageIndex_ + 1, 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);
display_.show(one, two, stageIndex_ + 1, stageCount_);
}
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[24], two[24];
Display::formatTestFrequency(packet.actualHz ? packet.actualHz : packet.requestedHz,
target, sizeof(target));
if (reason == FailReason::NONE) {
snprintf(one, sizeof(one), "Test:%-6s %2.0f%% %2lu/%2lu",
target, packet.actualDutyX100 / 100.0f, stageIndex_ + 1, stageCount_);
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);
} else snprintf(two, sizeof(two), "F:--- D:---%%");
} else if (reason == FailReason::PERIOD_OUT && packet.measuredHzX10) {
snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, packet.actualDutyX100 / 100.0f);
char measured[12];
Display::formatTestFrequency((packet.measuredHzX10 + 5U) / 10U, measured, sizeof(measured));
snprintf(two, sizeof(two), "PERIOD OUT %s", measured);
} else if (reason == FailReason::DUTY_OUT && packet.measuredDutyX10) {
snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, packet.actualDutyX100 / 100.0f);
char duty[10]; formatErrorDuty(packet.measuredDutyX10 / 10.0f, duty, sizeof(duty));
snprintf(two, sizeof(two), "DUTY OUT %s", duty);
} else {
snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, packet.actualDutyX100 / 100.0f);
snprintf(two, sizeof(two), "%s", failName(reason));
}
display_.show(one, two, stageIndex_ + 1, stageCount_);
}
void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) const {
packet.reason = static_cast<uint8_t>(stats.reason);
packet.periods = stats.periods;
if (!stats.periods || !stats.periodSum) return;
const bool badPeriod = (stats.reason == FailReason::PERIOD_OUT || stats.reason == FailReason::DUTY_OUT) &&
stats.badFrequency > 0.0f;
const float measuredHz = badPeriod ? stats.badFrequency :
static_cast<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));
}
void App::showStageProgress() {
char target[12], one[24];
Display::formatTestFrequency(actual_.actualHz, target, sizeof(target));
snprintf(one, sizeof(one), "Test:%-6s %2.0f%% %2lu/%2lu",
target, actual_.actualDutyPct, stageIndex_ + 1, stageCount_);
display_.show(one, "F:--- D:---%", stageIndex_ + 1, stageCount_);
}

View File

@@ -8,7 +8,7 @@
enum class AppState : uint8_t {
IDLE, MENU, SOLO_MEASURE, MASTER_DISCOVER, MASTER_WAIT_READY,
MASTER_WAIT_RESULT, SLAVE_READY, SLAVE_WAIT_START, SLAVE_MEASURE,
MASTER_WAIT_RESULT, MASTER_FINALIZE, SLAVE_READY, SLAVE_WAIT_START, SLAVE_MEASURE,
SLAVE_WAIT_ACK, FINISHED
};
@@ -24,21 +24,26 @@ class App {
void changeMenu(int direction);
void sanitizeRange();
void startTest();
bool armSlave();
bool prepareStage();
bool armSlave(bool preserveDisplay = false);
bool prepareStage(bool showProgress = true);
bool startLocalMeasurement(float hz, float duty);
void startMasterDiscovery();
void handleRadio();
void updateMaster();
void updateSlave();
void stagePassed();
void finish(bool pass, FailReason reason);
void finish(bool pass, FailReason reason, bool preserveDisplay = false);
void abortTest();
void sendAbort();
void sendAbort(FailReason reason = FailReason::ABORTED);
void printConfiguration();
void printStageStats(const StageStats &s, uint32_t hz);
void showStageResult(const StageStats &s);
void showRemoteResult(const ProtocolPacket &packet);
void fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) const;
void showStageProgress();
uint64_t actualNominalTotalUs();
ProtocolPacket makePacket(MessageType type) const;
bool sendLinked(ProtocolPacket packet);
void sendCurrent(MessageType type);
void updateHeartbeat();
bool packetForCurrent(const ProtocolPacket &p) const;
@@ -69,4 +74,5 @@ class App {
bool initialized_ = false, bootResetCandidate_ = false;
uint32_t bootCheckStartedMs_ = 0;
uint32_t slaveRearmAtMs_ = 0;
bool stageStartConfirmed_ = false;
};

View File

@@ -48,16 +48,23 @@ constexpr uint8_t LINK_PACKET_RETRIES = 10;
constexpr uint32_t LINK_RETRY_INTERVAL_MS = 1000;
constexpr uint32_t LINK_HEARTBEAT_INTERVAL_MS = 500;
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;
constexpr uint8_t MEASUREMENT_PROGRESS_STEPS = 10;
constexpr uint32_t C3_STRICT_MAX_HZ = 1000000;
constexpr uint32_t S3_STRICT_MAX_HZ = 1000000;
constexpr uint32_t CAPTURE_RESOLUTION_HZ = 80000000;
// RMT stores each HIGH/LOW duration in 15 bits. At 80 MHz that limits a
// single level to about 409 us, so even a 1 kHz signal with 50% duty cannot
// be captured. 20 MHz still provides 20 ticks at 1 MHz (5% resolution),
// while allowing level durations up to about 1.64 ms for the 1 kHz/90% case.
constexpr uint32_t CAPTURE_RESOLUTION_HZ = 20000000;
// Arduino-ESP32 uses the 40 MHz crystal as the default LEDC clock on C3/S3.
// Keep this explicit so the resolution calculation never asks LEDC for an
// impossible frequency/resolution combination.

View File

@@ -93,37 +93,29 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz,
if (!requestedHz || !sourceClockHz || !maxBits || dutyPct > 100U) return false;
bool found = false;
uint64_t bestError = 0;
uint32_t bestDenominator = 1;
uint32_t bestErrorHz = 0;
uint32_t bestDutyError = 0;
uint32_t bestLevels = 1;
for (uint8_t bits = 1; bits <= maxBits && bits < 31; ++bits) {
const uint32_t levels = 1UL << bits;
const uint64_t requestedProduct = static_cast<uint64_t>(requestedHz) * levels;
uint32_t lowerDivider = static_cast<uint32_t>(sourceClockHz / requestedProduct);
if (lowerDivider < 1U) lowerDivider = 1U;
if (lowerDivider > 1023U) lowerDivider = 1023U;
const uint32_t candidates[] = {lowerDivider,
lowerDivider < 1023U ? lowerDivider + 1U : lowerDivider};
for (uint8_t candidate = 0; candidate < 2; ++candidate) {
const uint32_t divider = candidates[candidate];
if (candidate && divider == candidates[0]) continue;
for (uint32_t divider = 1; divider <= 1023U; ++divider) {
const uint32_t denominator = levels * divider;
const uint64_t targetClock = static_cast<uint64_t>(requestedHz) * denominator;
const uint64_t error = targetClock > sourceClockHz
? targetClock - sourceClockHz : sourceClockHz - targetClock;
// 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 bool frequencyBetter = !found ||
error * bestDenominator < bestError * denominator;
const bool frequencyEqual = found &&
error * bestDenominator == bestError * denominator;
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;
@@ -132,12 +124,10 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz,
static_cast<uint64_t>(bestDutyError) * levels;
if (!frequencyBetter && !dutyBetter && !(dutyEqual && bits > config.bits)) continue;
config.actualHz = static_cast<uint32_t>(
(static_cast<uint64_t>(sourceClockHz) + denominator / 2U) / denominator);
config.actualHz = actualHz;
config.divider = static_cast<uint16_t>(divider);
config.bits = bits;
bestError = error;
bestDenominator = denominator;
bestErrorHz = errorHz;
bestDutyError = dutyError;
bestLevels = levels;
found = true;

View File

@@ -37,7 +37,7 @@ void Display::fit(char *s) {
}
}
void Display::show(const char *a, const char *b) {
void Display::show(const char *a, const char *b, uint32_t progress, uint32_t progressTotal) {
char one[32], two[32];
snprintf(one, sizeof(one), "%s", a ? a : ""); snprintf(two, sizeof(two), "%s", b ? b : "");
// Serial is the primary UI mirror and remains available when OLED is absent.
@@ -45,16 +45,39 @@ void Display::show(const char *a, const char *b) {
if (!ok_) return;
fit(one); fit(two);
oled_.clearDisplay(); oled_.setCursor(0, 3); oled_.print(one);
oled_.setCursor(0, 19); oled_.print(two); oled_.display();
oled_.setCursor(0, 19); oled_.print(two);
if (progressTotal) {
if (progress > progressTotal) progress = progressTotal;
const uint16_t width = static_cast<uint16_t>(
(static_cast<uint64_t>(progress) * 128U + progressTotal - 1U) / progressTotal);
if (width) oled_.drawFastHLine(0, 31, width, SSD1306_WHITE);
}
oled_.display();
}
void Display::formatFrequency(float hz, char *out, size_t n) {
float value = hz; const char *suffix = "Hz";
if (hz >= 1000000.0f) { value = hz / 1000000.0f; suffix = "M"; }
if (hz >= 999950.0f) { value = hz / 1000000.0f; suffix = "M"; }
else if (hz >= 1000.0f) { value = hz / 1000.0f; suffix = "k"; }
if (fabsf(value - roundf(value)) < 0.005f) snprintf(out, n, "%.0f%s", value, suffix);
else if (fabsf(value * 10.0f - roundf(value * 10.0f)) < 0.005f) snprintf(out, n, "%.1f%s", value, suffix);
else snprintf(out, n, "%.2f%s", value, suffix);
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);
else if (value >= 10.0f) snprintf(out, n, "%.2f%s", value, suffix);
else snprintf(out, n, "%.3f%s", value, suffix);
}
void Display::formatTestFrequency(uint32_t hz, char *out, size_t n) {
if (hz >= 1000000U && hz % 1000000U == 0)
snprintf(out, n, "%luM", hz / 1000000U);
else if (hz >= 1000U && hz % 1000U == 0)
snprintf(out, n, "%luk", hz / 1000U);
else if (hz >= 1000U) {
const float khz = hz / 1000.0f;
if (khz >= 100.0f) snprintf(out, n, "%.1fk", khz);
else if (khz >= 10.0f) snprintf(out, n, "%.2fk", khz);
else snprintf(out, n, "%.3fk", khz);
} else
snprintf(out, n, "%lu", hz);
}
void Display::formatDuration(uint64_t us, char *out, size_t n) {

View File

@@ -7,13 +7,14 @@ class Display {
public:
Display();
bool begin();
void show(const char *line1, const char *line2);
void show(const char *line1, const char *line2,
uint32_t progress = 0, uint32_t progressTotal = 0);
bool available() const { return ok_; }
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);
private:
void fit(char *text);
Adafruit_SSD1306 oled_;
bool ok_ = false;
};

View File

@@ -9,8 +9,15 @@ bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs,
if (!expectedHz_ || !timeMs || !repeats || repeats > 10 ||
!makePeriodLimits(expectedHz_, duty, tolerance, receiver_.tickHz(), limits_) ||
!receiver_.start(expectedHz_)) return false;
timeMs_ = timeMs; repeats_ = repeats; settleLeft_ = settleCycles;
repeats_ = repeats; settleCycles_ = settleCycles; settleLeft_ = settleCycles;
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;
totalSteps_ = repeats * MEASUREMENT_PROGRESS_STEPS;
currentStep_ = currentRepeat_ = 0;
stats_.reset(); memset(repeatPeriods_, 0, sizeof(repeatPeriods_));
publishStats();
measurementStartTick_ = deadlineTick_ = 0; startedMs_ = millis();
measurementStartMs_ = lastPeriodMs_ = 0;
currentRepeat_ = 0;
@@ -34,19 +41,39 @@ void Measurement::taskLoop() {
void Measurement::fail(FailReason reason) {
if (stats_.reason == FailReason::NONE) stats_.reason = reason;
publishStats();
receiver_.stop(); state_ = MeasureState::FAIL;
}
void Measurement::completeWindow() {
void Measurement::completeStep() {
receiver_.stop();
stats_.droppedItems += receiver_.takeDroppedItems();
if (receiver_.overflowed()) { fail(FailReason::GLITCH); return; }
++currentStep_;
publishStats();
if (currentStep_ < totalSteps_) {
state_ = MeasureState::STEP_READY;
return;
}
for (uint8_t i = 0; i < repeats_; ++i) if (!repeatPeriods_[i]) {
fail(FailReason::TOO_FEW_PERIODS); return;
}
state_ = MeasureState::PASS;
}
void Measurement::publishStats() {
portENTER_CRITICAL(&statsMux_);
publishedStats_ = stats_;
portEXIT_CRITICAL(&statsMux_);
}
bool Measurement::statsSnapshot(StageStats &out) const {
portENTER_CRITICAL(&statsMux_);
out = publishedStats_;
portEXIT_CRITICAL(&statsMux_);
return out.periods && out.periodSum;
}
MeasureState Measurement::processOnce() {
if (state_ != MeasureState::SETTLING && state_ != MeasureState::RUNNING) return state_;
if (receiver_.overflowed()) { fail(FailReason::GLITCH); return state_; }
@@ -63,19 +90,15 @@ MeasureState Measurement::processOnce() {
if (settleLeft_) --settleLeft_;
if (!settleLeft_) {
measurementStartTick_ = period.startTick + period.periodTicks;
repeatTicks_ = static_cast<uint64_t>(receiver_.tickHz()) * timeMs_ / 1000ULL;
deadlineTick_ = measurementStartTick_ + repeatTicks_ * repeats_;
nextRepeatTick_ = measurementStartTick_ + repeatTicks_;
stats_.reset(); measurementStartMs_ = lastPeriodMs_ = millis(); state_ = MeasureState::RUNNING;
deadlineTick_ = measurementStartTick_ + stepTicks_;
currentRepeat_ = currentStep_ / MEASUREMENT_PROGRESS_STEPS;
measurementStartMs_ = lastPeriodMs_ = millis(); state_ = MeasureState::RUNNING;
}
continue;
}
const uint64_t endTick = period.startTick + period.periodTicks;
if (period.startTick < measurementStartTick_) continue; // leading incomplete period
if (endTick > deadlineTick_) { completeWindow(); return state_; } // trailing incomplete period
while (currentRepeat_ + 1U < repeats_ && period.startTick >= nextRepeatTick_) {
++currentRepeat_; nextRepeatTick_ += repeatTicks_;
}
if (endTick > deadlineTick_) { completeStep(); return state_; } // trailing incomplete period
++repeatPeriods_[currentRepeat_];
const FailReason r = evaluatePeriodFast(period, receiver_.tickHz(), limits_, currentRepeat_ + 1, stats_);
if (r != FailReason::NONE) { fail(r); return state_; }
@@ -90,16 +113,32 @@ MeasureState Measurement::processOnce() {
if (state_ == MeasureState::SETTLING && millis() - startedMs_ > settleTimeout) fail(FailReason::NO_SIGNAL);
if (state_ == MeasureState::RUNNING && measurementStartTick_) {
const uint32_t now = millis();
const uint32_t totalMs = timeMs_ * repeats_;
const uint32_t edgeTimeoutMs = expectedPeriodMs_ * NO_SIGNAL_TIMEOUT_PERIODS + 2;
if (now - measurementStartMs_ < totalMs && now - lastPeriodMs_ > edgeTimeoutMs) {
if (now - measurementStartMs_ < stepTimeMs_ && now - lastPeriodMs_ > edgeTimeoutMs) {
fail(FailReason::LOST_EDGE); return state_;
}
if (now - measurementStartMs_ > totalMs + expectedPeriodMs_ + 2) completeWindow();
if (now - measurementStartMs_ > stepTimeMs_ + expectedPeriodMs_ + 2) completeStep();
}
return state_;
}
MeasureState Measurement::update() { return state_; }
void Measurement::abort() { if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING) fail(FailReason::ABORTED); }
bool Measurement::continueAfterDisplay() {
if (state_ != MeasureState::STEP_READY) return false;
if (!receiver_.start(expectedHz_)) {
fail(FailReason::UNSUPPORTED);
return false;
}
settleLeft_ = settleCycles_;
measurementStartTick_ = deadlineTick_ = 0;
startedMs_ = millis(); measurementStartMs_ = lastPeriodMs_ = 0;
state_ = MeasureState::SETTLING;
xTaskNotifyGive(task_);
return true;
}
void Measurement::abort() {
if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING ||
state_ == MeasureState::STEP_READY) fail(FailReason::ABORTED);
}

View File

@@ -1,7 +1,7 @@
#pragma once
#include "Receiver.h"
enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, PASS, FAIL };
enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, STEP_READY, PASS, FAIL };
class Measurement {
public:
@@ -9,27 +9,32 @@ class Measurement {
bool start(float expectedHz, float expectedDuty, float tolerancePct,
uint32_t testTimeMs, uint8_t repeats, uint8_t settleCycles);
MeasureState update();
bool continueAfterDisplay();
void abort();
MeasureState state() const { return state_; }
FailReason reason() const { return stats_.reason; }
const StageStats &stats() const { return stats_; }
bool statsSnapshot(StageStats &out) const;
private:
static void taskEntry(void *context);
void taskLoop();
MeasureState processOnce();
void fail(FailReason reason);
void completeWindow();
void completeStep();
void publishStats();
PulseReceiver &receiver_;
volatile MeasureState state_ = MeasureState::IDLE;
TaskHandle_t task_ = nullptr;
StageStats stats_ = {};
StageStats publishedStats_ = {};
mutable portMUX_TYPE statsMux_ = portMUX_INITIALIZER_UNLOCKED;
PeriodLimits limits_ = {};
uint32_t expectedHz_ = 0;
uint32_t timeMs_ = 0;
uint8_t repeats_ = 0, settleLeft_ = 0, currentRepeat_ = 0;
uint64_t measurementStartTick_ = 0, deadlineTick_ = 0, repeatTicks_ = 0, nextRepeatTick_ = 0;
uint8_t repeats_ = 0, settleCycles_ = 0, settleLeft_ = 0;
uint8_t currentRepeat_ = 0, currentStep_ = 0, totalSteps_ = 0;
uint64_t measurementStartTick_ = 0, deadlineTick_ = 0, stepTicks_ = 0;
uint32_t startedMs_ = 0, measurementStartMs_ = 0, lastPeriodMs_ = 0;
uint32_t expectedPeriodMs_ = 1;
uint32_t stepTimeMs_ = 1, expectedPeriodMs_ = 1;
uint32_t repeatPeriods_[10] = {};
PulsePeriod periodBatch_[PERIOD_BATCH_SIZE] = {};
};

View File

@@ -3,7 +3,7 @@
const char *messageName(MessageType type) {
static const char *names[] = {"DISCOVER", "DISCOVER_ACK", "PREPARE", "READY",
"START_STAGE", "RESULT", "ACK", "ABORT", "HEARTBEAT", "HEARTBEAT_ACK"};
"START_STAGE", "RESULT", "ACK", "ABORT", "HEARTBEAT", "HEARTBEAT_ACK", "PROGRESS"};
const uint8_t index = static_cast<uint8_t>(type);
return index < sizeof(names) / sizeof(names[0]) ? names[index] : "UNKNOWN";
}
@@ -24,5 +24,5 @@ void finalizePacket(ProtocolPacket &p) {
bool validPacket(const ProtocolPacket &p) {
return p.magic == PROTOCOL_MAGIC && p.version == PROTOCOL_VERSION &&
p.type <= static_cast<uint8_t>(MessageType::HEARTBEAT_ACK) && p.crc == packetCrc(p);
p.type <= static_cast<uint8_t>(MessageType::PROGRESS) && p.crc == packetCrc(p);
}

View File

@@ -2,11 +2,11 @@
#include "Core.h"
constexpr uint16_t PROTOCOL_MAGIC = 0x4F43;
constexpr uint8_t PROTOCOL_VERSION = 2;
constexpr uint8_t PROTOCOL_VERSION = 6;
enum class MessageType : uint8_t {
DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT,
HEARTBEAT, HEARTBEAT_ACK
HEARTBEAT, HEARTBEAT_ACK, PROGRESS
};
const char *messageName(MessageType type);
@@ -18,6 +18,7 @@ struct ProtocolPacket {
uint8_t type;
uint32_t session;
uint16_t stage;
uint16_t stageCount;
uint16_t sequence;
uint32_t requestedHz;
uint32_t actualHz;
@@ -29,13 +30,15 @@ struct ProtocolPacket {
uint8_t passed;
uint8_t reason;
uint32_t periods;
uint32_t measuredHzX10;
uint16_t measuredDutyX10;
uint32_t minPeriodTicks;
uint32_t maxPeriodTicks;
uint16_t crc;
};
#pragma pack(pop)
static_assert(sizeof(ProtocolPacket) == 46, "Protocol layout changed");
static_assert(sizeof(ProtocolPacket) == 54, "Protocol layout changed");
uint16_t packetCrc(const ProtocolPacket &packet);
void finalizePacket(ProtocolPacket &packet);

View File

@@ -53,7 +53,7 @@ bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) {
if (settleUs > 2000U) settleUs = 2000U;
delayMicroseconds(settleUs);
const uint32_t actualHz = ledcReadFreq(GPIO_PWM);
if (actualHz) {
if (actualHz == config.actualHz) {
a = {hz, actualHz, 100.0f * duty / levels, bits};
running_ = true;
return true;

View File

@@ -23,16 +23,22 @@ bool Radio::begin() {
if (esp_wifi_set_channel(ESPNOW_WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK) {
Log::event("ESP-NOW", "Wi-Fi channel setup FAILED"); return false;
}
queue_ = xQueueCreate(8, sizeof(ReceivedPacket));
if (!queue_) { Log::event("ESP-NOW", "receive queue creation FAILED"); return false; }
if (!queue_) queue_ = xQueueCreate(8, sizeof(ReceivedPacket));
if (!heartbeatQueue_) heartbeatQueue_ = xQueueCreate(4, sizeof(ReceivedPacket));
if (!heartbeatTask_ && xTaskCreate(heartbeatTaskEntry, "radio-hb", 2048, this, 5, &heartbeatTask_) != pdPASS)
heartbeatTask_ = nullptr;
if (!queue_ || !heartbeatQueue_ || !heartbeatTask_) {
Log::event("ESP-NOW", "receive/heartbeat service creation FAILED"); return false;
}
if (esp_now_init() != ESP_OK) {
vQueueDelete(queue_); queue_ = nullptr;
Log::event("ESP-NOW", "initialization FAILED"); return false;
}
const esp_err_t rateResult = esp_wifi_config_espnow_rate(WIFI_IF_STA, WIFI_PHY_RATE_1M_L);
instance_ = this;
if (esp_now_register_recv_cb(onReceive) != ESP_OK) { end(); return false; }
active_ = true;
if (esp_now_register_recv_cb(onReceive) != ESP_OK) {
instance_ = nullptr; esp_now_deinit(); return false;
}
active_ = true; __atomic_store_n(&lastValidRxMs_, millis(), __ATOMIC_RELAXED);
const bool ok = ensurePeer(BROADCAST_MAC);
uint8_t primaryChannel = 0;
wifi_second_chan_t secondaryChannel = WIFI_SECOND_CHAN_NONE;
@@ -44,9 +50,11 @@ bool Radio::begin() {
}
void Radio::end() {
if (active_) { esp_now_unregister_recv_cb(); esp_now_deinit(); }
if (queue_) { vQueueDelete(queue_); queue_ = nullptr; }
active_ = false; if (instance_ == this) instance_ = nullptr;
const bool wasActive = active_; active_ = false;
if (wasActive) { esp_now_unregister_recv_cb(); esp_now_deinit(); }
if (queue_) xQueueReset(queue_);
if (heartbeatQueue_) xQueueReset(heartbeatQueue_);
if (instance_ == this) instance_ = nullptr;
Log::event("ESP-NOW", "stopped");
}
@@ -72,7 +80,7 @@ bool Radio::sendTo(const uint8_t mac[6], ProtocolPacket p) {
finalizePacket(p);
const bool ok = esp_now_send(mac, reinterpret_cast<const uint8_t *>(&p), sizeof(p)) == ESP_OK;
const MessageType type = static_cast<MessageType>(p.type);
if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK)
if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK && type != MessageType::PROGRESS)
Log::printf("ESP-NOW", "TX %s to %s session=%08lX stage=%u seq=%u %s",
messageName(type), peer, p.session, p.stage, p.sequence, ok ? "QUEUED" : "FAILED");
return ok;
@@ -97,16 +105,37 @@ void Radio::maintainChannel() {
restored ? "RESTORED" : "FAILED");
}
void Radio::flush() { if (queue_) xQueueReset(queue_); }
void Radio::flush() {
if (queue_) xQueueReset(queue_);
if (heartbeatQueue_) xQueueReset(heartbeatQueue_);
}
void Radio::onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int length) {
if (!instance_ || !instance_->queue_ || !info || length != sizeof(ProtocolPacket)) return;
ReceivedPacket item;
memcpy(item.mac, info->src_addr, 6); memcpy(&item.packet, data, sizeof(item.packet));
if (!validPacket(item.packet)) return;
__atomic_store_n(&instance_->lastValidRxMs_, millis(), __ATOMIC_RELAXED);
if (static_cast<MessageType>(item.packet.type) == MessageType::HEARTBEAT && instance_->heartbeatQueue_)
xQueueSend(instance_->heartbeatQueue_, &item, 0);
xQueueSend(instance_->queue_, &item, 0); // Wi-Fi task callback: copy only, never block
}
void Radio::heartbeatTaskEntry(void *context) {
static_cast<Radio *>(context)->heartbeatTaskLoop();
}
void Radio::heartbeatTaskLoop() {
ReceivedPacket item;
for (;;) {
if (xQueueReceive(heartbeatQueue_, &item, portMAX_DELAY) != pdTRUE || !active_) continue;
ProtocolPacket ack = item.packet;
ack.type = static_cast<uint8_t>(MessageType::HEARTBEAT_ACK);
finalizePacket(ack);
esp_now_send(item.mac, reinterpret_cast<const uint8_t *>(&ack), sizeof(ack));
}
}
void Radio::macText(const uint8_t mac[6], char *out, size_t n) {
snprintf(out, n, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

View File

@@ -13,14 +13,20 @@ class Radio {
bool sendTo(const uint8_t mac[6], ProtocolPacket packet);
bool receive(ReceivedPacket &received);
void flush();
uint32_t lastReceiveMs() const { return __atomic_load_n(&lastValidRxMs_, __ATOMIC_RELAXED); }
static void macText(const uint8_t mac[6], char *out, size_t size);
private:
static void onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int length);
static void heartbeatTaskEntry(void *context);
void heartbeatTaskLoop();
bool ensurePeer(const uint8_t mac[6]);
void maintainChannel();
static Radio *instance_;
QueueHandle_t queue_ = nullptr;
bool active_ = false;
QueueHandle_t heartbeatQueue_ = nullptr;
TaskHandle_t heartbeatTask_ = nullptr;
volatile bool active_ = false;
volatile uint32_t lastValidRxMs_ = 0;
uint32_t lastChannelCheckMs_ = 0;
};

View File

@@ -54,9 +54,12 @@ bool PulseReceiver::start(uint32_t expectedHz) {
receiveChunkSymbols_ = static_cast<uint16_t>(symbols);
if (rmt_enable(channel_) != ESP_OK) return false;
rmt_receive_config_t cfg = {};
cfg.signal_range_min_ns = 20;
cfg.signal_range_min_ns = 1000000000UL / CAPTURE_RESOLUTION_HZ;
const uint64_t maxNs = 4000000000ULL / (expectedHz ? expectedHz : 1);
cfg.signal_range_max_ns = maxNs > 100000000ULL ? 100000000UL : static_cast<uint32_t>(maxNs);
// 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 = 32766ULL * 1000000000ULL / CAPTURE_RESOLUTION_HZ;
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) {