diff --git a/.gitignore b/.gitignore index d93038f..398ef4b 100644 --- a/.gitignore +++ b/.gitignore @@ -233,6 +233,7 @@ test_end_2/ # --- device_firmware: PlatformIO / STM32G431 cart remote --- # build output (regenerated by `pio run`) device_firmware/cart_firmware/.pio/ +device_firmware/switch_controller/.pio/ # scope captures: raw .bin + .npz + preview .png, local-only device_firmware/cart_firmware/captures/ # machine-specific, regenerated by the PlatformIO extension diff --git a/device_firmware/per_point_switching.md b/device_firmware/per_point_switching.md index 6aad6e1..a80e496 100644 --- a/device_firmware/per_point_switching.md +++ b/device_firmware/per_point_switching.md @@ -3,7 +3,7 @@ Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера измеряется на **каждой** частоте дважды — с двумя положениями выходного ключа. -``` +```text 4 выхода = 2 порта мастера × 2 положения ключа 4 входа = 2 слейва × 2 порта (входной ключ не участвует) ``` @@ -29,7 +29,7 @@ стадии**, а период 2 нацело делит 4 стадии. Значит через границу точки чередование продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная -чётность, и та только один раз на старте развёртки. +чётность, а её контроллер восстанавливает на каждой точке по длине паузы. ## Контракт по проводу @@ -137,32 +137,36 @@ FPGA::SetupSweep(s.stages, s.port1Stage, s.port2Stage, s.port3Stage, ## Контроллер ключа (отдельный STM32) -Врезается в триггерную петлю между `TRIGGER_OUT` мастера и `TRIGGER_IN` слейва. -Электрически это чисто: в режиме `Mode::Trigger` прошивка LibreVNA уводит STM32 -прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `NOPULL`, EXTI снят), так что -обе линии свободны. +Реализован: [`switch_controller/`](switch_controller/) — WeAct STM32G431CB, +PlatformIO, тот же тулчейн, что у пульта тележки. Подробности подключения и +диагностики в [его README](switch_controller/README.md), здесь только суть. -Три требования: +Врезается в триггерную петлю между `TRIGGER_OUT` мастера и `TRIGGER_IN` первого +слейва. Электрически чисто: в режиме `Mode::Trigger` прошивка LibreVNA уводит +STM32 прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `NOPULL`, EXTI снят). -1. **Прозрачный повторитель обоих фронтов.** Задержка суб-микросекундная - (bare-metal EXTI), иначе она добавляется к каждой из 4 стадий каждой точки. -2. **Ключ переключается по СПАДУ**, а не по фронту. Спад означает «оцифровка - стадии закончилась»; дальше идёт `SamplingDone` → `Settling` следующей стадии, - то есть окно успокоения. При `dwell_us = 2` и штатной задержке PLL это окно - ~62 мкс — запас огромный. Переключение по фронту заставило бы удерживать - кольцо на время установления ключа. -3. **Одна нога на два ключа.** Ключи на порту 1 и на порту 2 ходят синхронно; - нужное положение — просто инверсия на каждом цикле триггера. +Логика сводится к трём правилам: -Чётность привязывается к началу развёртки. Между развёртками FPGA держится в -сбросе (`StartSweep` = снять и поднять AUX3), а хост успевает получить пакет -статуса, поэтому пауза на линии триггера на порядок длиннее межстадийной -(~100 мкс против ≥1 мс — померить осциллографом и взять порог посередине). Если -есть физический доступ к AUX3 мастера (нога FPGA P74) — лучше завести её на -контроллер как точный маркер вместо порога по паузе. +1. **Зеркалирование обоих фронтов.** Задержка не влияет на измерения: мастер + стартует оцифровку по своему `TRIGGER_IN`, который приходит с конца кольца, + то есть тоже после контроллера. Она общая для всех трёх приборов и в + отношении «приёмник/опорник» сокращается — только чуть удлиняет развёртку. +2. **Инверсия ноги по СПАДУ триггера.** Положение ключа — младший бит номера + стадии, а период 2 нацело делит 4 стадии, поэтому чередование не рвётся и на + границе точки. Спад означает «оцифровка закончилась», дальше идёт окно + успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу + хватает с огромным запасом. +3. **Одна нога на оба ключа.** Нужное положение зависит только от номера стадии, + а не от того, какой порт излучает, поэтому ключи на порту 1 и порту 2 ходят + синхронно. -Сбой чётности меняет местами антенны 0↔2 и 1↔3 **молча**, поэтому стоит вывести -текущее положение ключа на отладочную ногу для сверки осциллографом. +Страховка от сбоя чётности: один потерянный фронт инвертирует чередование +навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по +длине паузы (между точками к времени успокоения добавляется перезагрузка +регистров PLL, между развёртками — ещё и работа хоста) и принудительно ставит +положение A. Так чётность чинится на **каждой** точке. Порог берётся как минимум +паузы по скользящему окну с запасом, то есть подстраивается сам под любые +настройки развёртки; отдельный провод для маркера начала развёртки не нужен. ## Хост diff --git a/device_firmware/switch_controller/README.md b/device_firmware/switch_controller/README.md new file mode 100644 index 0000000..cf0b550 --- /dev/null +++ b/device_firmware/switch_controller/README.md @@ -0,0 +1,113 @@ +# Контроллер выходного ключа + +Прошивка STM32, которая переключает выходной СВЧ-ключ внутри частотной точки — +вторая половина режима, описанного в [`../per_point_switching.md`](../per_point_switching.md). +Плата и тулчейн те же, что у пульта тележки: WeAct STM32G431CB, PlatformIO. + +## Подключение + +```text +мастер TRIGGER_OUT ──► PB0 [STM32] PB1 ──► слейв 1 TRIGGER_IN + PB2 ──► управление обоими СВЧ-ключами + PB5 ──► отладочный выход +``` + +Контроллер встаёт **в разрыв** линии между мастером и первым слейвом, остальная +часть кольца (слейв 1 → слейв 2 → мастер) не трогается. Это безопасно: в режиме +`Mode::Trigger` прошивка LibreVNA уводит собственный STM32 прибора в Hi-Z на обеих +триггерных линиях, так что и вход слейва, и выход мастера свободны. + +Одна нога PB2 управляет **обоими** ключами (на порту 1 и на порту 2) — они ходят +синхронно, и этого достаточно: нужное положение зависит только от номера стадии, +а не от того, какой порт сейчас излучает. Если у ключа больше двух состояний, +лишние входы замыкаются джампером. + +Если положению A соответствует лог. 1, а не 0 — поменяйте `kSwitchActiveLow` +в [`src/main.cpp`](src/main.cpp). + +## Как это работает + +Прибор измеряет каждую частоту в 4 стадии: порт 1 в положениях A и B ключа, +затем порт 2 в тех же положениях. Положение ключа — это младший бит номера +стадии, а период 2 нацело делит 4 стадии, поэтому нужная последовательность — +непрерывное чередование `A B A B A B …`, не прерывающееся на границе точки. +Контроллеру достаточно **инвертировать ногу на каждый цикл триггера**; +абсолютный счётчик стадий не нужен. + +Переключение делается по **спаду** триггера. Спад означает «оцифровка стадии +закончилась»; сразу за ним прибор уходит в состояние успокоения следующей +стадии, и это окно (`dwell_us` плюс собственная задержка PLL прибора, ~62 мкс +при `dwell_us = 2`) целиком покрывает установление ключа. Переключение по фронту +заставило бы удерживать кольцо на время установления и удлинило бы каждую стадию. + +### Восстановление чётности + +Чередование самодостаточно, но один пропущенный или лишний фронт инвертирует +чётность навсегда и **молча** меняет антенны местами. Поэтому контроллер +дополнительно распознаёт границу точки по длине паузы: + +| Пауза | Длительность | Из чего состоит | +|---|---|---| +| между стадиями | базовая | только время успокоения | +| между точками | базовая + | перезагрузка регистров PLL мастера | +| между развёртками | базовая + + | ещё и работа хоста между свипами | + +Пауза длиннее межстадийной означает, что следующий триггер начнёт стадию 0, то +есть положение A. Контроллер выставляет A принудительно, независимо от +накопленной чётности — то есть чинит рассинхрон на **каждой точке**, а не один +раз на развёртку. + +Порог подстраивается сам: межстадийная пауза — это минимальная регулярно +повторяющаяся пауза, поэтому за порог берётся минимум по скользящему окну, +умноженный на `kBoundaryFactorNum / kBoundaryFactorDen` (по умолчанию 1.5). Пока +минимум не набран, восстановление молчит — чередование корректно и без него. + +Множитель намеренно щедрый. Ложное срабатывание **посреди** точки испортит +данные, а несрабатывание всего лишь отключит страховку. Если по отчёту видно, +что границы не распознаются вообще (`point boundaries` близко к нулю при +ненулевом `trigger cycles`), множитель можно снижать — но не ниже реального +отношения межточечной паузы к межстадийной, которое видно там же в отчёте. + +### Задержка + +Зеркалирование занимает ~50 нс и на измерения не влияет: мастер стартует +оцифровку по своему `TRIGGER_IN`, который приходит с конца кольца, то есть тоже +после контроллера. Задержка общая для всех трёх приборов, взаимные сдвиги +мастер/слейвы задаются только межFPGA-передачей, и в отношении +«приёмник/опорник» она сокращается. Единственный эффект — развёртка длиннее на +~2 × задержку × число стадий. + +## Диагностика + +**PB5** — импульс 2 мкс каждый раз, когда чётность пришлось **чинить**. В +исправной системе эта нога молчит: предсказание чередованием уже даёт нужное +положение на границе точки. Постоянные импульсы означают, что фронты теряются +или порог распознавания границы занижен. + +**PB2** — само положение ключа, смотрится осциллографом вместе с триггером: на +одной частоте должно быть ровно 4 стадии и два полных периода `A B A B`. + +**USB CDC (115200)** — отчёт печатается один раз, когда на линии триггера +тишина дольше 200 мс, то есть когда развёртка остановлена. Во время работы +печати нет: `Serial` блокирует зеркалирование, а пропущенные фронты сбили бы +чётность. Ключевые строки: + +```text +parity corrections : 0 (ok) <- в норме всегда 0 +stages per point : 4 (ожидается 4), отклонений: 0 +inter-stage gap, us : 62 +boundary above, us : 93 +``` + +`stages per point` — самопроверка распознавания границ: если оно верное, между +двумя границами всегда ровно 4 стадии. Любое другое число означает, что порог +подобран неправильно. + +## Сборка и прошивка + +```bash +cd device_firmware/switch_controller +pio run # сборка +pio run -t upload # прошить через ST-Link +pio device monitor # отчёт по USB CDC +``` diff --git a/device_firmware/switch_controller/platformio.ini b/device_firmware/switch_controller/platformio.ini new file mode 100644 index 0000000..4783761 --- /dev/null +++ b/device_firmware/switch_controller/platformio.ini @@ -0,0 +1,14 @@ +; Контроллер выходного ключа для multi-device LibreVNA. +; Плата и тулчейн те же, что у пульта тележки (WeAct STM32G431CB). +; Телеметрия — USB CDC (Serial), как в cart_firmware. +[env:weact_g431cb] +platform = ststm32 +board = genericSTM32G431CB +framework = arduino +upload_protocol = stlink +debug_tool = stlink +monitor_speed = 115200 +build_flags = + -O2 + -DUSBCON + -DUSBD_USE_CDC diff --git a/device_firmware/switch_controller/src/main.cpp b/device_firmware/switch_controller/src/main.cpp new file mode 100644 index 0000000..1f442f6 --- /dev/null +++ b/device_firmware/switch_controller/src/main.cpp @@ -0,0 +1,405 @@ +// Контроллер выходного СВЧ-ключа для multi-device LibreVNA. +// +// Стоит в разрыве триггерной петли между мастером и первым слейвом: +// +// мастер TRIGGER_OUT --> PB0 [STM32] PB1 --> слейв1 TRIGGER_IN +// PB2 --> оба СВЧ-ключа +// +// Задача. В режиме переключения внутри точки (см. ../per_point_switching.md) +// прибор измеряет каждую частоту в 4 стадии: порт 1 в положениях A и B ключа, +// затем порт 2 в тех же двух положениях. Положение ключа — это младший бит +// номера стадии, а период 2 нацело делит 4 стадии, поэтому нужная +// последовательность это просто непрерывное чередование A,B,A,B,... и +// контроллеру достаточно инвертировать ногу на каждый цикл триггера. Абсолютный +// счётчик стадий не нужен. +// +// Переключение делается по СПАДУ триггера: спад означает «оцифровка стадии +// закончилась», после него прибор уходит в состояние Settling следующей стадии. +// Это окно (dwell_time + собственная задержка PLL прибора, ~62 мкс при +// dwell_us = 2) целиком покрывает установление ключа. Переключение по фронту +// заставило бы удерживать кольцо на время установления. +// +// Восстановление чётности. Само по себе чередование самодостаточно, но +// единственный пропущенный или лишний фронт инвертирует чётность НАВСЕГДА и +// молча меняет антенны местами. Поэтому контроллер дополнительно распознаёт +// границу точки по длине паузы: между стадиями пауза равна времени успокоения, +// а между точками к ней добавляется перезагрузка регистров PLL, между +// развёртками — ещё и работа хоста. Длинная пауза означает, что следующий +// триггер начнёт стадию 0, то есть положение A. Так система чинит чётность на +// КАЖДОЙ точке, а не один раз на развёртку. +// +// Порог для «длинной паузы» подстраивается сам: межстадийная пауза — это +// минимальная регулярно повторяющаяся пауза, поэтому за порог берётся минимум +// по скользящему окну, умноженный на запас. Пока минимум не набран, +// восстановление молчит — чередование и без него корректно. +// +// Задержка зеркалирования (~50 нс) общая для всех приборов: мастер начинает +// оцифровку по своему TRIGGER_IN, который приходит с конца кольца, то есть тоже +// после этого контроллера. На взаимные сдвиги мастер/слейвы она не влияет и в +// отношении «приёмник/опорник» сокращается — она лишь чуть удлиняет развёртку. +#include + +namespace { + +// ---- распиновка ---- +// Все три триггерные линии на GPIOB: зеркалирование это одно чтение IDR и одна +// запись BSRR, без обращения к разным периферийным блокам. +constexpr uint32_t kPinTriggerIn = GPIO_PIN_0; // PB0 <- TRIGGER_OUT мастера +constexpr uint32_t kPinTriggerOut = GPIO_PIN_1; // PB1 -> TRIGGER_IN слейва 1 +constexpr uint32_t kPinSwitch = GPIO_PIN_2; // PB2 -> управление обоими ключами +constexpr uint32_t kPinResync = GPIO_PIN_5; // PB5 -> отладочный выход (см. ниже) +GPIO_TypeDef *const kTriggerPort = GPIOB; + +// Инверсия управляющего уровня, если положению A у ключа соответствует лог. 1. +constexpr bool kSwitchActiveLow = false; + +// ---- настройка распознавания границы точки ---- +// Порог = минимальная пауза * kBoundaryFactorNum / kBoundaryFactorDen. Запас +// намеренно щедрый: ложное срабатывание ПОСРЕДИ точки испортит данные, а +// несрабатывание всего лишь отключит восстановление, оставив корректное +// чередование. Если по осциллографу видно, что восстановление не срабатывает +// ни разу, множитель можно снижать — но не ниже реального отношения +// межточечной паузы к межстадийной. +constexpr uint32_t kBoundaryFactorNum = 3; +constexpr uint32_t kBoundaryFactorDen = 2; + +// Окно, по которому берётся минимальная пауза. Двух окон хватает, чтобы +// оценка пережила смену настроек развёртки за пару секунд. +constexpr uint32_t kGapWindowSamples = 4096; + +// Защита от шума: пауза короче этого не считается настоящей стадией. +constexpr uint32_t kMinPlausibleGapMicroseconds = 2; + +// Длительность импульса на отладочной ноге. +constexpr uint32_t kResyncPulseMicroseconds = 2; + +// Через столько тишины на линии триггера считаем, что развёртка остановлена, и +// печатаем отчёт. Порог заведомо больше межразвёрточной паузы, чтобы печать +// никогда не наложилась на работающий свип: печать блокирует зеркалирование. +constexpr uint32_t kIdleReportMilliseconds = 200; + +uint32_t g_cpuCyclesPerMicrosecond = 1; + +// ---- счётчик тактов ядра как источник времени ---- +// DWT считает такты ядра свободно и без прерываний, поэтому годится и внутри +// горячего цикла. 32 бита при 170 МГц переполняются за ~25 с; все интервалы +// считаются вычитанием без знака, поэтому переполнение безопасно. +void startCycleCounter() { + CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; + DWT->CYCCNT = 0; + DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; + g_cpuCyclesPerMicrosecond = SystemCoreClock / 1000000UL; + if (g_cpuCyclesPerMicrosecond == 0) { + g_cpuCyclesPerMicrosecond = 1; + } +} + +inline uint32_t cycleCount() { + return DWT->CYCCNT; +} + +inline uint32_t microsecondsToCycles(uint32_t microseconds) { + return microseconds * g_cpuCyclesPerMicrosecond; +} + +inline uint32_t cyclesToMicroseconds(uint32_t cycles) { + return cycles / g_cpuCyclesPerMicrosecond; +} + +// Минимум по скользящему окну за O(1) памяти и времени: держим минимум текущего +// окна и минимум предыдущего, на границе окна сдвигаем. Эффективная длина окна +// плавает между одним и двумя kGapWindowSamples, чего для оценки порога хватает. +class SlidingWindowMinimum { + public: + void add(uint32_t value) { + if (value < current_) { + current_ = value; + } + if (++sampleCount_ >= kGapWindowSamples) { + previous_ = current_; + current_ = UINT32_MAX; + sampleCount_ = 0; + hasPrevious_ = true; + } + } + + bool isValid() const { + return hasPrevious_; + } + + // Минимум по обоим окнам. Достоверен только при isValid(). + uint32_t value() const { + return current_ < previous_ ? current_ : previous_; + } + + private: + uint32_t current_ = UINT32_MAX; + uint32_t previous_ = UINT32_MAX; + uint32_t sampleCount_ = 0; + bool hasPrevious_ = false; +}; + +// Диагностика, накапливаемая между отчётами. Читается только в паузе, поэтому +// синхронизация не нужна: всё однопоточное. +struct Statistics { + uint32_t triggerCycles = 0; // сколько стадий отработано + uint32_t boundaries = 0; // сколько границ точки распознано + uint32_t corrections = 0; // из них потребовавших ЧИНИТЬ положение ключа + uint32_t shortestGapMicros = UINT32_MAX; + uint32_t longestGapMicros = 0; + uint32_t lastStagesPerPoint = 0; // стадий между двумя границами; должно быть 4 + uint32_t unexpectedStagesPerPoint = 0; + + void reset() { + *this = Statistics{}; + } +}; + +// Ожидаемое число стадий между границами точки при переключении на 2 положения: +// 2 порта мастера x 2 положения ключа. +constexpr uint32_t kExpectedStagesPerPoint = 4; + +class SwitchController { + public: + void begin() { + configurePins(); + applySwitchPosition(0); + previousInputIsHigh_ = readInput(); + mirror(previousInputIsHigh_); + lastEdgeCycles_ = cycleCount(); + lastFallingEdgeCycles_ = lastEdgeCycles_; + } + + // Один проход горячего цикла. Держится коротким и без ветвлений на + // критическом пути: зеркалирование выполняется безусловно, до всей логики. + inline void poll() { + const bool inputIsHigh = readInput(); + mirror(inputIsHigh); + + if (inputIsHigh != previousInputIsHigh_) { + previousInputIsHigh_ = inputIsHigh; + if (inputIsHigh) { + onRisingEdge(); + } else { + onFallingEdge(); + } + return; + } + + endResyncPulseIfElapsed(); + detectPointBoundaryIfElapsed(); + } + + bool isIdleFor(uint32_t milliseconds) const { + return (cycleCount() - lastEdgeCycles_) > microsecondsToCycles(milliseconds * 1000UL); + } + + Statistics &statistics() { + return statistics_; + } + + uint32_t boundaryThresholdMicroseconds() const { + return gapMinimum_.isValid() ? cyclesToMicroseconds(boundaryThresholdCycles()) : 0; + } + + uint32_t typicalGapMicroseconds() const { + return gapMinimum_.isValid() ? cyclesToMicroseconds(gapMinimum_.value()) : 0; + } + + private: + static void configurePins() { + __HAL_RCC_GPIOB_CLK_ENABLE(); + + // Вход с подтяжкой вниз: мастер гонит линию двухтактным выходом, поэтому + // подтяжка ему не мешает, но удерживает вход в покое, если провод отвалился. + GPIO_InitTypeDef input = {}; + input.Pin = kPinTriggerIn; + input.Mode = GPIO_MODE_INPUT; + input.Pull = GPIO_PULLDOWN; + HAL_GPIO_Init(kTriggerPort, &input); + + GPIO_InitTypeDef outputs = {}; + outputs.Pin = kPinTriggerOut | kPinSwitch | kPinResync; + outputs.Mode = GPIO_MODE_OUTPUT_PP; + outputs.Pull = GPIO_NOPULL; + outputs.Speed = GPIO_SPEED_FREQ_VERY_HIGH; + HAL_GPIO_Init(kTriggerPort, &outputs); + + kTriggerPort->BSRR = (kPinTriggerOut | kPinSwitch | kPinResync) << 16U; + } + + static inline bool readInput() { + return (kTriggerPort->IDR & kPinTriggerIn) != 0U; + } + + static inline void mirror(bool inputIsHigh) { + kTriggerPort->BSRR = inputIsHigh ? kPinTriggerOut : (kPinTriggerOut << 16U); + } + + static inline void applySwitchLevel(bool isHigh) { + kTriggerPort->BSRR = isHigh ? kPinSwitch : (kPinSwitch << 16U); + } + + inline void applySwitchPosition(uint32_t position) { + switchPosition_ = position & 1U; + applySwitchLevel((switchPosition_ != 0U) != kSwitchActiveLow); + } + + inline void onRisingEdge() { + const uint32_t now = cycleCount(); + const uint32_t gapCycles = now - lastFallingEdgeCycles_; + lastEdgeCycles_ = now; + boundaryPending_ = false; + + // Первый фронт после сброса не с чем сравнивать: паузы до него не было. + if (hasCompletedFirstCycle_ && gapCycles >= minPlausibleGapCycles()) { + gapMinimum_.add(gapCycles); + const uint32_t gapMicros = cyclesToMicroseconds(gapCycles); + if (gapMicros < statistics_.shortestGapMicros) { + statistics_.shortestGapMicros = gapMicros; + } + if (gapMicros > statistics_.longestGapMicros) { + statistics_.longestGapMicros = gapMicros; + } + } + ++statistics_.triggerCycles; + ++stagesSinceBoundary_; + } + + inline void onFallingEdge() { + const uint32_t now = cycleCount(); + lastEdgeCycles_ = now; + lastFallingEdgeCycles_ = now; + hasCompletedFirstCycle_ = true; + + // Установившийся режим: следующая стадия всегда в другом положении. + // Через границу точки это тоже верно, поэтому предсказание безусловное, + // а распознавание границы ниже нужно лишь как страховка от сбоя чётности. + applySwitchPosition(switchPosition_ + 1U); + boundaryPending_ = true; + } + + // Пауза затянулась сверх межстадийной: следующий триггер начнёт новую точку, + // то есть стадию 0. Ставим положение A независимо от накопленной чётности. + inline void detectPointBoundaryIfElapsed() { + if (!boundaryPending_ || !gapMinimum_.isValid()) { + return; + } + if ((cycleCount() - lastFallingEdgeCycles_) <= boundaryThresholdCycles()) { + return; + } + boundaryPending_ = false; + ++statistics_.boundaries; + + // Первая распознанная граница закрывает точку, начало которой мы не + // видели, поэтому её неполный счёт не считается отклонением. + if (hasSeenBoundary_) { + statistics_.lastStagesPerPoint = stagesSinceBoundary_; + if (stagesSinceBoundary_ != kExpectedStagesPerPoint) { + ++statistics_.unexpectedStagesPerPoint; + } + } + hasSeenBoundary_ = true; + stagesSinceBoundary_ = 0; + + if (switchPosition_ != 0U) { + // Чередование разошлось с реальностью — чиним. В исправной системе + // этого не происходит, поэтому счётчик прямо показывает здоровье. + applySwitchPosition(0); + ++statistics_.corrections; + beginResyncPulse(); + } + } + + inline void beginResyncPulse() { + kTriggerPort->BSRR = kPinResync; + resyncPulseStartCycles_ = cycleCount(); + resyncPulseActive_ = true; + } + + inline void endResyncPulseIfElapsed() { + if (!resyncPulseActive_) { + return; + } + if ((cycleCount() - resyncPulseStartCycles_) < microsecondsToCycles(kResyncPulseMicroseconds)) { + return; + } + kTriggerPort->BSRR = kPinResync << 16U; + resyncPulseActive_ = false; + } + + uint32_t boundaryThresholdCycles() const { + return (gapMinimum_.value() / kBoundaryFactorDen) * kBoundaryFactorNum; + } + + static uint32_t minPlausibleGapCycles() { + return microsecondsToCycles(kMinPlausibleGapMicroseconds); + } + + SlidingWindowMinimum gapMinimum_; + Statistics statistics_; + uint32_t switchPosition_ = 0; + uint32_t stagesSinceBoundary_ = 0; + uint32_t lastEdgeCycles_ = 0; + uint32_t lastFallingEdgeCycles_ = 0; + uint32_t resyncPulseStartCycles_ = 0; + bool previousInputIsHigh_ = false; + bool boundaryPending_ = false; + bool resyncPulseActive_ = false; + bool hasCompletedFirstCycle_ = false; + bool hasSeenBoundary_ = false; +}; + +SwitchController g_controller; +bool g_reportPrinted = false; + +void printReport() { + Statistics &statistics = g_controller.statistics(); + Serial.println(F("--- switch controller ---")); + Serial.print(F("trigger cycles : ")); + Serial.println(statistics.triggerCycles); + Serial.print(F("point boundaries : ")); + Serial.println(statistics.boundaries); + Serial.print(F("parity corrections : ")); + Serial.print(statistics.corrections); + Serial.println(statistics.corrections == 0 ? F(" (ok)") : F(" <-- ПРОВЕРИТЬ")); + Serial.print(F("stages per point : ")); + Serial.print(statistics.lastStagesPerPoint); + Serial.print(F(" (ожидается ")); + Serial.print(kExpectedStagesPerPoint); + Serial.print(F("), отклонений: ")); + Serial.println(statistics.unexpectedStagesPerPoint); + Serial.print(F("gap min/max, us : ")); + Serial.print(statistics.shortestGapMicros == UINT32_MAX ? 0 : statistics.shortestGapMicros); + Serial.print(F(" / ")); + Serial.println(statistics.longestGapMicros); + Serial.print(F("inter-stage gap, us : ")); + Serial.println(g_controller.typicalGapMicroseconds()); + Serial.print(F("boundary above, us : ")); + Serial.println(g_controller.boundaryThresholdMicroseconds()); + statistics.reset(); +} + +} // namespace + +void setup() { + Serial.begin(115200); + startCycleCounter(); + g_controller.begin(); +} + +void loop() { + g_controller.poll(); + + // Отчёт печатается только когда развёртка заведомо остановлена: Serial + // блокирует зеркалирование, а пропущенные фронты сбили бы чётность (её, + // впрочем, восстановит ближайшая граница точки). + if (g_controller.isIdleFor(kIdleReportMilliseconds)) { + if (!g_reportPrinted) { + printReport(); + g_reportPrinted = true; + } + } else { + g_reportPrinted = false; + } +}