commit 3399e194ad03b10d0df5513d020c475fbd5e39c5 Author: Razvalyaev Date: Wed Aug 5 16:49:05 2026 +0300 codex init diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp new file mode 100644 index 0000000..9efc5cd --- /dev/null +++ b/OpticalChannelTester/App.cpp @@ -0,0 +1,322 @@ +#include "App.h" +#include "Config.h" +#include +#include +#include +#include + +App::App() : startButton_(GPIO_BUTTON_START), modeButton_(GPIO_BUTTON_MODE), measurement_(receiver_) {} + +void App::begin() { + Serial.begin(SERIAL_BAUD); + startButton_.begin(); modeButton_.begin(); pwm_.begin(); + bootCheckStartedMs_ = millis(); + bootResetCandidate_ = startButton_.pressed() && modeButton_.pressed(); + if (!bootResetCandidate_) finishInitialization(false); +} + +void App::finishInitialization(bool factoryReset) { + if (initialized_) return; + if (factoryReset) { + store_.defaults(settings_); store_.save(settings_); Serial.println("FACTORY DEFAULTS RESTORED"); + } else if (!store_.load(settings_)) { + store_.save(settings_); Serial.println("NVS invalid/missing: defaults loaded"); + } + params_ = store_.params(settings_); + if (!display_.begin()) Serial.println("OLED unavailable; continuing through Serial"); + initialized_ = true; + if (!receiver_.begin()) { Serial.println("FATAL: capture peripheral init failed"); finish(false, FailReason::UNSUPPORTED); return; } + printConfiguration(); showIdle(); +} + +void App::update() { + const uint32_t now = millis(); + const ButtonEvent startEvent = startButton_.update(now); + const ButtonEvent modeEvent = modeButton_.update(now); + if (!initialized_) { + if (!startButton_.pressed() || !modeButton_.pressed()) finishInitialization(false); + else if (now - bootCheckStartedMs_ >= FACTORY_RESET_HOLD_MS) finishInitialization(true); + return; + } + if (state_ != AppState::IDLE && state_ != AppState::MENU && state_ != AppState::FINISHED && + startEvent == ButtonEvent::LONG) { abortTest(); return; } + + if (state_ == AppState::IDLE || state_ == AppState::FINISHED) { + if (modeEvent == ButtonEvent::SHORT) { + settings_.role = (settings_.role + 1U) % 3U; store_.save(settings_); params_ = store_.params(settings_); showIdle(); + Serial.printf("MODE: %s\n", roleName(static_cast(settings_.role))); + } else if (modeEvent == ButtonEvent::LONG) { + state_ = AppState::MENU; menuItem_ = 0; showMenu(); + } else if (startEvent == ButtonEvent::SHORT) startTest(); + return; + } + if (state_ == AppState::MENU) { + if (modeEvent == ButtonEvent::SHORT) { menuItem_ = (menuItem_ + 1U) % 7U; showMenu(); } + else if (modeEvent == ButtonEvent::LONG) { + sanitizeRange(); store_.save(settings_); params_ = store_.params(settings_); + state_ = AppState::IDLE; printConfiguration(); showIdle(); + } else if (startEvent == ButtonEvent::SHORT) changeMenu(+1); + else if (startEvent == ButtonEvent::LONG || startEvent == ButtonEvent::REPEAT) changeMenu(-1); + return; + } + if (state_ == AppState::SOLO_MEASURE) { + const MeasureState ms = measurement_.update(); + if (ms == MeasureState::FAIL) { printStageStats(measurement_.stats(), requestedHz_); finish(false, measurement_.reason()); } + else if (ms == MeasureState::PASS) { printStageStats(measurement_.stats(), requestedHz_); stagePassed(); } + } else if (state_ == AppState::MASTER_DISCOVER || state_ == AppState::MASTER_WAIT_READY || + state_ == AppState::MASTER_WAIT_RESULT) { + handleRadio(); updateMaster(); + } else { + handleRadio(); updateSlave(); + } +} + +void App::showIdle() { + char one[24]; snprintf(one, sizeof(one), "MODE: %s", roleName(static_cast(settings_.role))); + display_.show(one, "START=RUN"); +} + +void App::sanitizeRange() { + settings_.startIndex %= countOf(START_FREQ_OPTIONS_HZ); settings_.endIndex %= countOf(END_FREQ_OPTIONS_HZ); + if (END_FREQ_OPTIONS_HZ[settings_.endIndex] <= START_FREQ_OPTIONS_HZ[settings_.startIndex]) { + size_t i = 0; + while (i < countOf(END_FREQ_OPTIONS_HZ) && END_FREQ_OPTIONS_HZ[i] <= START_FREQ_OPTIONS_HZ[settings_.startIndex]) ++i; + if (i == countOf(END_FREQ_OPTIONS_HZ)) { settings_.startIndex = 0; i = countOf(END_FREQ_OPTIONS_HZ) - 1; } + settings_.endIndex = i; + } +} + +void App::changeMenu(int d) { + uint8_t *value = nullptr; size_t count = 0; + switch (menuItem_) { + case 0: value = &settings_.startIndex; count = countOf(START_FREQ_OPTIONS_HZ); break; + case 1: value = &settings_.endIndex; count = countOf(END_FREQ_OPTIONS_HZ); break; + case 2: value = &settings_.stepIndex; count = countOf(STEP_OPTIONS_HZ); break; + case 3: value = &settings_.accuracyIndex; count = countOf(ACCURACY_OPTIONS_PCT); break; + case 4: value = &settings_.timeIndex; count = countOf(TEST_TIME_OPTIONS_MS); break; + case 5: value = &settings_.repeatIndex; count = countOf(REPEAT_OPTIONS); break; + default: value = &settings_.dutyIndex; count = countOf(DUTY_OPTIONS_PCT); break; + } + *value = static_cast((*value + count + d) % count); sanitizeRange(); params_ = store_.params(settings_); showMenu(); +} + +void App::showMenu() { + char one[22], two[22], all[12]; + Display::formatDuration(actualNominalTotalUs(), all, sizeof(all)); + switch (menuItem_) { + case 0: snprintf(one, sizeof(one), "START FREQ"); Display::formatFrequency(params_.startHz, two, sizeof(two)); break; + case 1: snprintf(one, sizeof(one), "END FREQ"); Display::formatFrequency(params_.endHz, two, sizeof(two)); break; + case 2: snprintf(one, sizeof(one), "FREQ STEP"); Display::formatFrequency(params_.stepHz, two, sizeof(two)); break; + case 3: snprintf(one, sizeof(one), "ACCURACY"); snprintf(two, sizeof(two), "+/-%g%%", params_.accuracyPct); break; + case 4: snprintf(one, sizeof(one), "TEST TIME"); snprintf(two, sizeof(two), "%.1fs", params_.testTimeMs / 1000.0f); break; + case 5: snprintf(one, sizeof(one), "REPEATS"); snprintf(two, sizeof(two), "%ux", params_.repeats); break; + default: snprintf(one, sizeof(one), "PWM DUTY"); snprintf(two, sizeof(two), "%u%%", params_.dutyPct); break; + } + const size_t used = strlen(two); snprintf(two + used, sizeof(two) - used, " ALL %s", all); display_.show(one, two); +} + +void App::startTest() { + params_ = store_.params(settings_); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); + stageIndex_ = 0; pendingReason_ = FailReason::NONE; + if (!stageCount_) { finish(false, FailReason::UNSUPPORTED); return; } + printConfiguration(); + const Role role = static_cast(settings_.role); + if (role == Role::SOLO) { + if (!prepareStage()) return; + state_ = AppState::SOLO_MEASURE; + } else if (!radio_.begin()) finish(false, FailReason::LINK_LOST); + else if (role == Role::MASTER) startMasterDiscovery(); + else { state_ = AppState::SLAVE_READY; display_.show("SLAVE READY", "WAIT MASTER"); Serial.println("SLAVE READY"); } +} + +bool App::prepareStage() { + requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, stageIndex_); + 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; } + if (!pwm_.start(requestedHz_, params_.dutyPct, actual_)) { finish(false, FailReason::RESOLUTION); return false; } + const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct, + receiver_.tickHz(), actual_.bits); + if (resolution != FailReason::NONE) { finish(false, resolution); return false; } + Serial.printf("STAGE %lu/%lu requested=%luHz actual=%luHz duty=%.2f%% bits=%u\n", + 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 (static_cast(settings_.role) == Role::SOLO && !startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { + finish(false, FailReason::UNSUPPORTED); return false; + } + return true; +} + +bool App::startLocalMeasurement(float hz, float duty) { + return measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, params_.repeats, PWM_SETTLE_CYCLES); +} + +void App::stagePassed() { + pwm_.stop(); + if (++stageIndex_ >= stageCount_) { finish(true, FailReason::NONE); return; } + 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_); + pendingPacket_ = makePacket(MessageType::PREPARE); sendCurrent(MessageType::PREPARE); + state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; + } +} + +void App::startMasterDiscovery() { + session_ = esp_random(); if (!session_) session_ = 1; sequence_ = 1; havePeer_ = false; radio_.flush(); + pendingPacket_ = makePacket(MessageType::DISCOVER); radio_.sendBroadcast(pendingPacket_); + lastSendMs_ = millis(); deadlineMs_ = millis() + LINK_DISCOVERY_TIMEOUT_MS; retries_ = 0; + state_ = AppState::MASTER_DISCOVER; display_.show("MASTER SEARCH", "WAIT SLAVE"); Serial.println("ESP-NOW DISCOVER"); +} + +ProtocolPacket App::makePacket(MessageType type) const { + ProtocolPacket p = {}; + p.type = static_cast(type); p.session = session_; p.stage = stageIndex_; p.sequence = sequence_; + p.requestedHz = requestedHz_; p.actualHz = actual_.actualHz; + p.actualDutyX100 = static_cast(actual_.actualDutyPct * 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; +} + +void App::sendCurrent(MessageType type) { + ++sequence_; pendingPacket_ = makePacket(type); radio_.sendTo(peer_, pendingPacket_); lastSendMs_ = millis(); +} + +bool App::packetForCurrent(const ProtocolPacket &p) const { + return p.session == session_ && p.stage == stageIndex_; +} + +void App::handleRadio() { + ReceivedPacket r; + while (radio_.receive(r)) { + const MessageType type = static_cast(r.packet.type); + if (state_ != AppState::SLAVE_MEASURE) + Serial.printf("ESP-NOW RX type=%u session=%08lX stage=%u seq=%u\n", r.packet.type, r.packet.session, r.packet.stage, r.packet.sequence); + if (state_ == AppState::SLAVE_READY && type == MessageType::DISCOVER) { + memcpy(peer_, r.mac, 6); havePeer_ = true; session_ = r.packet.session; stageIndex_ = 0; sequence_ = r.packet.sequence; + ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; radio_.sendTo(peer_, ack); + state_ = AppState::SLAVE_WAIT_START; display_.show("SLAVE LINKED", "WAIT PREPARE"); continue; + } + if (state_ == AppState::MASTER_DISCOVER && type == MessageType::DISCOVER_ACK && r.packet.session == session_) { + memcpy(peer_, r.mac, 6); havePeer_ = true; requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, stageIndex_); + sendCurrent(MessageType::PREPARE); state_ = AppState::MASTER_WAIT_READY; retries_ = 0; deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; + char mac[20]; Radio::macText(peer_, mac, sizeof(mac)); Serial.printf("SLAVE SELECTED %s\n", mac); continue; + } + if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::DISCOVER && + r.packet.session == session_ && !memcmp(peer_, r.mac, 6)) { + ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); + ack.sequence = r.packet.sequence; radio_.sendTo(peer_, 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_.sendTo(peer_, 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_.sendTo(peer_, 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_.sendTo(peer_, ready); + } else if (state_ == AppState::SLAVE_WAIT_START && type == MessageType::START_STAGE) { + actual_.actualHz = r.packet.actualHz; actual_.actualDutyPct = r.packet.actualDutyX100 / 100.0f; + if (!startLocalMeasurement(actual_.actualHz, actual_.actualDutyPct)) { finish(false, FailReason::UNSUPPORTED); continue; } + state_ = AppState::SLAVE_MEASURE; deadlineMs_ = millis() + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS; + } 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)); + } + } +} + +void App::updateMaster() { + const uint32_t now = millis(); + if (state_ == AppState::MASTER_DISCOVER) { + if (now >= deadlineMs_) { finish(false, FailReason::LINK_LOST); return; } + if (now - lastSendMs_ >= LINK_RETRY_INTERVAL_MS) { radio_.sendBroadcast(pendingPacket_); lastSendMs_ = now; } + return; + } + if (now < deadlineMs_) return; + if (retries_ >= LINK_PACKET_RETRIES) { finish(false, FailReason::LINK_LOST); return; } + radio_.sendTo(peer_, pendingPacket_); ++retries_; + deadlineMs_ = now + (state_ == AppState::MASTER_WAIT_RESULT ? + params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS : LINK_REPLY_TIMEOUT_MS); +} + +void App::updateSlave() { + if (state_ == AppState::SLAVE_MEASURE) { + const MeasureState ms = measurement_.update(); + if (ms != MeasureState::PASS && ms != MeasureState::FAIL) return; + printStageStats(measurement_.stats(), requestedHz_); + pendingPacket_ = makePacket(MessageType::RESULT); pendingPacket_.passed = ms == MeasureState::PASS; + pendingPacket_.reason = static_cast(measurement_.reason()); pendingPacket_.periods = measurement_.stats().periods; + pendingPacket_.minPeriodTicks = measurement_.stats().minPeriod; pendingPacket_.maxPeriodTicks = measurement_.stats().maxPeriod; + pendingPacket_.sequence = ++sequence_; radio_.sendTo(peer_, pendingPacket_); + 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 { radio_.sendTo(peer_, pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } + } +} + +void App::sendAbort() { if (havePeer_) sendCurrent(MessageType::ABORT); } + +void App::abortTest() { sendAbort(); measurement_.abort(); finish(false, FailReason::ABORTED); } + +void App::finish(bool pass, FailReason reason) { + pwm_.stop(); receiver_.stop(); + if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end(); + state_ = AppState::FINISHED; pendingReason_ = reason; + char one[24]; + if (pass) { snprintf(one, sizeof(one), "PASS %luHz-%lu", params_.startHz, params_.endHz); display_.show(one, "START=REPEAT"); } + else { snprintf(one, sizeof(one), "FAIL AT %lu", requestedHz_); display_.show(one, failName(reason)); } + Serial.printf("TEST %s: %s\n", pass ? "PASS" : "FAIL", failName(reason)); +} + +void App::printConfiguration() { + const char *board = TARGET_IS_C3 ? "ESP32-C3" : "ESP32-S3"; + uint8_t mac[6] = {}; esp_read_mac(mac, ESP_MAC_WIFI_STA); + Serial.printf("\nOptical Channel Tester | %s | mode=%s\n", board, roleName(static_cast(settings_.role))); + Serial.printf("MAC=%02X:%02X:%02X:%02X:%02X:%02X\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + Serial.printf("GPIO PWM=%u RX=%u START=%u MODE=%u SDA=%u SCL=%u\n", GPIO_PWM, GPIO_RX, + GPIO_BUTTON_START, GPIO_BUTTON_MODE, GPIO_SDA, GPIO_SCL); + Serial.printf("Test %lu..%lu step %lu Hz, accuracy %.2f%%, %lums x%u, duty %u%%\n", + params_.startHz, params_.endHz, params_.stepHz, params_.accuracyPct, params_.testTimeMs, params_.repeats, params_.dutyPct); + stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); + Serial.printf("Frequencies (%lu): ", stageCount_); + for (uint32_t i = 0; i < stageCount_; ++i) Serial.printf("%lu%s", frequencyAt(params_.startHz, params_.endHz, params_.stepHz, i), i + 1 == stageCount_ ? "\n" : ","); + Serial.printf("ALL nominal: %llu us | RX=%s\n", actualNominalTotalUs(), receiver_.highRateBackend() ? "RMT DMA" : "RMT ping-pong"); +} + +uint64_t App::actualNominalTotalUs() { + uint64_t total = 0; + const uint32_t count = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); + for (uint32_t i = 0; i < count; ++i) { + ActualPwm preview = {}; + const uint32_t requested = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, i); + const uint32_t actualHz = pwm_.preview(requested, params_.dutyPct, preview) ? preview.actualHz : requested; + total += (1000000ULL * PWM_SETTLE_CYCLES + actualHz - 1) / actualHz; + total += static_cast(params_.testTimeMs) * 1000ULL * params_.repeats; + } + return total; +} + +void App::printStageStats(const StageStats &s, uint32_t hz) { + if (!s.periods) return; + Serial.printf("STATS %luHz periods=%lu period ticks min/avg/max=%lu/%llu/%lu active=%lu/%llu/%lu\n", + hz, s.periods, s.minPeriod, s.periodSum / s.periods, s.maxPeriod, + s.minActive, s.activeSum / s.periods, s.maxActive); + if (s.reason != FailReason::NONE) Serial.printf("FIRST BAD repeat=%u period=%lu f=%.3f duty=%.3f reason=%s\n", + s.firstBadRepeat, s.firstBadPeriod, s.badFrequency, s.badDuty, failName(s.reason)); +} diff --git a/OpticalChannelTester/App.h b/OpticalChannelTester/App.h new file mode 100644 index 0000000..52c3035 --- /dev/null +++ b/OpticalChannelTester/App.h @@ -0,0 +1,68 @@ +#pragma once +#include "Buttons.h" +#include "Display.h" +#include "Measurement.h" +#include "Pwm.h" +#include "Radio.h" +#include "SettingsStore.h" + +enum class AppState : uint8_t { + IDLE, MENU, SOLO_MEASURE, MASTER_DISCOVER, MASTER_WAIT_READY, + MASTER_WAIT_RESULT, SLAVE_READY, SLAVE_WAIT_START, SLAVE_MEASURE, + SLAVE_WAIT_ACK, FINISHED +}; + +class App { + public: + App(); + void begin(); + void update(); + private: + void showIdle(); + void finishInitialization(bool factoryReset); + void showMenu(); + void changeMenu(int direction); + void sanitizeRange(); + void startTest(); + bool prepareStage(); + bool startLocalMeasurement(float hz, float duty); + void startMasterDiscovery(); + void handleRadio(); + void updateMaster(); + void updateSlave(); + void stagePassed(); + void finish(bool pass, FailReason reason); + void abortTest(); + void sendAbort(); + void printConfiguration(); + void printStageStats(const StageStats &s, uint32_t hz); + uint64_t actualNominalTotalUs(); + ProtocolPacket makePacket(MessageType type) const; + void sendCurrent(MessageType type); + bool packetForCurrent(const ProtocolPacket &p) const; + + Button startButton_, modeButton_; + Display display_; + SettingsStore store_; + Settings settings_ = {}; + TestParams params_ = {}; + PwmGenerator pwm_; + PulseReceiver receiver_; + Measurement measurement_; + Radio radio_; + AppState state_ = AppState::IDLE; + uint8_t menuItem_ = 0; + uint32_t stageIndex_ = 0, stageCount_ = 0; + uint32_t requestedHz_ = 0; + ActualPwm actual_ = {}; + FailReason pendingReason_ = FailReason::NONE; + uint32_t session_ = 0; + uint16_t sequence_ = 0; + uint8_t peer_[6] = {}; + bool havePeer_ = false; + uint32_t deadlineMs_ = 0, lastSendMs_ = 0; + uint8_t retries_ = 0; + ProtocolPacket pendingPacket_ = {}; + bool initialized_ = false, bootResetCandidate_ = false; + uint32_t bootCheckStartedMs_ = 0; +}; diff --git a/OpticalChannelTester/Buttons.cpp b/OpticalChannelTester/Buttons.cpp new file mode 100644 index 0000000..30d6a9c --- /dev/null +++ b/OpticalChannelTester/Buttons.cpp @@ -0,0 +1,28 @@ +#include "Buttons.h" +#include "Config.h" + +void Button::begin() { + pinMode(pin_, BUTTON_ACTIVE_LEVEL == LOW ? INPUT_PULLUP : INPUT_PULLDOWN); + raw_ = stable_ = (digitalRead(pin_) == BUTTON_ACTIVE_LEVEL); + changedAt_ = millis(); +} + +ButtonEvent Button::update(uint32_t now) { + const bool sample = (digitalRead(pin_) == BUTTON_ACTIVE_LEVEL); + if (sample != raw_) { raw_ = sample; changedAt_ = now; } + if (raw_ != stable_ && now - changedAt_ >= BUTTON_DEBOUNCE_MS) { + stable_ = raw_; + if (stable_) { + pressedAt_ = now; nextRepeat_ = now + BUTTON_LONG_PRESS_MS + BUTTON_REPEAT_DELAY_MS; + longSent_ = false; + } else if (!longSent_) return ButtonEvent::SHORT; + } + if (stable_ && !longSent_ && now - pressedAt_ >= BUTTON_LONG_PRESS_MS) { + longSent_ = true; return ButtonEvent::LONG; + } + if (stable_ && longSent_ && now >= nextRepeat_) { + nextRepeat_ += BUTTON_REPEAT_MS; return ButtonEvent::REPEAT; + } + return ButtonEvent::NONE; +} + diff --git a/OpticalChannelTester/Buttons.h b/OpticalChannelTester/Buttons.h new file mode 100644 index 0000000..272f859 --- /dev/null +++ b/OpticalChannelTester/Buttons.h @@ -0,0 +1,17 @@ +#pragma once +#include + +enum class ButtonEvent : uint8_t { NONE, SHORT, LONG, REPEAT }; + +class Button { + public: + explicit Button(uint8_t pin) : pin_(pin) {} + void begin(); + ButtonEvent update(uint32_t nowMs); + bool pressed() const { return stable_; } + private: + uint8_t pin_; + bool raw_ = false, stable_ = false, longSent_ = false; + uint32_t changedAt_ = 0, pressedAt_ = 0, nextRepeat_ = 0; +}; + diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h new file mode 100644 index 0000000..a2cc8a4 --- /dev/null +++ b/OpticalChannelTester/Config.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +// ------------------------- Hardware configuration ------------------------- +#if CONFIG_IDF_TARGET_ESP32C3 +constexpr bool TARGET_IS_C3 = true; +constexpr uint8_t GPIO_PWM = 3; +constexpr uint8_t GPIO_RX = 4; +constexpr uint8_t GPIO_BUTTON_START = 0; +constexpr uint8_t GPIO_BUTTON_MODE = 1; +constexpr uint8_t GPIO_SDA = 6; +constexpr uint8_t GPIO_SCL = 7; +#elif CONFIG_IDF_TARGET_ESP32S3 +constexpr bool TARGET_IS_C3 = false; +constexpr uint8_t GPIO_PWM = 4; +constexpr uint8_t GPIO_RX = 5; +constexpr uint8_t GPIO_BUTTON_START = 6; +constexpr uint8_t GPIO_BUTTON_MODE = 7; +constexpr uint8_t GPIO_SDA = 8; +constexpr uint8_t GPIO_SCL = 9; +#else +#error "Only ESP32-C3 and ESP32-S3 are supported" +#endif + +constexpr uint8_t OLED_ADDRESS = 0x3C; +constexpr uint8_t ESPNOW_WIFI_CHANNEL = 6; +constexpr uint32_t SERIAL_BAUD = 115200; + +#define BUTTON_ACTIVE_LEVEL LOW +#define RX_SIGNAL_INVERTED false +#define PWM_SAFE_LEVEL LOW +#define PWM_SETTLE_CYCLES 5U + +constexpr uint32_t BUTTON_DEBOUNCE_MS = 30; +constexpr uint32_t BUTTON_LONG_PRESS_MS = 800; +constexpr uint32_t BUTTON_REPEAT_DELAY_MS = 600; +constexpr uint32_t BUTTON_REPEAT_MS = 180; +constexpr uint32_t FACTORY_RESET_HOLD_MS = 1500; + +constexpr uint32_t LINK_DISCOVERY_TIMEOUT_MS = 3000; +constexpr uint32_t LINK_REPLY_TIMEOUT_MS = 800; +constexpr uint8_t LINK_PACKET_RETRIES = 3; +constexpr uint32_t LINK_RETRY_INTERVAL_MS = 100; +constexpr uint8_t NO_SIGNAL_TIMEOUT_PERIODS = 8; + +constexpr uint32_t C3_STRICT_MAX_HZ = 100000; +constexpr uint32_t S3_STRICT_MAX_HZ = 1000000; +constexpr uint32_t C3_GUARANTEED_HZ = 10000; +constexpr uint32_t CAPTURE_RESOLUTION_HZ = 80000000; +constexpr uint8_t LEDC_CHANNEL = 0; +constexpr uint8_t LEDC_MAX_BITS = 14; + +// -------------------------- Menu value arrays ----------------------------- +constexpr uint32_t START_FREQ_OPTIONS_HZ[] = {1000, 2000, 5000, 10000, 20000, 50000}; +constexpr uint32_t END_FREQ_OPTIONS_HZ[] = {100000, 200000, 500000, 750000, 1000000}; +constexpr uint32_t STEP_OPTIONS_HZ[] = {1000, 2000, 5000, 10000, 20000, 50000, 100000}; +constexpr float ACCURACY_OPTIONS_PCT[] = {1.0f, 2.0f, 5.0f, 10.0f}; +constexpr uint32_t TEST_TIME_OPTIONS_MS[] = {100, 250, 500, 1000, 2000, 5000}; +constexpr uint8_t REPEAT_OPTIONS[] = {1, 2, 3, 5, 10}; +constexpr uint8_t DUTY_OPTIONS_PCT[] = {10, 25, 50, 75, 90}; + +template constexpr size_t countOf(const T (&)[N]) { return N; } diff --git a/OpticalChannelTester/Core.cpp b/OpticalChannelTester/Core.cpp new file mode 100644 index 0000000..009f17d --- /dev/null +++ b/OpticalChannelTester/Core.cpp @@ -0,0 +1,101 @@ +#include "Core.h" +#include +#include + +const char *roleName(Role r) { + static const char *names[] = {"SOLO", "MASTER", "SLAVE"}; + const uint8_t i = static_cast(r); + return i < 3 ? names[i] : "?"; +} + +const char *failName(FailReason r) { + static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "DUTY OUT", + "EXTRA EDGE", "GLITCH", "LOST EDGE", "TOO FEW PERIODS", "LINK LOST", + "UNSUPPORTED", "RESOLUTION", "ABORTED"}; + const uint8_t i = static_cast(r); + return i < (sizeof(names) / sizeof(names[0])) ? names[i] : "UNKNOWN"; +} + +void StageStats::reset() { + memset(this, 0, sizeof(*this)); + minPeriod = minActive = UINT32_MAX; + reason = FailReason::NONE; +} + +uint32_t settingsChecksum(const Settings &s) { + const uint8_t *p = reinterpret_cast(&s); + const size_t n = offsetof(Settings, checksum); + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < n; ++i) { hash ^= p[i]; hash *= 16777619UL; } + return hash; +} + +uint32_t frequencyPointCount(uint32_t startHz, uint32_t endHz, uint32_t stepHz) { + if (!startHz || !stepHz || endHz <= startHz) return 0; + const uint64_t span = static_cast(endHz) - startHz; + return static_cast(span / stepHz + 1U + ((span % stepHz) ? 1U : 0U)); +} + +uint32_t frequencyAt(uint32_t startHz, uint32_t endHz, uint32_t stepHz, uint32_t index) { + const uint32_t count = frequencyPointCount(startHz, endHz, stepHz); + if (!count || index >= count) return 0; + if (index == count - 1) return endHz; + const uint64_t v = static_cast(startHz) + static_cast(stepHz) * index; + return v < endHz ? static_cast(v) : endHz; +} + +uint64_t nominalTotalUs(const TestParams &p, uint32_t settleCycles) { + uint64_t total = 0; + const uint32_t count = frequencyPointCount(p.startHz, p.endHz, p.stepHz); + for (uint32_t i = 0; i < count; ++i) { + const uint32_t f = frequencyAt(p.startHz, p.endHz, p.stepHz, i); + total += (1000000ULL * settleCycles + f - 1) / f; + total += static_cast(p.testTimeMs) * 1000ULL * p.repeats; + } + return total; +} + +bool periodWithin(float measured, float expected, float tolerance) { + return expected > 0.0f && fabsf(measured - expected) * 100.0f / expected <= tolerance + 0.0001f; +} + +bool dutyWithin(float measured, float expected, float tolerance) { + return fabsf(measured - expected) <= tolerance + 0.0001f; +} + +FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct, + uint32_t captureHz, uint8_t pwmBits) { + if (!frequencyHz || !captureHz || !pwmBits) return FailReason::RESOLUTION; + const float periodTicks = static_cast(captureHz) / frequencyHz; + const float activeTicks = periodTicks * dutyPct / 100.0f; + const float inactiveTicks = periodTicks - activeTicks; + if (periodTicks < 4.0f || activeTicks < 2.0f || inactiveTicks < 2.0f) return FailReason::RESOLUTION; + const float timerPeriodError = 100.0f / periodTicks; + const float timerDutyError = 100.0f / periodTicks; + const float pwmDutyStep = 100.0f / static_cast((1UL << pwmBits) - 1UL); + return (timerPeriodError > accuracyPct || timerDutyError > accuracyPct || pwmDutyStep > accuracyPct) + ? FailReason::RESOLUTION : FailReason::NONE; +} + +FailReason evaluatePeriod(const PulsePeriod &p, uint32_t tickHz, float expectedHz, + float expectedDuty, float tolerance, uint8_t repeat, + StageStats &s) { + if (!p.periodTicks || p.activeTicks >= p.periodTicks) return FailReason::EXTRA_EDGE; + const float hz = static_cast(tickHz) / p.periodTicks; + const float duty = 100.0f * p.activeTicks / p.periodTicks; + ++s.periods; + s.periodSum += p.periodTicks; s.activeSum += p.activeTicks; + if (p.periodTicks < s.minPeriod) s.minPeriod = p.periodTicks; + if (p.periodTicks > s.maxPeriod) s.maxPeriod = p.periodTicks; + if (p.activeTicks < s.minActive) s.minActive = p.activeTicks; + if (p.activeTicks > s.maxActive) s.maxActive = p.activeTicks; + FailReason reason = FailReason::NONE; + if (!periodWithin(hz, expectedHz, tolerance)) reason = FailReason::PERIOD_OUT; + else if (!dutyWithin(duty, expectedDuty, tolerance)) reason = FailReason::DUTY_OUT; + if (reason != FailReason::NONE && s.reason == FailReason::NONE) { + s.reason = reason; s.firstBadPeriod = s.periods; s.firstBadRepeat = repeat; + s.badFrequency = hz; s.badDuty = duty; + } + return reason; +} + diff --git a/OpticalChannelTester/Core.h b/OpticalChannelTester/Core.h new file mode 100644 index 0000000..4a2688b --- /dev/null +++ b/OpticalChannelTester/Core.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +enum class Role : uint8_t { SOLO, MASTER, SLAVE }; +enum class FailReason : uint8_t { + NONE, NO_SIGNAL, PERIOD_OUT, DUTY_OUT, EXTRA_EDGE, GLITCH, LOST_EDGE, + TOO_FEW_PERIODS, LINK_LOST, UNSUPPORTED, RESOLUTION, ABORTED +}; + +const char *roleName(Role role); +const char *failName(FailReason reason); + +struct Settings { + uint16_t version; + uint8_t role; + uint8_t startIndex; + uint8_t endIndex; + uint8_t stepIndex; + uint8_t accuracyIndex; + uint8_t timeIndex; + uint8_t repeatIndex; + uint8_t dutyIndex; + uint32_t checksum; +}; + +struct TestParams { + uint32_t startHz; + uint32_t endHz; + uint32_t stepHz; + float accuracyPct; + uint32_t testTimeMs; + uint8_t repeats; + uint8_t dutyPct; +}; + +struct PulsePeriod { + uint64_t startTick; + uint32_t periodTicks; + uint32_t activeTicks; +}; + +struct StageStats { + uint32_t periods; + uint64_t periodSum; + uint64_t activeSum; + uint32_t minPeriod; + uint32_t maxPeriod; + uint32_t minActive; + uint32_t maxActive; + uint32_t firstBadPeriod; + uint8_t firstBadRepeat; + float badFrequency; + float badDuty; + FailReason reason; + void reset(); +}; + +uint32_t settingsChecksum(const Settings &s); +uint32_t frequencyPointCount(uint32_t startHz, uint32_t endHz, uint32_t stepHz); +uint32_t frequencyAt(uint32_t startHz, uint32_t endHz, uint32_t stepHz, uint32_t index); +uint64_t nominalTotalUs(const TestParams &p, uint32_t settleCycles); +bool periodWithin(float measuredHz, float expectedHz, float tolerancePct); +bool dutyWithin(float measuredPct, float expectedPct, float tolerancePct); +FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct, + uint32_t captureResolutionHz, uint8_t pwmBits); +FailReason evaluatePeriod(const PulsePeriod &period, uint32_t tickHz, float expectedHz, + float expectedDuty, float tolerancePct, uint8_t repeat, + StageStats &stats); + diff --git a/OpticalChannelTester/Display.cpp b/OpticalChannelTester/Display.cpp new file mode 100644 index 0000000..6cbb409 --- /dev/null +++ b/OpticalChannelTester/Display.cpp @@ -0,0 +1,44 @@ +#include "Display.h" +#include "Config.h" +#include +#include + +Display::Display() : oled_(128, 32, &Wire, -1) {} + +bool Display::begin() { + Wire.begin(GPIO_SDA, GPIO_SCL); + ok_ = oled_.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); + if (ok_) { oled_.setTextColor(SSD1306_WHITE); oled_.setTextSize(1); } + return ok_; +} + +void Display::fit(char *s) { + int16_t x, y; uint16_t w, h; + while (*s) { + oled_.getTextBounds(s, 0, 0, &x, &y, &w, &h); + if (w <= 128) break; + s[strlen(s) - 1] = '\0'; + } +} + +void Display::show(const char *a, const char *b) { + if (!ok_) return; + char one[32], two[32]; + snprintf(one, sizeof(one), "%s", a ? a : ""); snprintf(two, sizeof(two), "%s", b ? b : ""); + fit(one); fit(two); + oled_.clearDisplay(); oled_.setCursor(0, 3); oled_.print(one); + oled_.setCursor(0, 19); oled_.print(two); oled_.display(); +} + +void Display::formatFrequency(float hz, char *out, size_t n) { + if (hz >= 1000000.0f) snprintf(out, n, "%.2fM", hz / 1000000.0f); + else if (hz >= 1000.0f) snprintf(out, n, "%.2fk", hz / 1000.0f); + else snprintf(out, n, "%.0fHz", hz); +} + +void Display::formatDuration(uint64_t us, char *out, size_t n) { + const uint64_t minutes = us / 60000000ULL; + if (minutes < 60) snprintf(out, n, "%02llu:%02llu", minutes, (us / 1000000ULL) % 60ULL); + else snprintf(out, n, "%llu:%02llu", minutes / 60ULL, minutes % 60ULL); +} + diff --git a/OpticalChannelTester/Display.h b/OpticalChannelTester/Display.h new file mode 100644 index 0000000..d27ff16 --- /dev/null +++ b/OpticalChannelTester/Display.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include + +class Display { + public: + Display(); + bool begin(); + void show(const char *line1, const char *line2); + bool available() const { return ok_; } + static void formatFrequency(float 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 new file mode 100644 index 0000000..ea8a988 --- /dev/null +++ b/OpticalChannelTester/Measurement.cpp @@ -0,0 +1,68 @@ +#include "Measurement.h" +#include "Config.h" +#include + +bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs, + uint8_t repeats, uint8_t settleCycles) { + if (!hz || !timeMs || !repeats || repeats > 10 || !receiver_.start(static_cast(hz))) return false; + expectedHz_ = hz; expectedDuty_ = duty; tolerance_ = tolerance; + timeMs_ = timeMs; repeats_ = repeats; settleLeft_ = settleCycles; + stats_.reset(); memset(repeatPeriods_, 0, sizeof(repeatPeriods_)); + measurementStartTick_ = deadlineTick_ = 0; startedMs_ = millis(); + measurementStartMs_ = lastPeriodMs_ = 0; + state_ = MeasureState::SETTLING; return true; +} + +void Measurement::fail(FailReason reason) { + if (stats_.reason == FailReason::NONE) stats_.reason = reason; + receiver_.stop(); state_ = MeasureState::FAIL; +} + +MeasureState Measurement::update() { + if (state_ != MeasureState::SETTLING && state_ != MeasureState::RUNNING) return state_; + if (receiver_.overflowed()) { fail(FailReason::GLITCH); return state_; } + PulsePeriod period; + while (receiver_.poll(period)) { + if (state_ == MeasureState::SETTLING) { + if (settleLeft_) --settleLeft_; + if (!settleLeft_) { + measurementStartTick_ = period.startTick + period.periodTicks; + repeatTicks_ = static_cast(receiver_.tickHz()) * timeMs_ / 1000ULL; + deadlineTick_ = measurementStartTick_ + repeatTicks_ * repeats_; + stats_.reset(); 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_) break; // trailing incomplete period + uint8_t repeat = static_cast((period.startTick - measurementStartTick_) / repeatTicks_); + if (repeat >= repeats_) repeat = repeats_ - 1; + ++repeatPeriods_[repeat]; + lastPeriodMs_ = millis(); + const FailReason r = evaluatePeriod(period, receiver_.tickHz(), expectedHz_, expectedDuty_, + tolerance_, repeat + 1, stats_); + if (r != FailReason::NONE) { fail(r); return state_; } + } + const uint64_t expectedPeriodMs = static_cast(1000.0f / expectedHz_) + 1; + const uint64_t settleTimeout = (static_cast(PWM_SETTLE_CYCLES + NO_SIGNAL_TIMEOUT_PERIODS) * + expectedPeriodMs) + 20; + 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 = static_cast(expectedPeriodMs * NO_SIGNAL_TIMEOUT_PERIODS + 2); + if (now - measurementStartMs_ < totalMs && now - lastPeriodMs_ > edgeTimeoutMs) { + fail(FailReason::LOST_EDGE); return state_; + } + if (now - measurementStartMs_ > totalMs + expectedPeriodMs + 2) { + for (uint8_t i = 0; i < repeats_; ++i) if (!repeatPeriods_[i]) { + fail(FailReason::TOO_FEW_PERIODS); return state_; + } + receiver_.stop(); state_ = MeasureState::PASS; + } + } + return state_; +} + +void Measurement::abort() { if (state_ == MeasureState::SETTLING || state_ == MeasureState::RUNNING) fail(FailReason::ABORTED); } diff --git a/OpticalChannelTester/Measurement.h b/OpticalChannelTester/Measurement.h new file mode 100644 index 0000000..68a18f7 --- /dev/null +++ b/OpticalChannelTester/Measurement.h @@ -0,0 +1,27 @@ +#pragma once +#include "Receiver.h" + +enum class MeasureState : uint8_t { IDLE, SETTLING, RUNNING, PASS, FAIL }; + +class Measurement { + public: + explicit Measurement(PulseReceiver &receiver) : receiver_(receiver) {} + bool start(float expectedHz, float expectedDuty, float tolerancePct, + uint32_t testTimeMs, uint8_t repeats, uint8_t settleCycles); + MeasureState update(); + void abort(); + MeasureState state() const { return state_; } + FailReason reason() const { return stats_.reason; } + const StageStats &stats() const { return stats_; } + private: + void fail(FailReason reason); + PulseReceiver &receiver_; + MeasureState state_ = MeasureState::IDLE; + StageStats stats_ = {}; + float expectedHz_ = 0, expectedDuty_ = 0, tolerance_ = 0; + uint32_t timeMs_ = 0; + uint8_t repeats_ = 0, settleLeft_ = 0; + uint64_t measurementStartTick_ = 0, deadlineTick_ = 0, repeatTicks_ = 0; + uint32_t startedMs_ = 0, measurementStartMs_ = 0, lastPeriodMs_ = 0; + uint32_t repeatPeriods_[10] = {}; +}; diff --git a/OpticalChannelTester/OpticalChannelTester.ino b/OpticalChannelTester/OpticalChannelTester.ino new file mode 100644 index 0000000..1f8a4c9 --- /dev/null +++ b/OpticalChannelTester/OpticalChannelTester.ino @@ -0,0 +1,7 @@ +#include "App.h" + +App app; + +void setup() { app.begin(); } +void loop() { app.update(); } + diff --git a/OpticalChannelTester/Protocol.cpp b/OpticalChannelTester/Protocol.cpp new file mode 100644 index 0000000..f7c4c8b --- /dev/null +++ b/OpticalChannelTester/Protocol.cpp @@ -0,0 +1,22 @@ +#include "Protocol.h" +#include + +uint16_t packetCrc(const ProtocolPacket &p) { + const uint8_t *data = reinterpret_cast(&p); + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < offsetof(ProtocolPacket, crc); ++i) { + crc ^= static_cast(data[i]) << 8; + for (uint8_t b = 0; b < 8; ++b) crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : crc << 1; + } + return crc; +} + +void finalizePacket(ProtocolPacket &p) { + p.magic = PROTOCOL_MAGIC; p.version = PROTOCOL_VERSION; p.crc = packetCrc(p); +} + +bool validPacket(const ProtocolPacket &p) { + return p.magic == PROTOCOL_MAGIC && p.version == PROTOCOL_VERSION && + p.type <= static_cast(MessageType::ABORT) && p.crc == packetCrc(p); +} + diff --git a/OpticalChannelTester/Protocol.h b/OpticalChannelTester/Protocol.h new file mode 100644 index 0000000..7946b64 --- /dev/null +++ b/OpticalChannelTester/Protocol.h @@ -0,0 +1,39 @@ +#pragma once +#include "Core.h" + +constexpr uint16_t PROTOCOL_MAGIC = 0x4F43; +constexpr uint8_t PROTOCOL_VERSION = 1; + +enum class MessageType : uint8_t { + DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT +}; + +#pragma pack(push, 1) +struct ProtocolPacket { + uint16_t magic; + uint8_t version; + uint8_t type; + uint32_t session; + uint16_t stage; + uint16_t sequence; + uint32_t requestedHz; + uint32_t actualHz; + uint16_t actualDutyX100; + uint32_t testTimeMs; + uint8_t repeats; + uint16_t accuracyX100; + uint8_t settleCycles; + uint8_t passed; + uint8_t reason; + uint32_t periods; + uint32_t minPeriodTicks; + uint32_t maxPeriodTicks; + uint16_t crc; +}; +#pragma pack(pop) + +static_assert(sizeof(ProtocolPacket) == 46, "Protocol layout changed"); + +uint16_t packetCrc(const ProtocolPacket &packet); +void finalizePacket(ProtocolPacket &packet); +bool validPacket(const ProtocolPacket &packet); diff --git a/OpticalChannelTester/Pwm.cpp b/OpticalChannelTester/Pwm.cpp new file mode 100644 index 0000000..5ea68b0 --- /dev/null +++ b/OpticalChannelTester/Pwm.cpp @@ -0,0 +1,39 @@ +#include "Pwm.h" +#include "Config.h" + +void PwmGenerator::begin() { pinMode(GPIO_PWM, OUTPUT); stop(); } + +bool PwmGenerator::start(uint32_t hz, uint8_t dutyPct, ActualPwm &a) { + stop(); + uint8_t bits = LEDC_MAX_BITS; + while (bits > 1 && static_cast(hz) * (1ULL << bits) > 80000000ULL) --bits; + if (!ledcAttachChannel(GPIO_PWM, hz, bits, LEDC_CHANNEL)) return false; + const uint32_t top = (1UL << bits) - 1UL; + const uint32_t duty = (static_cast(top) * dutyPct + 50U) / 100U; + if (!ledcWriteChannel(LEDC_CHANNEL, duty)) { ledcDetach(GPIO_PWM); return false; } + const uint32_t actualHz = ledcReadFreq(GPIO_PWM); + if (!actualHz) { ledcDetach(GPIO_PWM); return false; } + a = {hz, actualHz, 100.0f * duty / top, bits}; + running_ = true; + return true; +} + +bool PwmGenerator::preview(uint32_t hz, uint8_t dutyPct, ActualPwm &a) { + stop(); + uint8_t bits = LEDC_MAX_BITS; + while (bits > 1 && static_cast(hz) * (1ULL << bits) > 80000000ULL) --bits; + if (!ledcAttachChannel(GPIO_PWM, hz, bits, LEDC_CHANNEL)) return false; + ledcWriteChannel(LEDC_CHANNEL, 0); // query hardware without emitting test pulses + const uint32_t actualHz = ledcReadFreq(GPIO_PWM); + const uint32_t top = (1UL << bits) - 1UL; + const uint32_t duty = (static_cast(top) * dutyPct + 50U) / 100U; + a = {hz, actualHz, 100.0f * duty / top, bits}; + ledcDetach(GPIO_PWM); pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL); + return actualHz != 0; +} + +void PwmGenerator::stop() { + if (running_) ledcDetach(GPIO_PWM); + pinMode(GPIO_PWM, OUTPUT); digitalWrite(GPIO_PWM, PWM_SAFE_LEVEL); + running_ = false; +} diff --git a/OpticalChannelTester/Pwm.h b/OpticalChannelTester/Pwm.h new file mode 100644 index 0000000..0601562 --- /dev/null +++ b/OpticalChannelTester/Pwm.h @@ -0,0 +1,15 @@ +#pragma once +#include + +struct ActualPwm { uint32_t requestedHz; uint32_t actualHz; float actualDutyPct; uint8_t bits; }; + +class PwmGenerator { + public: + void begin(); + bool preview(uint32_t frequencyHz, uint8_t dutyPct, ActualPwm &actual); + bool start(uint32_t frequencyHz, uint8_t dutyPct, ActualPwm &actual); + void stop(); + bool running() const { return running_; } + private: + bool running_ = false; +}; diff --git a/OpticalChannelTester/Radio.cpp b/OpticalChannelTester/Radio.cpp new file mode 100644 index 0000000..075bef8 --- /dev/null +++ b/OpticalChannelTester/Radio.cpp @@ -0,0 +1,59 @@ +#include "Radio.h" +#include "Config.h" +#include +#include +#include + +Radio *Radio::instance_ = nullptr; +static const uint8_t BROADCAST_MAC[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + +bool Radio::begin() { + if (active_) return true; + WiFi.mode(WIFI_STA); WiFi.disconnect(); + if (esp_wifi_set_channel(ESPNOW_WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK) return false; + queue_ = xQueueCreate(8, sizeof(ReceivedPacket)); + if (!queue_ || esp_now_init() != ESP_OK) return false; + instance_ = this; + if (esp_now_register_recv_cb(onReceive) != ESP_OK) { end(); return false; } + active_ = true; + return ensurePeer(BROADCAST_MAC); +} + +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; +} + +bool Radio::ensurePeer(const uint8_t mac[6]) { + if (esp_now_is_peer_exist(mac)) return true; + esp_now_peer_info_t peer = {}; + memcpy(peer.peer_addr, mac, 6); peer.channel = ESPNOW_WIFI_CHANNEL; peer.encrypt = false; + return esp_now_add_peer(&peer) == ESP_OK; +} + +bool Radio::sendBroadcast(ProtocolPacket p) { return sendTo(BROADCAST_MAC, p); } + +bool Radio::sendTo(const uint8_t mac[6], ProtocolPacket p) { + if (!active_ || !ensurePeer(mac)) return false; + finalizePacket(p); + return esp_now_send(mac, reinterpret_cast(&p), sizeof(p)) == ESP_OK; +} + +bool Radio::receive(ReceivedPacket &r) { + return queue_ && xQueueReceive(queue_, &r, 0) == pdTRUE; +} + +void Radio::flush() { if (queue_) xQueueReset(queue_); } + +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; + xQueueSend(instance_->queue_, &item, 0); // Wi-Fi task callback: copy only, never block +} + +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 new file mode 100644 index 0000000..2488254 --- /dev/null +++ b/OpticalChannelTester/Radio.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include "Protocol.h" + +struct ReceivedPacket { uint8_t mac[6]; ProtocolPacket packet; }; + +class Radio { + public: + bool begin(); + void end(); + bool sendBroadcast(ProtocolPacket packet); + bool sendTo(const uint8_t mac[6], ProtocolPacket packet); + bool receive(ReceivedPacket &received); + void flush(); + 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); + bool ensurePeer(const uint8_t mac[6]); + static Radio *instance_; + QueueHandle_t queue_ = nullptr; + bool active_ = false; +}; + diff --git a/OpticalChannelTester/Receiver.cpp b/OpticalChannelTester/Receiver.cpp new file mode 100644 index 0000000..f55bc94 --- /dev/null +++ b/OpticalChannelTester/Receiver.cpp @@ -0,0 +1,155 @@ +#include "Receiver.h" +#include "Config.h" +#include +#if !OPTICAL_USE_RMT_DMA +#include +#include +#endif + +uint32_t PulseReceiver::tickHz() const { +#if OPTICAL_USE_RMT_DMA + return CAPTURE_RESOLUTION_HZ; +#else + return cpuTickHz_; +#endif +} + +bool PulseReceiver::begin() { +#if OPTICAL_USE_RMT_DMA + queue_ = xQueueCreate(16, sizeof(SymbolBlock)); + rmt_rx_channel_config_t cfg = {}; + cfg.clk_src = RMT_CLK_SRC_DEFAULT; cfg.resolution_hz = CAPTURE_RESOLUTION_HZ; + cfg.mem_block_symbols = 512; cfg.gpio_num = static_cast(GPIO_RX); + cfg.flags.invert_in = RX_SIGNAL_INVERTED; +#if CONFIG_IDF_TARGET_ESP32S3 + cfg.flags.with_dma = true; +#else + cfg.flags.with_dma = false; // C3 uses hardware RMT ping-pong partial reception +#endif + if (!queue_ || rmt_new_rx_channel(&cfg, &channel_) != ESP_OK) return false; + rmt_rx_event_callbacks_t callbacks = {}; callbacks.on_recv_done = onRmt; + return rmt_rx_register_event_callbacks(channel_, &callbacks, this) == ESP_OK; +#else + queue_ = xQueueCreate(256, sizeof(Edge)); + if (!queue_) return false; + pinMode(GPIO_RX, INPUT); + cpuTickHz_ = getCpuFrequencyMhz() * 1000000UL; + attachInterruptArg(GPIO_RX, onGpio, this, CHANGE); + return cpuTickHz_ != 0; +#endif +} + +bool PulseReceiver::start(uint32_t expectedHz) { + resetStream(); +#if OPTICAL_USE_RMT_DMA + if (rmt_enable(channel_) != ESP_OK) return false; + rmt_receive_config_t cfg = {}; + cfg.signal_range_min_ns = 20; + const uint64_t maxNs = 4000000000ULL / (expectedHz ? expectedHz : 1); + cfg.signal_range_max_ns = maxNs > 100000000ULL ? 100000000UL : static_cast(maxNs); + cfg.flags.en_partial_rx = true; + if (rmt_receive(channel_, dmaBuffer_, sizeof(dmaBuffer_), &cfg) != ESP_OK) { + rmt_disable(channel_); return false; + } +#else + (void)expectedHz; +#endif + running_ = true; return true; +} + +void PulseReceiver::stop() { +#if OPTICAL_USE_RMT_DMA + if (running_) rmt_disable(channel_); +#endif + running_ = false; +} + +void PulseReceiver::resetStream() { + if (queue_) xQueueReset(queue_); + overflow_ = false; haveRise_ = haveFall_ = haveRawTick_ = false; + lastRawTick_ = 0; tickEpoch_ = rise_ = fall_ = 0; +#if OPTICAL_USE_RMT_DMA + block_ = {}; blockIndex_ = 0; phase_ = 0; haveLevel_ = false; level_ = false; rmtTick_ = 0; +#endif +} + +bool PulseReceiver::consumeEdge(const Edge &e, PulsePeriod &out) { + if (haveRawTick_ && e.tick < lastRawTick_ && lastRawTick_ - e.tick > 0x80000000UL) + tickEpoch_ += 0x100000000ULL; + haveRawTick_ = true; lastRawTick_ = e.tick; + const uint64_t tick = tickEpoch_ + e.tick; + if (e.rising) { + if (!haveRise_) { rise_ = tick; haveRise_ = true; haveFall_ = false; return false; } + if (!haveFall_) { overflow_ = true; rise_ = tick; return false; } + const uint32_t period = static_cast(tick - rise_); + const uint32_t active = fall_ - rise_; + out = {rise_, period, active}; rise_ = tick; haveFall_ = false; + return true; + } + if (!haveRise_ || haveFall_) { overflow_ = true; return false; } + fall_ = tick; haveFall_ = true; return false; +} + +bool PulseReceiver::overflowed() { + const bool value = overflow_; overflow_ = false; return value; +} + +#if OPTICAL_USE_RMT_DMA +bool IRAM_ATTR PulseReceiver::onRmt(rmt_channel_handle_t, const rmt_rx_done_event_data_t *data, void *ctx) { + PulseReceiver *self = static_cast(ctx); + BaseType_t wake = pdFALSE; + size_t offset = 0; + while (offset < data->num_symbols) { + SymbolBlock b = {}; + b.count = static_cast((data->num_symbols - offset) > BLOCK_SYMBOLS ? + BLOCK_SYMBOLS : (data->num_symbols - offset)); + memcpy(b.symbols, data->received_symbols + offset, b.count * sizeof(rmt_symbol_word_t)); + if (xQueueSendFromISR(self->queue_, &b, &wake) != pdTRUE) self->overflow_ = true; + offset += b.count; + } + return wake == pdTRUE; +} + +bool PulseReceiver::nextRmtEdge(Edge &edge) { + for (;;) { + if (blockIndex_ >= block_.count) { + if (xQueueReceive(queue_, &block_, 0) != pdTRUE) return false; + blockIndex_ = 0; phase_ = 0; + } + const rmt_symbol_word_t &s = block_.symbols[blockIndex_]; + const bool nextLevel = phase_ == 0 ? s.level0 : s.level1; + const uint32_t duration = phase_ == 0 ? s.duration0 : s.duration1; + phase_ ^= 1; + if (phase_ == 0) ++blockIndex_; + if (!duration) continue; + if (!haveLevel_) { haveLevel_ = true; level_ = nextLevel; rmtTick_ += duration; continue; } + if (nextLevel != level_) { + level_ = nextLevel; edge = {rmtTick_, static_cast(nextLevel)}; + rmtTick_ += duration; return true; + } + rmtTick_ += duration; + } +} + +bool PulseReceiver::poll(PulsePeriod &period) { + Edge e; + while (nextRmtEdge(e)) if (consumeEdge(e, period)) return true; + return false; +} +#else +void IRAM_ATTR PulseReceiver::onGpio(void *ctx) { + PulseReceiver *self = static_cast(ctx); + bool level = gpio_get_level(static_cast(GPIO_RX)); + if (RX_SIGNAL_INVERTED) level = !level; + Edge e = {esp_cpu_get_cycle_count(), static_cast(level)}; + BaseType_t wake = pdFALSE; + if (xQueueSendFromISR(self->queue_, &e, &wake) != pdTRUE) self->overflow_ = true; + if (wake) portYIELD_FROM_ISR(); +} + +bool PulseReceiver::poll(PulsePeriod &period) { + Edge e; + while (xQueueReceive(queue_, &e, 0) == pdTRUE) if (consumeEdge(e, period)) return true; + return false; +} +#endif diff --git a/OpticalChannelTester/Receiver.h b/OpticalChannelTester/Receiver.h new file mode 100644 index 0000000..6a24a56 --- /dev/null +++ b/OpticalChannelTester/Receiver.h @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include "Core.h" + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +#define OPTICAL_USE_RMT_DMA 1 +#include +#else +#define OPTICAL_USE_RMT_DMA 0 +#include +#endif + +class PulseReceiver { + public: + bool begin(); + bool start(uint32_t expectedHz); + void stop(); + void resetStream(); + bool poll(PulsePeriod &period); + bool overflowed(); + uint32_t tickHz() const; + bool highRateBackend() const { +#if OPTICAL_USE_RMT_DMA && CONFIG_IDF_TARGET_ESP32S3 + return true; +#else + return false; +#endif + } + private: + struct Edge { uint32_t tick; uint8_t rising; }; + bool consumeEdge(const Edge &edge, PulsePeriod &period); + +#if OPTICAL_USE_RMT_DMA + static constexpr size_t BLOCK_SYMBOLS = 64; + struct SymbolBlock { uint16_t count; rmt_symbol_word_t symbols[BLOCK_SYMBOLS]; }; + static bool IRAM_ATTR onRmt(rmt_channel_handle_t, const rmt_rx_done_event_data_t *, void *); + bool nextRmtEdge(Edge &edge); + rmt_channel_handle_t channel_ = nullptr; + rmt_symbol_word_t dmaBuffer_[512]; + SymbolBlock block_ = {}; + uint16_t blockIndex_ = 0; + uint8_t phase_ = 0; + bool haveLevel_ = false; + bool level_ = false; + uint32_t rmtTick_ = 0; +#else + static void IRAM_ATTR onGpio(void *ctx); + uint32_t cpuTickHz_ = 0; +#endif + QueueHandle_t queue_ = nullptr; + volatile bool overflow_ = false; + bool running_ = false; + bool haveRise_ = false, haveFall_ = false, haveRawTick_ = false; + uint32_t lastRawTick_ = 0; + uint64_t tickEpoch_ = 0, rise_ = 0, fall_ = 0; +}; diff --git a/OpticalChannelTester/SettingsStore.cpp b/OpticalChannelTester/SettingsStore.cpp new file mode 100644 index 0000000..38952c3 --- /dev/null +++ b/OpticalChannelTester/SettingsStore.cpp @@ -0,0 +1,45 @@ +#include "SettingsStore.h" +#include "Config.h" +#include + +namespace { constexpr uint16_t SETTINGS_VERSION = 1; constexpr char NAMESPACE[] = "opt-test"; } + +void SettingsStore::defaults(Settings &s) const { + s = {SETTINGS_VERSION, static_cast(Role::SOLO), 0, 4, 1, 2, 3, 2, 2, 0}; + s.checksum = settingsChecksum(s); +} + +bool SettingsStore::valid(const Settings &s) const { + return s.version == SETTINGS_VERSION && s.role <= static_cast(Role::SLAVE) && + s.startIndex < countOf(START_FREQ_OPTIONS_HZ) && s.endIndex < countOf(END_FREQ_OPTIONS_HZ) && + s.stepIndex < countOf(STEP_OPTIONS_HZ) && s.accuracyIndex < countOf(ACCURACY_OPTIONS_PCT) && + s.timeIndex < countOf(TEST_TIME_OPTIONS_MS) && s.repeatIndex < countOf(REPEAT_OPTIONS) && + s.dutyIndex < countOf(DUTY_OPTIONS_PCT) && s.checksum == settingsChecksum(s) && + END_FREQ_OPTIONS_HZ[s.endIndex] > START_FREQ_OPTIONS_HZ[s.startIndex]; +} + +bool SettingsStore::load(Settings &s) { + Preferences prefs; + if (!prefs.begin(NAMESPACE, true)) { defaults(s); return false; } + const size_t got = prefs.getBytes("settings", &s, sizeof(s)); + prefs.end(); + if (got != sizeof(s) || !valid(s)) { defaults(s); return false; } + return true; +} + +bool SettingsStore::save(Settings &s) { + s.version = SETTINGS_VERSION; s.checksum = settingsChecksum(s); + if (!valid(s)) return false; + Preferences prefs; + if (!prefs.begin(NAMESPACE, false)) return false; + const bool ok = prefs.putBytes("settings", &s, sizeof(s)) == sizeof(s); + prefs.end(); return ok; +} + +TestParams SettingsStore::params(const Settings &s) const { + return {START_FREQ_OPTIONS_HZ[s.startIndex], END_FREQ_OPTIONS_HZ[s.endIndex], + STEP_OPTIONS_HZ[s.stepIndex], ACCURACY_OPTIONS_PCT[s.accuracyIndex], + TEST_TIME_OPTIONS_MS[s.timeIndex], REPEAT_OPTIONS[s.repeatIndex], + DUTY_OPTIONS_PCT[s.dutyIndex]}; +} + diff --git a/OpticalChannelTester/SettingsStore.h b/OpticalChannelTester/SettingsStore.h new file mode 100644 index 0000000..f4128ca --- /dev/null +++ b/OpticalChannelTester/SettingsStore.h @@ -0,0 +1,12 @@ +#pragma once +#include "Core.h" + +class SettingsStore { + public: + void defaults(Settings &s) const; + bool valid(const Settings &s) const; + bool load(Settings &s); + bool save(Settings &s); + TestParams params(const Settings &s) const; +}; + diff --git a/README.md b/README.md new file mode 100644 index 0000000..31fd909 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Optical Channel Tester — ESP32-C3 / ESP32-S3 + +Полностью рабочий Arduino IDE-проект для строгой проверки оптического цифрового канала. Устройство формирует PWM аппаратным LEDC, пропускает его через проверяемый канал и проверяет каждый завершённый период отдельно: частоту и заполнение. Поддерживаются роли `SOLO`, `MASTER` и `SLAVE`; роли выбираются только вручную. + +## Что реализовано + +- неблокирующий конечный автомат без `pulseIn()` и длинных `delay()`; +- аппаратный LEDC с расчётом реально получившихся частоты, разрядности и duty; +- Arduino-ESP32 3.3.10: потоковый аппаратный RMT RX (`partial RX`), ping-pong на C3 и DMA на S3; +- безопасный fallback для старых Arduino-ESP32 3.x через GPIO edge ISR и аппаратный CPU cycle counter (верхний предел при этом автоматически ограничен 100 кГц); +- отбрасывание ровно `PWM_SETTLE_CYCLES` полных периодов; +- потоковая статистика без хранения всех периодов; +- немедленный FAIL по первому плохому периоду; +- непрерывное измерение повторов без слепых промежутков; +- ESP-NOW discovery, handshake, CRC, session/stage/sequence, ACK, повторы и защита от старых пакетов; +- SSD1306 128×32: каждый экран всегда состоит ровно из двух строк; +- две кнопки, debounce, long press и repeat; после long press ложный short click не создаётся; +- Preferences/NVS с версией структуры, checksum, проверкой индексов и восстановлением defaults; +- работа через Serial при отсутствующем OLED; +- безопасное выключение PWM при PASS, FAIL, ABORT и потере связи. + +## Файлы + +Arduino sketch находится в каталоге `OpticalChannelTester`: + +- `OpticalChannelTester.ino` — стандартная точка входа Arduino IDE; +- `Config.h` — GPIO, тайм-ауты, пределы и все пользовательские массивы; +- `Core.*` — последовательность частот, ALL, допуски, статистика; +- `Buttons.*` — автомат двух кнопок; +- `SettingsStore.*` — NVS; +- `Display.*` — OLED и компактное форматирование; +- `Pwm.*` — LEDC; +- `Receiver.*` — RMT RX / совместимый fallback; +- `Measurement.*` — строгая проверка периодов; +- `Protocol.*`, `Radio.*` — ESP-NOW; +- `App.*` — общий конечный автомат SOLO/MASTER/SLAVE. + +Каталог `tests` содержит локальные unit-тесты чистой логики и автомата кнопок. + +## Требования Arduino IDE + +Проверенная конфигурация: + +- Arduino IDE 2.x; +- пакет плат **esp32 by Espressif Systems 3.3.10**; +- **Adafruit GFX Library 1.12.1**; +- **Adafruit SSD1306 2.5.15**; +- Adafruit BusIO устанавливается Library Manager как зависимость. + +В Arduino IDE откройте `OpticalChannelTester/OpticalChannelTester.ino`. Не переносите один `.ino` отдельно: остальные вкладки являются частью скетча. + +Для C3 выберите подходящую плату ESP32-C3, например `ESP32C3 Dev Module`. Для S3 — `ESP32S3 Dev Module`. Затем выберите порт и нажмите Verify/Upload. + +## GPIO по умолчанию + +Все назначения находятся в начале `Config.h` и могут быть изменены до сборки. + +| Сигнал | ESP32-C3 | ESP32-S3 | +|---|---:|---:| +| PWM output | GPIO 3 | GPIO 4 | +| Optical RX input | GPIO 4 | GPIO 5 | +| START | GPIO 0 | GPIO 6 | +| MODE | GPIO 1 | GPIO 7 | +| OLED SDA | GPIO 6 | GPIO 8 | +| OLED SCL | GPIO 7 | GPIO 9 | + +### OLED + +Подключите SSD1306 128×32: `VCC → 3.3 V`, `GND → GND`, `SDA/SCL` по таблице. Адрес по умолчанию `0x3C`. Если OLED не отвечает, тест продолжает работать и пишет диагностику в Serial 115200. + +### Кнопки + +По умолчанию задано: + +```cpp +#define BUTTON_ACTIVE_LEVEL LOW +``` + +Поэтому каждая кнопка подключается между своим GPIO и GND, а прошивка автоматически включает `INPUT_PULLUP`. + +Если установить `BUTTON_ACTIVE_LEVEL HIGH`, подключайте кнопку между GPIO и 3.3 V; автоматически будет использован `INPUT_PULLDOWN`. Других изменений логики не требуется. + +## Подключение сигнала + +### SOLO + +```text +ESP GPIO_PWM -> вход передатчика оптического канала +выход приёмника оптического канала -> ESP GPIO_RX +GND ESP -> GND входной/выходной электроники (если канал не гальванически развязан) +``` + +### MASTER / SLAVE + +```text +MASTER GPIO_PWM -> вход передатчика проверяемого канала +выход приёмника проверяемого канала -> SLAVE GPIO_RX +MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE +``` + +На обеих ESP должны совпадать `ESPNOW_WIFI_CHANNEL` и версия прошивки. Slave сначала переводится в `SLAVE READY` кнопкой START, затем START нажимается на Master. + +**GPIO ESP32 допускают только логические уровни 0…3.3 В.** Не подавайте 5 В на `GPIO_RX`; применяйте согласование уровня. Если оптический приёмник инвертирует сигнал, установите: + +```cpp +#define RX_SIGNAL_INVERTED true +``` + +## Управление + +В ожидании: + +- MODE short: `SOLO → MASTER → SLAVE → SOLO`, выбранная роль сохраняется; +- MODE long: открыть настройки; +- START short: начать тест; +- START long во время теста: ABORT, PWM немедленно выключается. + +В настройках: + +- MODE short: следующий параметр; +- MODE long: проверить диапазон, сохранить один раз в NVS и выйти; +- START short: следующее значение; +- START long: предыдущее значение, затем autorepeat при удержании. + +Удержание обеих кнопок минимум 1.5 с при включении восстанавливает defaults. + +## Настройка диапазона + +Редактируйте отдельные `constexpr`-массивы в `Config.h`. Начальная и конечная частоты намеренно находятся в разных массивах. Сохранённые индексы всегда проверяются; после изменения массивов повреждённая/несовместимая настройка не приводит к выходу за границы. + +Последовательность всегда начинается точно с START, идёт с STEP и завершается точно END. Например, `100…1000` с шагом `300` даёт `100, 400, 700, 1000`. Конечная точка не дублируется, вычисления выполняются через 64-битные промежуточные значения. + +`ALL` пересчитывается после изменения START, END, STEP, TEST TIME или REPEATS. Частота каждой точки предварительно запрашивается у LEDC с duty=0 (на выходе остаётся безопасный уровень), поэтому в расчёте используется фактически достижимая частота. Для каждой точки учитывается: + +```text +PWM_SETTLE_CYCLES / actualFrequency + TEST_TIME * REPEATS +``` + +## Строгая проверка + +После каждой перенастройки PWM приёмник: + +1. отбрасывает ровно `PWM_SETTLE_CYCLES` полных периодов; +2. очищает статистику; +3. непрерывно проверяет все полные периоды всех повторов. + +Незавершённый период в начале и конце окна не учитывается. Период, пересекающий границу повторов, не теряется. Для каждого периода отдельно вычисляются частота и duty; средние используются только для диагностики. Любой один выход за допуск немедленно завершает всю проверку. + +Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT`, `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `TOO FEW PERIODS`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`. + +### Пределы + +- C3: гарантированный проектный диапазон до 10 кГц; настраиваемый строгий предел по умолчанию 100 кГц; +- S3: строгий предел до 1 МГц только при рабочем RMT DMA; +- выше строгого аппаратного предела выдаётся `UNSUPPORTED`, ослабленной проверки нет; +- комбинации, для которых LEDC или RX timer не дают выбранную точность, завершаются `RESOLUTION` до запуска заведомо неверного теста. + +Реальный предел зависит от конкретной платы, разводки, формы фронтов, Wi-Fi нагрузки и оптического оборудования. Начинайте проверку с 100 Гц…10 кГц. + +## Первый запуск SOLO + +1. Соберите схему SOLO и ещё раз убедитесь, что на `GPIO_RX` не бывает напряжения выше 3.3 В. +2. Откройте Serial Monitor на 115200 baud. +3. Включите плату. На OLED должно быть `MODE: SOLO` / `START=RUN`. +4. Если выбран другой режим, коротко нажимайте MODE до SOLO. +5. Для первого опыта оставьте defaults: 100 Гц…10 кГц, duty 50%, accuracy 5%. +6. Коротко нажмите START. +7. Serial покажет запрошенные и фактические параметры LEDC, список частот, ALL и статистику каждой точки. +8. Успех: `PASS ...` / `START=REPEAT`. Ошибка: `FAIL AT ...` и точная причина. + +## Проверка проекта + +Финальный исходный код собран Arduino CLI, использующим тот же builder, что и Arduino IDE: + +| Target | Arduino-ESP32 | Flash | RAM | Результат | +|---|---:|---:|---:|---| +| ESP32-C3 | 3.3.10 | 1,020,337 B (77%) | 39,460 B (12%) | PASS | +| ESP32-S3 | 3.3.10 | 947,712 B (72%) | 48,620 B (14%) | PASS | + +Локальные unit-тесты: `core tests: PASS`, `button tests: PASS`. Они покрывают неделимый диапазон, END без дубля, ALL, границы допусков, немедленный FAIL, resolution, checksum настроек, CRC протокола и отсутствие short после long. + +Физическое оборудование в этой среде недоступно, поэтому реальные оптические фронты, RMT DMA под длительной нагрузкой, дальность ESP-NOW и электрическая совместимость должны быть проверены на ваших платах. Сборка и программные тесты не выдаются за аппаратный тест.