codex init

This commit is contained in:
2026-08-05 16:49:05 +03:00
commit 3399e194ad
23 changed files with 1484 additions and 0 deletions

View File

@@ -0,0 +1,322 @@
#include "App.h"
#include "Config.h"
#include <WiFi.h>
#include <esp_mac.h>
#include <esp_system.h>
#include <string.h>
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<Role>(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<Role>(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<uint8_t>((*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<Role>(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<Role>(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<Role>(settings_.role) == Role::SOLO) { if (prepareStage()) state_ = AppState::SOLO_MEASURE; }
else if (static_cast<Role>(settings_.role) == Role::MASTER) {
requestedHz_ = frequencyAt(params_.startHz, params_.endHz, params_.stepHz, stageIndex_);
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<uint8_t>(type); p.session = session_; p.stage = stageIndex_; p.sequence = sequence_;
p.requestedHz = requestedHz_; p.actualHz = actual_.actualHz;
p.actualDutyX100 = static_cast<uint16_t>(actual_.actualDutyPct * 100.0f + 0.5f);
p.testTimeMs = params_.testTimeMs; p.repeats = params_.repeats;
p.accuracyX100 = static_cast<uint16_t>(params_.accuracyPct * 100.0f + 0.5f); p.settleCycles = PWM_SETTLE_CYCLES;
return p;
}
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<MessageType>(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<uint8_t>(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<FailReason>(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<FailReason>(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<uint8_t>(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<Role>(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<uint64_t>(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));
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -0,0 +1,17 @@
#pragma once
#include <Arduino.h>
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;
};

View File

@@ -0,0 +1,63 @@
#pragma once
#include <Arduino.h>
// ------------------------- 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 <typename T, size_t N> constexpr size_t countOf(const T (&)[N]) { return N; }

View File

@@ -0,0 +1,101 @@
#include "Core.h"
#include <math.h>
#include <string.h>
const char *roleName(Role r) {
static const char *names[] = {"SOLO", "MASTER", "SLAVE"};
const uint8_t i = static_cast<uint8_t>(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<uint8_t>(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<const uint8_t *>(&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<uint64_t>(endHz) - startHz;
return static_cast<uint32_t>(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<uint64_t>(startHz) + static_cast<uint64_t>(stepHz) * index;
return v < endHz ? static_cast<uint32_t>(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<uint64_t>(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<float>(captureHz) / frequencyHz;
const float activeTicks = periodTicks * dutyPct / 100.0f;
const float inactiveTicks = periodTicks - activeTicks;
if (periodTicks < 4.0f || activeTicks < 2.0f || inactiveTicks < 2.0f) return FailReason::RESOLUTION;
const float timerPeriodError = 100.0f / periodTicks;
const float timerDutyError = 100.0f / periodTicks;
const float pwmDutyStep = 100.0f / static_cast<float>((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<float>(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;
}

View File

@@ -0,0 +1,71 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
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);

View File

@@ -0,0 +1,44 @@
#include "Display.h"
#include "Config.h"
#include <Wire.h>
#include <string.h>
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);
}

View File

@@ -0,0 +1,19 @@
#pragma once
#include <Arduino.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
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;
};

View File

@@ -0,0 +1,68 @@
#include "Measurement.h"
#include "Config.h"
#include <string.h>
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<uint32_t>(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<uint64_t>(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<uint8_t>((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<uint64_t>(1000.0f / expectedHz_) + 1;
const uint64_t settleTimeout = (static_cast<uint64_t>(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<uint32_t>(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); }

View File

@@ -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] = {};
};

View File

@@ -0,0 +1,7 @@
#include "App.h"
App app;
void setup() { app.begin(); }
void loop() { app.update(); }

View File

@@ -0,0 +1,22 @@
#include "Protocol.h"
#include <stddef.h>
uint16_t packetCrc(const ProtocolPacket &p) {
const uint8_t *data = reinterpret_cast<const uint8_t *>(&p);
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < offsetof(ProtocolPacket, crc); ++i) {
crc ^= static_cast<uint16_t>(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<uint8_t>(MessageType::ABORT) && p.crc == packetCrc(p);
}

View File

@@ -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);

View File

@@ -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<uint64_t>(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<uint64_t>(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<uint64_t>(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<uint64_t>(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;
}

View File

@@ -0,0 +1,15 @@
#pragma once
#include <Arduino.h>
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;
};

View File

@@ -0,0 +1,59 @@
#include "Radio.h"
#include "Config.h"
#include <WiFi.h>
#include <esp_wifi.h>
#include <string.h>
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<const uint8_t *>(&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]);
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <Arduino.h>
#include <esp_now.h>
#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;
};

View File

@@ -0,0 +1,155 @@
#include "Receiver.h"
#include "Config.h"
#include <string.h>
#if !OPTICAL_USE_RMT_DMA
#include <esp_cpu.h>
#include <esp32-hal-cpu.h>
#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_num_t>(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<uint32_t>(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<uint32_t>(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<PulseReceiver *>(ctx);
BaseType_t wake = pdFALSE;
size_t offset = 0;
while (offset < data->num_symbols) {
SymbolBlock b = {};
b.count = static_cast<uint16_t>((data->num_symbols - offset) > BLOCK_SYMBOLS ?
BLOCK_SYMBOLS : (data->num_symbols - offset));
memcpy(b.symbols, data->received_symbols + offset, b.count * sizeof(rmt_symbol_word_t));
if (xQueueSendFromISR(self->queue_, &b, &wake) != pdTRUE) 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<uint8_t>(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<PulseReceiver *>(ctx);
bool level = gpio_get_level(static_cast<gpio_num_t>(GPIO_RX));
if (RX_SIGNAL_INVERTED) level = !level;
Edge e = {esp_cpu_get_cycle_count(), static_cast<uint8_t>(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

View File

@@ -0,0 +1,57 @@
#pragma once
#include <Arduino.h>
#include <esp_idf_version.h>
#include "Core.h"
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
#define OPTICAL_USE_RMT_DMA 1
#include <driver/rmt_rx.h>
#else
#define OPTICAL_USE_RMT_DMA 0
#include <driver/gpio.h>
#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;
};

View File

@@ -0,0 +1,45 @@
#include "SettingsStore.h"
#include "Config.h"
#include <Preferences.h>
namespace { constexpr uint16_t SETTINGS_VERSION = 1; constexpr char NAMESPACE[] = "opt-test"; }
void SettingsStore::defaults(Settings &s) const {
s = {SETTINGS_VERSION, static_cast<uint8_t>(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<uint8_t>(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]};
}

View File

@@ -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;
};

182
README.md Normal file

File diff suppressed because one or more lines are too long