switch controller firmware + per-point switching spec
Прошивка STM32 (WeAct G431CB, тот же тулчейн, что у пульта тележки), которая стоит в разрыве триггерной петли между мастером и первым слейвом и переключает выходной ключ внутри частотной точки. Положение ключа — младший бит номера стадии, а период 2 нацело делит 4 стадии, поэтому чередование не рвётся на границе точки и контроллеру достаточно инвертировать ногу на каждый цикл триггера. Переключение по СПАДУ: спад означает «оцифровка закончилась», дальше идёт окно успокоения следующей стадии (~62 мкс при dwell_us=2), которого ключу хватает с запасом, и кольцо удерживать не надо. Один потерянный фронт инвертировал бы чётность навсегда и молча поменял антенны местами, поэтому контроллер распознаёт границу точки по длине паузы (между точками к успокоению добавляется перезагрузка регистров PLL) и принудительно ставит положение A — чётность чинится на каждой точке, а не раз на развёртку. Порог берётся как минимум паузы по скользящему окну с запасом, то есть подстраивается сам; отдельный провод-маркер начала развёртки не нужен. Задержку зеркалирования проверил по Sweep.vhd: мастер стартует оцифровку по своему TRIGGER_IN с конца кольца, то есть тоже после контроллера. Задержка общая для всех приборов и сокращается в отношении «приёмник/опорник» — фазовой ошибки не вносит, только чуть удлиняет развёртку. Собирается: RAM 13.9%, Flash 20.0%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -233,6 +233,7 @@ test_end_2/
|
|||||||
# --- device_firmware: PlatformIO / STM32G431 cart remote ---
|
# --- device_firmware: PlatformIO / STM32G431 cart remote ---
|
||||||
# build output (regenerated by `pio run`)
|
# build output (regenerated by `pio run`)
|
||||||
device_firmware/cart_firmware/.pio/
|
device_firmware/cart_firmware/.pio/
|
||||||
|
device_firmware/switch_controller/.pio/
|
||||||
# scope captures: raw .bin + .npz + preview .png, local-only
|
# scope captures: raw .bin + .npz + preview .png, local-only
|
||||||
device_firmware/cart_firmware/captures/
|
device_firmware/cart_firmware/captures/
|
||||||
# machine-specific, regenerated by the PlatformIO extension
|
# machine-specific, regenerated by the PlatformIO extension
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера
|
Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера
|
||||||
измеряется на **каждой** частоте дважды — с двумя положениями выходного ключа.
|
измеряется на **каждой** частоте дважды — с двумя положениями выходного ключа.
|
||||||
|
|
||||||
```
|
```text
|
||||||
4 выхода = 2 порта мастера × 2 положения ключа
|
4 выхода = 2 порта мастера × 2 положения ключа
|
||||||
4 входа = 2 слейва × 2 порта (входной ключ не участвует)
|
4 входа = 2 слейва × 2 порта (входной ключ не участвует)
|
||||||
```
|
```
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
стадии**, а период 2 нацело делит 4 стадии. Значит через границу точки чередование
|
стадии**, а период 2 нацело делит 4 стадии. Значит через границу точки чередование
|
||||||
продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый
|
продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый
|
||||||
цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная
|
цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная
|
||||||
чётность, и та только один раз на старте развёртки.
|
чётность, а её контроллер восстанавливает на каждой точке по длине паузы.
|
||||||
|
|
||||||
## Контракт по проводу
|
## Контракт по проводу
|
||||||
|
|
||||||
@@ -137,32 +137,36 @@ FPGA::SetupSweep(s.stages, s.port1Stage, s.port2Stage, s.port3Stage,
|
|||||||
|
|
||||||
## Контроллер ключа (отдельный STM32)
|
## Контроллер ключа (отдельный STM32)
|
||||||
|
|
||||||
Врезается в триггерную петлю между `TRIGGER_OUT` мастера и `TRIGGER_IN` слейва.
|
Реализован: [`switch_controller/`](switch_controller/) — WeAct STM32G431CB,
|
||||||
Электрически это чисто: в режиме `Mode::Trigger` прошивка LibreVNA уводит STM32
|
PlatformIO, тот же тулчейн, что у пульта тележки. Подробности подключения и
|
||||||
прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `NOPULL`, EXTI снят), так что
|
диагностики в [его 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 держится в
|
1. **Зеркалирование обоих фронтов.** Задержка не влияет на измерения: мастер
|
||||||
сбросе (`StartSweep` = снять и поднять AUX3), а хост успевает получить пакет
|
стартует оцифровку по своему `TRIGGER_IN`, который приходит с конца кольца,
|
||||||
статуса, поэтому пауза на линии триггера на порядок длиннее межстадийной
|
то есть тоже после контроллера. Она общая для всех трёх приборов и в
|
||||||
(~100 мкс против ≥1 мс — померить осциллографом и взять порог посередине). Если
|
отношении «приёмник/опорник» сокращается — только чуть удлиняет развёртку.
|
||||||
есть физический доступ к AUX3 мастера (нога FPGA P74) — лучше завести её на
|
2. **Инверсия ноги по СПАДУ триггера.** Положение ключа — младший бит номера
|
||||||
контроллер как точный маркер вместо порога по паузе.
|
стадии, а период 2 нацело делит 4 стадии, поэтому чередование не рвётся и на
|
||||||
|
границе точки. Спад означает «оцифровка закончилась», дальше идёт окно
|
||||||
|
успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу
|
||||||
|
хватает с огромным запасом.
|
||||||
|
3. **Одна нога на оба ключа.** Нужное положение зависит только от номера стадии,
|
||||||
|
а не от того, какой порт излучает, поэтому ключи на порту 1 и порту 2 ходят
|
||||||
|
синхронно.
|
||||||
|
|
||||||
Сбой чётности меняет местами антенны 0↔2 и 1↔3 **молча**, поэтому стоит вывести
|
Страховка от сбоя чётности: один потерянный фронт инвертирует чередование
|
||||||
текущее положение ключа на отладочную ногу для сверки осциллографом.
|
навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по
|
||||||
|
длине паузы (между точками к времени успокоения добавляется перезагрузка
|
||||||
|
регистров PLL, между развёртками — ещё и работа хоста) и принудительно ставит
|
||||||
|
положение A. Так чётность чинится на **каждой** точке. Порог берётся как минимум
|
||||||
|
паузы по скользящему окну с запасом, то есть подстраивается сам под любые
|
||||||
|
настройки развёртки; отдельный провод для маркера начала развёртки не нужен.
|
||||||
|
|
||||||
## Хост
|
## Хост
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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 <Arduino.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user