From a8099bf2b863964602732858ef1e679068cd2335 Mon Sep 17 00:00:00 2001 From: Razvalyaev Date: Fri, 7 Aug 2026 16:04:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=89=D0=B5=D0=BD=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20?= =?UTF-8?q?=D0=B5=D1=81=D0=BF=20=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=D1=87=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OpticalChannelTester/App.cpp | 306 +++++++++++++++++++++++---- OpticalChannelTester/App.h | 16 +- OpticalChannelTester/Config.h | 9 +- OpticalChannelTester/Core.cpp | 36 ++-- OpticalChannelTester/Display.cpp | 35 ++- OpticalChannelTester/Display.h | 5 +- OpticalChannelTester/Measurement.cpp | 67 ++++-- OpticalChannelTester/Measurement.h | 17 +- OpticalChannelTester/Protocol.cpp | 4 +- OpticalChannelTester/Protocol.h | 9 +- OpticalChannelTester/Pwm.cpp | 2 +- OpticalChannelTester/Radio.cpp | 49 ++++- OpticalChannelTester/Radio.h | 8 +- OpticalChannelTester/Receiver.cpp | 7 +- 14 files changed, 449 insertions(+), 121 deletions(-) diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index 5e82e6c..491805b 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -4,12 +4,13 @@ #include #include #include +#include #include 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(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(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(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(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(settings_.role) == Role::SOLO) { if (prepareStage()) state_ = AppState::SOLO_MEASURE; } else if (static_cast(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(type); p.session = session_; p.stage = stageIndex_; p.sequence = sequence_; + p.type = static_cast(type); p.session = session_; p.stage = stageIndex_; + p.stageCount = static_cast(stageCount_); p.sequence = sequence_; p.requestedHz = requestedHz_; p.actualHz = actual_.actualHz; - p.actualDutyX100 = static_cast(actual_.actualDutyPct * 100.0f + 0.5f); + const float packetDuty = actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct; + p.actualDutyX100 = static_cast(packetDuty * 100.0f + 0.5f); p.testTimeMs = params_.testTimeMs; p.repeats = params_.repeats; p.accuracyX100 = static_cast(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(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(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 + } + const bool matchingPeer = havePeer_ && !memcmp(peer_, r.mac, 6) && r.packet.session == session_; + if (matchingPeer && type == MessageType::PREPARE) { + const bool expected = state_ == AppState::SLAVE_WAIT_START && r.packet.stage == stageIndex_; + const bool implicitAck = state_ == AppState::SLAVE_WAIT_ACK && pendingPacket_.passed && + r.packet.stage == static_cast(pendingPacket_.stage + 1U); + if (expected || implicitAck) { + stageIndex_ = r.packet.stage; + sequence_ = r.packet.sequence; + state_ = AppState::SLAVE_WAIT_START; + params_.testTimeMs = r.packet.testTimeMs; params_.repeats = r.packet.repeats; + params_.accuracyPct = r.packet.accuracyX100 / 100.0f; requestedHz_ = r.packet.requestedHz; + 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) { finish(false, FailReason::ABORTED); continue; } + if (type == MessageType::ABORT) { + const FailReason reason = r.packet.reason > static_cast(FailReason::NONE) && + r.packet.reason <= static_cast(FailReason::ABORTED) + ? static_cast(r.packet.reason) : FailReason::ABORTED; + if (r.packet.requestedHz) requestedHz_ = r.packet.requestedHz; + 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()) 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; + 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; radio_.sendBroadcast(ack); pwm_.stop(); - if (!r.packet.passed) finish(false, static_cast(r.packet.reason)); else stagePassed(); - } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::PREPARE) { - 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"); + ProtocolPacket ack = makePacket(MessageType::ACK); ack.sequence = r.packet.sequence; + ack.passed = r.packet.passed && stageIndex_ + 1U >= stageCount_; + sendLinked(ack); pwm_.stop(); + showRemoteResult(r.packet); + if (!r.packet.passed) { + finish(false, static_cast(r.packet.reason), true); + } else if (ack.passed) { + pendingPacket_ = ack; + state_ = AppState::MASTER_FINALIZE; retries_ = 0; + deadlineMs_ = millis() + FINAL_ACK_RETRY_INTERVAL_MS; + } else stagePassed(); + } else if (state_ == AppState::MASTER_FINALIZE && type == MessageType::RESULT) { + // The Slave did not receive the final ACK and repeated RESULT. + sendLinked(pendingPacket_); } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) { + sequence_ = r.packet.sequence; actual_.actualHz = r.packet.actualHz; actual_.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(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(r.packet.stage) + 1U; + state_ = AppState::SLAVE_WAIT_START; + } + } + else finish(false, static_cast(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(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(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(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(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(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(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(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(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(FailReason::ABORTED) + ? static_cast(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(stats.reason); + packet.periods = stats.periods; + if (!stats.periods || !stats.periodSum) return; + const bool badPeriod = (stats.reason == FailReason::PERIOD_OUT || stats.reason == FailReason::DUTY_OUT) && + stats.badFrequency > 0.0f; + const float measuredHz = badPeriod ? stats.badFrequency : + static_cast(receiver_.tickHz()) * stats.periods / stats.periodSum; + const float measuredDuty = badPeriod ? stats.badDuty : 100.0f * stats.activeSum / stats.periodSum; + packet.measuredHzX10 = static_cast(lroundf(measuredHz * 10.0f)); + packet.measuredDutyX10 = static_cast(lroundf(measuredDuty * 10.0f)); +} + +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_); +} diff --git a/OpticalChannelTester/App.h b/OpticalChannelTester/App.h index 31bc87d..ca282fe 100644 --- a/OpticalChannelTester/App.h +++ b/OpticalChannelTester/App.h @@ -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; }; diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h index 794bc18..35be25c 100644 --- a/OpticalChannelTester/Config.h +++ b/OpticalChannelTester/Config.h @@ -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. diff --git a/OpticalChannelTester/Core.cpp b/OpticalChannelTester/Core.cpp index f97732e..1ab0399 100644 --- a/OpticalChannelTester/Core.cpp +++ b/OpticalChannelTester/Core.cpp @@ -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(requestedHz) * levels; - uint32_t lowerDivider = static_cast(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(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(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(dutyError) * bestLevels < static_cast(bestDutyError) * levels; @@ -132,12 +124,10 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz, static_cast(bestDutyError) * levels; if (!frequencyBetter && !dutyBetter && !(dutyEqual && bits > config.bits)) continue; - config.actualHz = static_cast( - (static_cast(sourceClockHz) + denominator / 2U) / denominator); + config.actualHz = actualHz; config.divider = static_cast(divider); config.bits = bits; - bestError = error; - bestDenominator = denominator; + bestErrorHz = errorHz; bestDutyError = dutyError; bestLevels = levels; found = true; diff --git a/OpticalChannelTester/Display.cpp b/OpticalChannelTester/Display.cpp index 3be14c8..450da25 100644 --- a/OpticalChannelTester/Display.cpp +++ b/OpticalChannelTester/Display.cpp @@ -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( + (static_cast(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) { diff --git a/OpticalChannelTester/Display.h b/OpticalChannelTester/Display.h index d27ff16..9efed72 100644 --- a/OpticalChannelTester/Display.h +++ b/OpticalChannelTester/Display.h @@ -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; }; - diff --git a/OpticalChannelTester/Measurement.cpp b/OpticalChannelTester/Measurement.cpp index 605b14f..f9c4d1c 100644 --- a/OpticalChannelTester/Measurement.cpp +++ b/OpticalChannelTester/Measurement.cpp @@ -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(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(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); +} diff --git a/OpticalChannelTester/Measurement.h b/OpticalChannelTester/Measurement.h index a9f4c98..5491a95 100644 --- a/OpticalChannelTester/Measurement.h +++ b/OpticalChannelTester/Measurement.h @@ -1,7 +1,7 @@ #pragma once #include "Receiver.h" -enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, 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] = {}; }; diff --git a/OpticalChannelTester/Protocol.cpp b/OpticalChannelTester/Protocol.cpp index b4cd09f..6fb08f5 100644 --- a/OpticalChannelTester/Protocol.cpp +++ b/OpticalChannelTester/Protocol.cpp @@ -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(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(MessageType::HEARTBEAT_ACK) && p.crc == packetCrc(p); + p.type <= static_cast(MessageType::PROGRESS) && p.crc == packetCrc(p); } diff --git a/OpticalChannelTester/Protocol.h b/OpticalChannelTester/Protocol.h index 529ca10..35eccd4 100644 --- a/OpticalChannelTester/Protocol.h +++ b/OpticalChannelTester/Protocol.h @@ -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); diff --git a/OpticalChannelTester/Pwm.cpp b/OpticalChannelTester/Pwm.cpp index caecd77..3c0b989 100644 --- a/OpticalChannelTester/Pwm.cpp +++ b/OpticalChannelTester/Pwm.cpp @@ -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; diff --git a/OpticalChannelTester/Radio.cpp b/OpticalChannelTester/Radio.cpp index 3690a97..1df6ec4 100644 --- a/OpticalChannelTester/Radio.cpp +++ b/OpticalChannelTester/Radio.cpp @@ -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(&p), sizeof(p)) == ESP_OK; const MessageType type = static_cast(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(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(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(MessageType::HEARTBEAT_ACK); + finalizePacket(ack); + esp_now_send(item.mac, reinterpret_cast(&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]); } diff --git a/OpticalChannelTester/Radio.h b/OpticalChannelTester/Radio.h index 8038e51..fe3cc6e 100644 --- a/OpticalChannelTester/Radio.h +++ b/OpticalChannelTester/Radio.h @@ -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; }; diff --git a/OpticalChannelTester/Receiver.cpp b/OpticalChannelTester/Receiver.cpp index 53528a6..d4b55fa 100644 --- a/OpticalChannelTester/Receiver.cpp +++ b/OpticalChannelTester/Receiver.cpp @@ -54,9 +54,12 @@ bool PulseReceiver::start(uint32_t expectedHz) { receiveChunkSymbols_ = static_cast(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(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(maxNs > hardwareMaxNs ? hardwareMaxNs : maxNs); cfg.flags.en_partial_rx = true; if (rmt_receive(channel_, receiveBuffer_, receiveChunkSymbols_ * sizeof(receiveBuffer_[0]), &cfg) != ESP_OK) {