diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index 089de8f..5e82e6c 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -47,7 +47,9 @@ void App::finishInitialization(bool factoryReset) { initialized_ = true; if (!receiver_.begin()) { Log::event("BOOT", "FATAL: capture peripheral init failed"); finish(false, FailReason::UNSUPPORTED); return; } Log::printf("BOOT", "capture initialized: %s", receiver_.highRateBackend() ? "RMT DMA" : "RMT ping-pong"); - printConfiguration(); showIdle(); + printConfiguration(); + if (static_cast(settings_.role) == Role::SLAVE) armSlave(); + else showIdle(); } void App::update() { @@ -71,11 +73,26 @@ void App::update() { if (state_ == AppState::IDLE || state_ == AppState::FINISHED) { if (modeEvent == ButtonEvent::SHORT) { settings_.role = (settings_.role + 1U) % 3U; const bool saved = store_.save(settings_); - params_ = store_.params(settings_); showIdle(); + params_ = store_.params(settings_); + if (static_cast(settings_.role) == Role::SLAVE) armSlave(); + else showIdle(); Log::printf("ACTION", "role changed to %s, NVS=%s", roleName(static_cast(settings_.role)), saved ? "OK" : "FAILED"); } else if (modeEvent == ButtonEvent::LONG) { state_ = AppState::MENU; menuItem_ = 0; Log::event("ACTION", "settings menu entered"); showMenu(); } else if (startEvent == ButtonEvent::SHORT) { Log::event("ACTION", "test start requested"); startTest(); } + else if (state_ == AppState::FINISHED && static_cast(settings_.role) == Role::SLAVE && + now >= slaveRearmAtMs_) armSlave(); + return; + } + if (state_ == AppState::SLAVE_READY && modeEvent != ButtonEvent::NONE) { + radio_.end(); havePeer_ = false; + if (modeEvent == ButtonEvent::SHORT) { + settings_.role = static_cast(Role::SOLO); const bool saved = store_.save(settings_); + params_ = store_.params(settings_); state_ = AppState::IDLE; showIdle(); + Log::printf("ACTION", "role changed to SOLO, NVS=%s", saved ? "OK" : "FAILED"); + } else if (modeEvent == ButtonEvent::LONG) { + state_ = AppState::MENU; menuItem_ = 0; showMenu(); + } return; } if (state_ == AppState::MENU) { @@ -85,7 +102,9 @@ void App::update() { else if (modeEvent == ButtonEvent::LONG) { sanitizeRange(); const bool saved = store_.save(settings_); params_ = store_.params(settings_); Log::printf("ACTION", "settings menu saved and closed, NVS=%s", saved ? "OK" : "FAILED"); - state_ = AppState::IDLE; printConfiguration(); showIdle(); + state_ = AppState::IDLE; printConfiguration(); + if (static_cast(settings_.role) == Role::SLAVE) armSlave(); + else showIdle(); } else if (startEvent == ButtonEvent::SHORT) changeMenu(+1); else if (startEvent == ButtonEvent::LONG || startEvent == ButtonEvent::REPEAT) changeMenu(-1); return; @@ -153,7 +172,8 @@ void App::showMenu() { void App::startTest() { params_ = store_.params(settings_); stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); - stageIndex_ = 0; pendingReason_ = FailReason::NONE; + stageIndex_ = 0; requestedHz_ = 0; pendingReason_ = FailReason::NONE; + havePeer_ = false; lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0; if (!stageCount_) { finish(false, FailReason::UNSUPPORTED); return; } Log::printf("TEST", "starting role=%s stages=%lu", roleName(static_cast(settings_.role)), stageCount_); if (SERIAL_MINIMAL_LOG) { @@ -175,6 +195,23 @@ 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() { + params_ = store_.params(settings_); + stageIndex_ = 0; stageCount_ = frequencyPointCount(params_.startHz, params_.endHz, params_.stepHz); + requestedHz_ = 0; session_ = 0; sequence_ = 0; havePeer_ = false; + lastHeartbeatMs_ = 0; lastPeerSeenMs_ = 0; retries_ = 0; slaveRearmAtMs_ = 0; + if (!radio_.begin()) { + state_ = AppState::FINISHED; pendingReason_ = FailReason::LINK_LOST; + slaveRearmAtMs_ = millis() + LINK_HEARTBEAT_TIMEOUT_MS; + display_.show("LINK FAILED", "RADIO ERROR"); + return false; + } + radio_.flush(); state_ = AppState::SLAVE_READY; + Log::event("TEST", "Slave automatically armed and waiting for Master"); + display_.show("SLAVE READY", "WAIT MASTER"); + return true; +} + bool App::prepareStage() { requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, stageIndex_); const uint32_t maxHz = TARGET_IS_C3 ? C3_STRICT_MAX_HZ : @@ -226,11 +263,12 @@ void App::stagePassed() { } void App::startMasterDiscovery() { - session_ = esp_random(); if (!session_) session_ = 1; sequence_ = 1; havePeer_ = false; radio_.flush(); + session_ = esp_random(); if (!session_) session_ = 1; + sequence_ = 1; stageIndex_ = 0; requestedHz_ = 0; havePeer_ = false; radio_.flush(); pendingPacket_ = makePacket(MessageType::DISCOVER); radio_.sendBroadcast(pendingPacket_); - lastSendMs_ = millis(); deadlineMs_ = millis() + LINK_DISCOVERY_TIMEOUT_MS; retries_ = 0; + lastSendMs_ = millis(); retries_ = 0; state_ = AppState::MASTER_DISCOVER; Log::printf("ESP-NOW", "discovery started session=%08lX", session_); - display_.show("MASTER SEARCH", "WAIT SLAVE"); + display_.show("MASTER SEARCH", "HOLD START=STOP"); } ProtocolPacket App::makePacket(MessageType type) const { @@ -245,10 +283,27 @@ ProtocolPacket App::makePacket(MessageType type) const { void App::sendCurrent(MessageType type) { ++sequence_; pendingPacket_ = makePacket(type); - const bool ok = radio_.sendTo(peer_, pendingPacket_); lastSendMs_ = millis(); + const bool ok = radio_.sendBroadcast(pendingPacket_); lastSendMs_ = millis(); if (!ok) Log::printf("ESP-NOW", "sendCurrent %s FAILED", messageName(type)); } +void App::updateHeartbeat() { + if (!havePeer_ || state_ == AppState::FINISHED) return; + const uint32_t now = millis(); + if (now - lastPeerSeenMs_ >= LINK_HEARTBEAT_TIMEOUT_MS) { + Log::event("ESP-NOW", "peer heartbeat timeout"); + finish(false, FailReason::LINK_LOST); + return; + } + if (static_cast(settings_.role) == Role::MASTER && + now - lastHeartbeatMs_ >= LINK_HEARTBEAT_INTERVAL_MS) { + ProtocolPacket heartbeat = makePacket(MessageType::HEARTBEAT); + heartbeat.sequence = sequence_; + radio_.sendBroadcast(heartbeat); + lastHeartbeatMs_ = now; + } +} + bool App::packetForCurrent(const ProtocolPacket &p) const { return p.session == session_ && p.stage == stageIndex_; } @@ -257,29 +312,35 @@ void App::handleRadio() { ReceivedPacket r; while (radio_.receive(r)) { const MessageType type = static_cast(r.packet.type); - if (state_ != AppState::SLAVE_MEASURE) + if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK && + state_ != AppState::SLAVE_MEASURE) Log::printf("ESP-NOW", "RX %s session=%08lX stage=%u seq=%u", messageName(type), r.packet.session, r.packet.stage, r.packet.sequence); - if (state_ == AppState::SLAVE_READY && type == MessageType::DISCOVER) { + if ((state_ == AppState::SLAVE_READY || state_ == AppState::SLAVE_WAIT_START) && + 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; - 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; + lastPeerSeenMs_ = millis(); + ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; radio_.sendBroadcast(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_) { - memcpy(peer_, r.mac, 6); havePeer_ = true; requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, stageIndex_); + memcpy(peer_, r.mac, 6); havePeer_ = true; lastPeerSeenMs_ = lastHeartbeatMs_ = millis(); + 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)); Log::printf("ESP-NOW", "Slave selected %s", 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) && 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; } + 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_.sendTo(peer_, ack); continue; // idempotent ACK for a retried old result + radio_.sendBroadcast(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; } @@ -288,12 +349,13 @@ void App::handleRadio() { 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(); + 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_.sendTo(peer_, ready); + ProtocolPacket ready = makePacket(MessageType::READY); ready.sequence = r.packet.sequence; radio_.sendBroadcast(ready); + display_.show("SLAVE LINKED", "MASTER ONLINE"); } 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; } @@ -308,22 +370,25 @@ void App::handleRadio() { 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) { - Log::printf("ESP-NOW", "DISCOVER retry=%u", retries_ + 1); radio_.sendBroadcast(pendingPacket_); - lastSendMs_ = now; ++retries_; + Log::event("ESP-NOW", "DISCOVER retry"); radio_.sendBroadcast(pendingPacket_); + lastSendMs_ = now; } 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_.sendTo(peer_, pendingPacket_); ++retries_; + radio_.sendBroadcast(pendingPacket_); ++retries_; deadlineMs_ = now + (state_ == AppState::MASTER_WAIT_RESULT ? params_.testTimeMs * params_.repeats + LINK_REPLY_TIMEOUT_MS : LINK_REPLY_TIMEOUT_MS); } void App::updateSlave() { + updateHeartbeat(); + if (state_ == AppState::FINISHED) return; if (state_ == AppState::SLAVE_MEASURE) { const MeasureState ms = measurement_.update(); if (ms != MeasureState::PASS && ms != MeasureState::FAIL) return; @@ -332,13 +397,13 @@ void App::updateSlave() { pendingPacket_.passed = ms == MeasureState::PASS && measurement_.reason() == FailReason::NONE; 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_); + pendingPacket_.sequence = ++sequence_; radio_.sendBroadcast(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_.sendTo(peer_, pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } + else { Log::printf("ESP-NOW", "RESULT retry=%u", retries_); radio_.sendBroadcast(pendingPacket_); deadlineMs_ = millis() + LINK_REPLY_TIMEOUT_MS; } } } @@ -351,14 +416,33 @@ void App::abortTest() { void App::finish(bool pass, FailReason reason) { 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); + const bool slaveLinkLost = reason == FailReason::LINK_LOST && + (failedState == AppState::SLAVE_READY || failedState == AppState::SLAVE_WAIT_START || + failedState == AppState::SLAVE_MEASURE || failedState == AppState::SLAVE_WAIT_ACK); pwm_.stop(); receiver_.stop(); + if (masterLinkLost) { + Log::event("ESP-NOW", "link lost; returning to continuous discovery"); + startMasterDiscovery(); + return; + } if (state_ != AppState::IDLE && state_ != AppState::MENU) radio_.end(); state_ = AppState::FINISHED; pendingReason_ = reason; + if (slaveLinkLost) { + if (armSlave()) display_.show("MASTER LOST", "WAIT MASTER"); + return; + } + if (static_cast(settings_.role) == Role::SLAVE) slaveRearmAtMs_ = millis() + 2000; char one[24]; if (pass) display_.show("PASS", "REPEAT"); - else { + else if (requestedHz_) { char frequency[12]; Display::formatFrequency(requestedHz_, frequency, sizeof(frequency)); snprintf(one, sizeof(one), "FAIL %s", frequency); display_.show(one, failName(reason)); + } else { + display_.show("TEST FAILED", failName(reason)); } } diff --git a/OpticalChannelTester/App.h b/OpticalChannelTester/App.h index 52c3035..31bc87d 100644 --- a/OpticalChannelTester/App.h +++ b/OpticalChannelTester/App.h @@ -24,6 +24,7 @@ class App { void changeMenu(int direction); void sanitizeRange(); void startTest(); + bool armSlave(); bool prepareStage(); bool startLocalMeasurement(float hz, float duty); void startMasterDiscovery(); @@ -39,6 +40,7 @@ class App { uint64_t actualNominalTotalUs(); ProtocolPacket makePacket(MessageType type) const; void sendCurrent(MessageType type); + void updateHeartbeat(); bool packetForCurrent(const ProtocolPacket &p) const; Button startButton_, modeButton_; @@ -61,8 +63,10 @@ class App { uint8_t peer_[6] = {}; bool havePeer_ = false; uint32_t deadlineMs_ = 0, lastSendMs_ = 0; + uint32_t lastHeartbeatMs_ = 0, lastPeerSeenMs_ = 0; uint8_t retries_ = 0; ProtocolPacket pendingPacket_ = {}; bool initialized_ = false, bootResetCandidate_ = false; uint32_t bootCheckStartedMs_ = 0; + uint32_t slaveRearmAtMs_ = 0; }; diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h index c402650..794bc18 100644 --- a/OpticalChannelTester/Config.h +++ b/OpticalChannelTester/Config.h @@ -7,22 +7,24 @@ 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_BUTTON_MODE = 0; +constexpr uint8_t GPIO_BUTTON_START = 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_BUTTON_MODE = 6; +constexpr uint8_t GPIO_BUTTON_START = 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_ROTATION = 0; + constexpr uint8_t OLED_ADDRESS = 0x3C; constexpr uint8_t ESPNOW_WIFI_CHANNEL = 6; constexpr uint32_t SERIAL_BAUD = 115200; @@ -41,10 +43,11 @@ 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 uint32_t LINK_REPLY_TIMEOUT_MS = 1500; +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 uint8_t NO_SIGNAL_TIMEOUT_PERIODS = 8; constexpr uint16_t RMT_MIN_RECEIVE_SYMBOLS = 48; constexpr uint16_t RMT_MAX_RECEIVE_SYMBOLS = 512; diff --git a/OpticalChannelTester/Display.cpp b/OpticalChannelTester/Display.cpp index 6287371..3be14c8 100644 --- a/OpticalChannelTester/Display.cpp +++ b/OpticalChannelTester/Display.cpp @@ -2,14 +2,28 @@ #include "Config.h" #include "Log.h" #include +#include #include Display::Display() : oled_(128, 32, &Wire, -1) {} bool Display::begin() { Wire.begin(GPIO_SDA, GPIO_SCL); + // An absent optional OLED produces a large burst of ESP-IDF NACK messages. + // Probe it once and keep the I2C driver quiet when no display is connected. + esp_log_level_set("i2c.master", ESP_LOG_NONE); + Wire.beginTransmission(OLED_ADDRESS); + if (Wire.endTransmission() != 0) { + ok_ = false; + Log::printf("OLED", "not detected at I2C address=0x%02X", OLED_ADDRESS); + return false; + } ok_ = oled_.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); - if (ok_) { oled_.setTextColor(SSD1306_WHITE); oled_.setTextSize(1); } + if (ok_) { + oled_.setRotation(OLED_ROTATION); + oled_.setTextColor(SSD1306_WHITE); + oled_.setTextSize(1); + } Log::printf("OLED", "initialization %s, I2C address=0x%02X", ok_ ? "OK" : "FAILED", OLED_ADDRESS); return ok_; } diff --git a/OpticalChannelTester/Log.cpp b/OpticalChannelTester/Log.cpp index 4565f2f..cf0cfe6 100644 --- a/OpticalChannelTester/Log.cpp +++ b/OpticalChannelTester/Log.cpp @@ -7,7 +7,7 @@ namespace Log { void event(const char *component, const char *message) { if (!SERIAL_ACTION_LOG) return; if (SERIAL_MINIMAL_LOG && strcmp(component, "INPUT") && strcmp(component, "UI") && - strcmp(component, "CONFIG") && strcmp(component, "RESULT")) return; + strcmp(component, "CONFIG") && strcmp(component, "RESULT") && strcmp(component, "ESP-NOW")) return; if (SERIAL_LOG_TIMESTAMPS) Serial.printf("[%10lu][%-8s] %s\n", millis(), component, message); else Serial.printf("[%-8s] %s\n", component, message); } diff --git a/OpticalChannelTester/Protocol.cpp b/OpticalChannelTester/Protocol.cpp index fe24d71..b4cd09f 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"}; + "START_STAGE", "RESULT", "ACK", "ABORT", "HEARTBEAT", "HEARTBEAT_ACK"}; 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::ABORT) && p.crc == packetCrc(p); + p.type <= static_cast(MessageType::HEARTBEAT_ACK) && p.crc == packetCrc(p); } diff --git a/OpticalChannelTester/Protocol.h b/OpticalChannelTester/Protocol.h index 61f1f80..529ca10 100644 --- a/OpticalChannelTester/Protocol.h +++ b/OpticalChannelTester/Protocol.h @@ -2,10 +2,11 @@ #include "Core.h" constexpr uint16_t PROTOCOL_MAGIC = 0x4F43; -constexpr uint8_t PROTOCOL_VERSION = 1; +constexpr uint8_t PROTOCOL_VERSION = 2; enum class MessageType : uint8_t { - DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT + DISCOVER, DISCOVER_ACK, PREPARE, READY, START_STAGE, RESULT, ACK, ABORT, + HEARTBEAT, HEARTBEAT_ACK }; const char *messageName(MessageType type); diff --git a/OpticalChannelTester/Radio.cpp b/OpticalChannelTester/Radio.cpp index a8011b4..3690a97 100644 --- a/OpticalChannelTester/Radio.cpp +++ b/OpticalChannelTester/Radio.cpp @@ -10,17 +10,36 @@ static const uint8_t BROADCAST_MAC[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; bool Radio::begin() { if (active_) { Log::event("ESP-NOW", "already active"); return true; } - WiFi.mode(WIFI_STA); WiFi.disconnect(); + // Keep both devices on a dedicated, fixed STA channel. Stored access-point + // credentials must not reconnect in the background and move ESP-NOW to the + // AP's channel. + WiFi.persistent(false); + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(false); + WiFi.disconnect(false, false); + WiFi.setSleep(false); + const bool txPowerOk = WiFi.setTxPower(WIFI_POWER_8_5dBm); + delay(10); 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_ || esp_now_init() != ESP_OK) { Log::event("ESP-NOW", "initialization FAILED"); return false; } + if (!queue_) { Log::event("ESP-NOW", "receive queue 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; const bool ok = ensurePeer(BROADCAST_MAC); - Log::printf("ESP-NOW", "started channel=%u broadcast-peer=%s", ESPNOW_WIFI_CHANNEL, ok ? "OK" : "FAILED"); + uint8_t primaryChannel = 0; + wifi_second_chan_t secondaryChannel = WIFI_SECOND_CHAN_NONE; + esp_wifi_get_channel(&primaryChannel, &secondaryChannel); + Log::printf("ESP-NOW", "started channel=%u expected=%u rate=1M%s tx-power=max%s broadcast-peer=%s", + primaryChannel, ESPNOW_WIFI_CHANNEL, rateResult == ESP_OK ? "" : "-FAILED", + txPowerOk ? "" : "-FAILED", ok ? "OK" : "FAILED"); return ok; } @@ -34,13 +53,17 @@ void Radio::end() { 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; + memcpy(peer.peer_addr, mac, 6); + peer.channel = ESPNOW_WIFI_CHANNEL; + peer.ifidx = WIFI_IF_STA; + 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) { + maintainChannel(); char peer[20]; macText(mac, peer, sizeof(peer)); if (!active_ || !ensurePeer(mac)) { Log::printf("ESP-NOW", "TX %s to %s FAILED: inactive/peer", messageName(static_cast(p.type)), peer); @@ -48,15 +71,32 @@ 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; - Log::printf("ESP-NOW", "TX %s to %s session=%08lX stage=%u seq=%u %s", - messageName(static_cast(p.type)), peer, p.session, p.stage, p.sequence, ok ? "QUEUED" : "FAILED"); + const MessageType type = static_cast(p.type); + if (type != MessageType::HEARTBEAT && type != MessageType::HEARTBEAT_ACK) + 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; } bool Radio::receive(ReceivedPacket &r) { + maintainChannel(); return queue_ && xQueueReceive(queue_, &r, 0) == pdTRUE; } +void Radio::maintainChannel() { + if (!active_) return; + const uint32_t now = millis(); + if (now - lastChannelCheckMs_ < 250) return; + lastChannelCheckMs_ = now; + uint8_t primaryChannel = 0; + wifi_second_chan_t secondaryChannel = WIFI_SECOND_CHAN_NONE; + if (esp_wifi_get_channel(&primaryChannel, &secondaryChannel) != ESP_OK || + primaryChannel == ESPNOW_WIFI_CHANNEL) return; + const bool restored = esp_wifi_set_channel(ESPNOW_WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE) == ESP_OK; + Log::printf("ESP-NOW", "channel drift %u->%u %s", primaryChannel, ESPNOW_WIFI_CHANNEL, + restored ? "RESTORED" : "FAILED"); +} + void Radio::flush() { if (queue_) xQueueReset(queue_); } void Radio::onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int length) { diff --git a/OpticalChannelTester/Radio.h b/OpticalChannelTester/Radio.h index 2488254..8038e51 100644 --- a/OpticalChannelTester/Radio.h +++ b/OpticalChannelTester/Radio.h @@ -17,8 +17,10 @@ class Radio { private: static void onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int length); bool ensurePeer(const uint8_t mac[6]); + void maintainChannel(); static Radio *instance_; QueueHandle_t queue_ = nullptr; bool active_ = false; + uint32_t lastChannelCheckMs_ = 0; };