diff --git a/.dockerignore b/.dockerignore index 4921663..d425a04 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,13 +7,13 @@ pi/dist/ stm32/.deps/ stm32/app/build/ stm32/tests/test_build_info -stm32/tests/test_can_control stm32/tests/test_can_protocol -stm32/tests/test_can_queue +stm32/tests/test_can_rx_queue stm32/tests/test_can_tx_queue stm32/tests/test_diagnostics -stm32/tests/test_protocol stm32/tests/test_protocol_vectors +stm32/tests/test_rf_commands +stm32/tests/test_rf_plan **/__pycache__/ **/.pytest_cache/ *.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..43319db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Repository instructions + +## Protocol and API changes + +- Implement one current protocol and one current API. +- Do not add backward-compatibility paths, legacy fallbacks, dual-format decoding, deprecated aliases, migration shims, or compatibility wrappers unless the user explicitly requests them. +- Do not preserve old wire layouts, payload padding rules, status formats, numeric values, or function signatures solely for compatibility. +- Breaking changes are allowed on active development branches. Update firmware, clients, tools, tests, simulation, and documentation together. +- Prefer exact payload lengths and one canonical code path. +- Remove superseded code instead of retaining it behind feature checks or version branches. + +## Reference code + +- Use only repositories, branches, or implementations named by the user. +- Do not inspect unrelated rewrites as design references unless the user explicitly asks. + +## Firmware layout + +- Keep production firmware under `stm32/app/src/{can,rf,platform}` with matching headers under `stm32/app/include/`. +- Keep board GPIO and alternate-function assignments in `platform/board.h`. +- Keep bench experiments and captured media under `stm32/bringup/`, not production source directories. +- Name modules by responsibility: CAN transport/codec, RF planning/execution, or platform support. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c9b13e..6b62d89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,58 +1,52 @@ # Contributing -Thanks for helping with the uORocketry BeamControl beamforming software. This is a small -team project, so keep changes minimal and reviewable. +Keep changes small, tested, and reviewable. -## Repo layout +## Layout -- `pi/` — Raspberry Pi CAN controller (Python, `src/beamcontrol` package, console entry points `beamctl` and `beamd`). -- `stm32/` — STM32 embedded firmware (C, libopencm3). CAN protocol v1.1 + prioritized TX queue. -- `docs/` — project knowledge base (protocol spec, hardware, operations). -- `protocol/` — shared, language-neutral protocol test vectors. -- `tools/` — repo orchestration (toolchain/dependency fetch, generators, bundle builder). +- `pi/`: Python 3.11 controller, `beamctl`, `beamd` +- `stm32/`: STM32F072 firmware in C/libopencm3 +- `protocol/`: shared Python/C vectors +- `simulation/`: Docker, SocketCAN, Renode E2E +- `tools/`: setup, generators, bundles +- `docs/`: protocol, RF, operations -## The one command interface +## Commands -Everything runs through the root `Makefile`. Do not scatter setup instructions. +Use the root `Makefile`: ```bash -make setup # fetch pinned uv + ARM toolchain + libopencm3, sync Python env -make doctor # diagnose required and optional environment capabilities -make test # pi tests + native firmware unit tests + protocol contract -make check # lint + tests + one representative firmware build +make setup +make doctor +make test +make check make firmware NODE=1 ``` -## Before you open a PR +Before a PR: -- `make check` must pass clean (lint, all tests, and a representative firmware build). -- New/edited Python must be covered by a test in `pi/tests/` (unit with a fake - transport, or integration on the virtual CAN bus). -- Protocol changes must update `protocol/v1.1-vectors.toml` and regenerate the - C header: `python3 tools/generate-protocol-vectors.py`. Both Python and C - tests consume the same vectors, so they cannot silently drift. -- Firmware queue/priority logic goes in `can_tx_queue.c/h` (host-testable), not - buried in `can_bus.c`. +- Run `make check`. +- Add Python tests under `pi/tests/`. +- For protocol changes, edit `protocol/v2.1-vectors.toml` and run `python3 tools/generate-protocol-vectors.py`. +- Keep queue/priority logic in host-testable `can_tx_queue.c/h`. ## Style -- Python: `ruff` (see `pi/pyproject.toml`), line length 100, target 3.11. -- C: `-Wall -Wextra -Werror`, the firmware never builds with warnings. -- No em dashes in prose; keep messages human and direct. +- Python: Ruff, 100 columns, Python 3.11. +- C: C2x, `-Wall -Wextra -Werror -pedantic`. +- Markdown: direct, concise, no duplicated explanations. +- Repository automation: Python, not new shell scripts. -## Dependencies +## Managed dependencies -- `libopencm3` is pinned by commit + SHA256 in `stm32/third_party/libopencm3.lock` - and fetched by `tools/fetch_libopencm3.py` into gitignored `stm32/.deps/`. -- The ARM cross-toolchain is fetched into gitignored `.tools/` by - `tools/fetch_arm_toolchain.py`. -- `make setup` bootstraps the pinned `uv` binary into `.tools/`; only a host - Python 3.10+ interpreter and the documented OS packages are needed first. -- Repository automation is Python. Do not add new shell scripts. +`make setup` installs pinned tools only in gitignored paths: + +- `.tools/`: `uv`, ARM GNU toolchain, caches +- `stm32/.deps/`: libopencm3 +- `pi/.venv/`: Python environment + +libopencm3 is pinned in `stm32/third_party/libopencm3.lock`. ## Releases -Normal CI builds and smoke-tests the ARM64 Raspberry Pi 5 deployment bundle and -uploads it as a temporary artifact. Permanent GitHub Releases are created -automatically when a `v` tag matching `pi/pyproject.toml` is pushed. -See [`docs/operations/releases.md`](docs/operations/releases.md). +CI builds a temporary ARM64 Pi bundle. A matching `v` tag creates a GitHub Release. See [releases](docs/operations/releases.md). diff --git a/README.md b/README.md index 4b7f88b..e4c5211 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,73 @@ # BeamControl -BeamControl controls a four-channel RF receiver board from a Raspberry Pi 5 over CAN. -The repository contains the Pi software, STM32 firmware, shared protocol tests, deployment -tooling, and hardware notes. - -## Implemented architecture - -```text -Operator / browser / beamctl - | -Raspberry Pi 5 + CAN HAT CAN controller node 0 - | - CAN 2.0 bus - | -STM32 receiver board CAN receiver node 1..30 - |- RF channel 0 - |- RF channel 1 - |- RF channel 2 - `- RF channel 3 -``` - -One complete STM32 receiver board is one CAN node. Its four RF paths are channels inside -that node. Phase shifters, DVGAs, LNAs, filters, detectors, and antenna elements are not CAN -nodes. The Arduino/Wi-Fi and external ADC path shown in the REV3 design drawing is not part -of the implemented software or CAN protocol. +BeamControl controls four-channel RF receiver boards from a Raspberry Pi 5 over CAN. -The protocol uses controller node `0`, receiver-board nodes `1..30`, and broadcast address -`31`. See [`docs/can-protocol.md`](docs/can-protocol.md). +```mermaid +flowchart LR + operator["Operator
beamctl / dashboard"] --> pi["Raspberry Pi 5
CAN node 0"] + pi -->|"CAN 2.0B
500 kbit/s"| stm32["STM32 receiver board
node 1..30"] + stm32 --> channels["RF channels 0..3"] +``` -For a concise explanation suitable for a walkthrough or presentation—including what was -added beyond the original STM32 prototype—see [`docs/overview.md`](docs/overview.md). +One board is one CAN node. Phase shifters and DVGAs are board-local devices. -## Repository layout +## Layout | Path | Purpose | |:--|:--| -| `pi/` | Python 3.11 controller package, CLI, FastAPI dashboard, and Raspberry Pi deployment files | -| `stm32/` | STM32F072 firmware for one receiver board | -| `protocol/` | Shared Python/C protocol vectors | -| `simulation/` | Docker Compose, Renode platform, and virtual end-to-end test | -| `tools/` | Reproducible setup, diagnostics, bundle building, and checks | -| `docs/` | Current architecture, operations, and hardware design notes | +| `pi/` | Python client, CLI, monitor, dashboard, deployment | +| `stm32/` | STM32F072 firmware | +| `protocol/` | Shared Python/C vectors | +| `simulation/` | Docker/SocketCAN/Renode E2E | +| `tools/` | Setup, checks, bundles | +| `docs/` | Protocol, RF, operations | -## Develop and test +## Develop ```bash make setup make doctor make test make check -``` - -`make check` runs linting, all host tests, the protocol contract, and one representative -STM32 build. CI does not prebuild firmware for arbitrary receiver addresses. - -Run the real controller and STM32 ELF together over container-local virtual CAN with: - -```bash make simulation-test ``` -See [`simulation/README.md`](simulation/README.md) for scope and interactive use. - -## Build STM32 firmware - -A node ID is required and must be unique on the physical CAN bus: +## Firmware ```bash make firmware NODE=1 make firmware-size NODE=1 ``` -Valid receiver-board IDs are `1..30`. The build writes `beamcontrol.elf`, -`beamcontrol.bin`, and `beamcontrol.map` under `stm32/app/build/`. +Node IDs are `1..30` and must be unique. Outputs are under `stm32/app/build/`. -## Use the Pi controller +## Controller ```bash beamctl discover beamctl ping 1 -beamctl set-phase 1 --channel 2 --state 128 + +# Individual +beamctl set-phase 1 --state 128 --channel 2 +beamctl set-vga 1 --attenuation 8 --channel 2 +beamctl set-combined 1 --state 128 --attenuation 8 --channel 2 + +# Bulk, channel order 0..3 +beamctl set-phase 1 --states 128 64 32 16 +beamctl set-vga 1 --attenuations 8 9 10 11 +beamctl set-combined 1 --states 64 65 66 67 --attenuations 12 13 14 15 + +beamctl enter-safe 1 --channel 2 beamd --config /etc/uorocketry/beamcontrol.toml ``` -The first positional ID is the receiver-board CAN node. `--channel` selects one of that -board's four RF channels (`0..3`). - -`beamd` owns the CAN status monitor and serves a read-only FastAPI/Jinja2/HTMX dashboard on -port `8080`. The dashboard remains available when CAN hardware or receiver boards are offline. +`beamd` serves a read-only dashboard on port `8080` and stays available when CAN is offline. -For installation and releases, see: +## Docs +- [Overview](docs/overview.md) +- [CAN protocol](docs/can-protocol.md) +- [RF encoding](docs/rf-control.md) - [Developer setup](docs/operations/developer-setup.md) -- [Raspberry Pi 5 deployment](docs/operations/pi-provisioning.md) -- [Release process](docs/operations/releases.md) +- [Pi deployment](docs/operations/pi-provisioning.md) +- [Releases](docs/operations/releases.md) diff --git a/docs/PE44820_Lookup_2.4GHz.csv b/docs/PE44820_Lookup_2.4GHz.csv new file mode 100644 index 0000000..e307032 --- /dev/null +++ b/docs/PE44820_Lookup_2.4GHz.csv @@ -0,0 +1,257 @@ +State,8-bit Binary Word,Desired Phase (deg),Measured Phase at 2.4 GHz (deg),Optimum 9-bit Word (decimal),Optimum 9-bit Word (binary),Optimized Phase at 2.4 GHz (deg) +0,00000000,0,0,0,000000000,0 +1,00000001,1.40625,0.83,257,100000001,1.56 +2,00000010,2.8125,2.71,2,000000010,2.71 +3,00000011,4.21875,3.58,259,100000011,4.3 +4,00000100,5.625,5.82,4,000000100,5.82 +5,00000101,7.03125,6.6,261,100000101,7.36 +6,00000110,8.4375,8.83,6,000000110,8.83 +7,00000111,9.84375,9.69,7,000000111,9.69 +8,00001000,11.25,12.83,263,100000111,10.45 +9,00001001,12.65625,13.85,8,000001000,12.83 +10,00001010,14.0625,15.91,9,000001001,13.85 +11,00001011,15.46875,16.9,10,000001010,15.91 +12,00001100,16.875,20.01,11,000001011,16.9 +13,00001101,18.28125,20.95,267,100001011,17.79 +14,00001110,19.6875,23.36,12,000001100,20.01 +15,00001111,21.09375,24.32,13,000001101,20.95 +16,00010000,22.5,22.76,16,000010000,22.76 +17,00010001,23.90625,23.63,17,000010001,23.63 +18,00010010,25.3125,25.38,18,000010010,25.38 +19,00010011,26.71875,26.29,275,100010011,26.97 +20,00010100,28.125,28.59,20,000010100,28.59 +21,00010101,29.53125,29.44,21,000010101,29.44 +22,00010110,30.9375,31.51,22,000010110,31.51 +23,00010111,32.34375,32.34,23,000010111,32.34 +24,00011000,33.75,35.28,279,100010111,33.13 +25,00011001,35.15625,36.23,24,000011000,35.28 +26,00011010,36.5625,38.25,25,000011001,36.23 +27,00011011,37.96875,39.2,26,000011010,38.25 +28,00011100,39.375,42.3,27,000011011,39.2 +29,00011101,40.78125,43.24,283,100011011,40.07 +30,00011110,42.1875,45.5,28,000011100,42.3 +31,00011111,43.59375,46.5,29,000011101,43.24 +32,00100000,45,45.49,32,000100000,45.49 +33,00100001,46.40625,46.41,33,000100001,46.41 +34,00100010,47.8125,48.36,287,100011111,47.4 +35,00100011,49.21875,49.22,35,000100011,49.22 +36,00100100,50.625,51.06,36,000100100,51.06 +37,00100101,52.03125,51.94,37,000100101,51.94 +38,00100110,53.4375,54.15,38,000100110,54.15 +39,00100111,54.84375,55.08,294,100100110,54.94 +40,00101000,56.25,58.4,295,100100111,55.82 +41,00101001,57.65625,59.35,40,000101000,58.4 +42,00101010,59.0625,61.47,296,100101000,59.2 +43,00101011,60.46875,62.48,297,100101001,60.13 +44,00101100,61.875,65,42,000101010,61.47 +45,00101101,63.28125,66.01,299,100101011,63.28 +46,00101110,64.6875,68.34,44,000101100,65 +47,00101111,66.09375,69.38,45,000101101,66.01 +48,00110000,67.5,68.23,301,100101101,66.82 +49,00110001,68.90625,69.12,304,100110000,68.91 +50,00110010,70.3125,70.94,303,100101111,70.22 +51,00110011,71.71875,71.88,306,100110010,71.69 +52,00110100,73.125,73.79,307,100110011,72.6 +53,00110101,74.53125,74.68,308,100110100,74.5 +54,00110110,75.9375,76.79,309,100110101,75.32 +55,00110111,77.34375,77.68,310,100110110,77.51 +56,00111000,78.75,80.77,311,100110111,78.43 +57,00111001,80.15625,81.73,56,000111000,80.77 +58,00111010,81.5625,83.7,312,100111000,81.57 +59,00111011,82.96875,84.75,313,100111001,82.54 +60,00111100,84.375,87.29,314,100111010,84.56 +61,00111101,85.78125,88.28,315,100111011,85.61 +62,00111110,87.1875,90.5,60,000111100,87.29 +63,00111111,88.59375,91.53,61,000111101,88.28 +64,01000000,90,89.44,320,101000000,90.15 +65,01000001,91.40625,90.22,318,100111110,91.35 +66,01000010,92.8125,92.06,322,101000010,92.87 +67,01000011,94.21875,92.92,323,101000011,93.68 +68,01000100,95.625,95.54,68,001000100,95.54 +69,01000101,97.03125,96.33,325,101000101,97.08 +70,01000110,98.4375,98.61,70,001000110,98.61 +71,01000111,99.84375,99.4,327,101000111,100.2 +72,01001000,101.25,102.81,327,101000111,100.2 +73,01001001,102.65625,103.66,72,001001000,102.81 +74,01001010,104.0625,105.88,328,101001000,103.71 +75,01001011,105.46875,106.83,74,001001010,105.88 +76,01001100,106.875,110.43,75,001001011,106.83 +77,01001101,108.28125,111.34,331,101001011,107.8 +78,01001110,109.6875,113.83,76,001001100,110.43 +79,01001111,111.09375,114.79,77,001001101,111.34 +80,01010000,112.5,112.37,80,001010000,112.37 +81,01010001,113.90625,113.16,337,101010001,113.9 +82,01010010,115.3125,114.97,82,001010010,114.97 +83,01010011,116.71875,115.76,339,101010011,116.53 +84,01010100,118.125,118.47,84,001010100,118.47 +85,01010101,119.53125,119.24,340,101010100,119.25 +86,01010110,120.9375,121.37,86,001010110,121.37 +87,01010111,122.34375,122.22,87,001010111,122.22 +88,01011000,123.75,125.35,343,101010111,123.01 +89,01011001,125.15625,126.28,88,001011000,125.35 +90,01011010,126.5625,128.35,89,001011001,126.28 +91,01011011,127.96875,129.29,90,001011010,128.35 +92,01011100,129.375,132.84,91,001011011,129.29 +93,01011101,130.78125,133.77,347,101011011,130.23 +94,01011110,132.1875,136.16,92,001011100,132.84 +95,01011111,133.59375,137.13,93,001011101,133.77 +96,01100000,135,135.04,96,001100000,135.04 +97,01100001,136.40625,135.78,353,101100001,136.57 +98,01100010,137.8125,137.55,351,101011111,138.05 +99,01100011,139.21875,138.35,355,101100011,139.14 +100,01100100,140.625,141.38,100,001100100,141.38 +101,01100101,142.03125,142.14,101,001100101,142.14 +102,01100110,143.4375,144.32,357,101100101,142.97 +103,01100111,144.84375,145.09,103,001100111,145.09 +104,01101000,146.25,148.39,359,101100111,145.96 +105,01101001,147.65625,149.21,104,001101000,148.39 +106,01101010,149.0625,151.42,105,001101001,149.21 +107,01101011,150.46875,152.3,361,101101001,150.14 +108,01101100,151.875,156.52,107,001101011,152.3 +109,01101101,153.28125,157.37,363,101101011,153.31 +110,01101110,154.6875,159.98,363,101101011,153.31 +111,01101111,156.09375,160.88,108,001101100,156.52 +112,01110000,157.5,158.11,364,101101100,157.54 +113,01110001,158.90625,158.95,368,101110000,158.87 +114,01110010,160.3125,160.59,114,001110010,160.59 +115,01110011,161.71875,161.39,367,101101111,161.92 +116,01110100,163.125,164.48,371,101110011,162.18 +117,01110101,164.53125,165.25,116,001110100,164.48 +118,01110110,165.9375,167.3,373,101110101,166.05 +119,01110111,167.34375,168.1,118,001110110,167.3 +120,01111000,168.75,171.12,375,101110111,168.94 +121,01111001,170.15625,171.95,120,001111000,171.12 +122,01111010,171.5625,174.05,121,001111001,171.95 +123,01111011,172.96875,174.98,377,101111001,172.93 +124,01111100,174.375,179.1,122,001111010,174.05 +125,01111101,175.78125,179.99,379,101111011,175.93 +126,01111110,177.1875,182.43,379,101111011,175.93 +127,01111111,178.59375,183.35,124,001111100,179.1 +128,10000000,180,184.18,125,001111101,179.99 +129,10000001,181.40625,185.06,381,101111101,180.95 +130,10000010,182.8125,186.82,126,001111110,182.43 +131,10000011,184.21875,187.72,128,010000000,184.18 +132,10000100,185.625,190.05,385,110000001,185.74 +133,10000101,187.03125,190.87,130,010000010,186.82 +134,10000110,188.4375,192.94,387,110000011,188.47 +135,10000111,189.84375,193.83,132,010000100,190.05 +136,10001000,191.25,196.97,389,110000101,191.62 +137,10001001,192.65625,197.9,134,010000110,192.94 +138,10001010,194.0625,199.89,135,010000111,193.83 +139,10001011,195.46875,200.89,391,110000111,194.63 +140,10001100,196.875,204.03,136,010001000,196.97 +141,10001101,198.28125,205,137,010001001,197.9 +142,10001110,199.6875,207.25,138,010001010,199.89 +143,10001111,201.09375,208.28,139,010001011,200.89 +144,10010000,202.5,207.19,395,110001011,201.83 +145,10010001,203.90625,208.06,140,010001100,204.03 +146,10010010,205.3125,209.73,141,010001101,205 +147,10010011,206.71875,210.69,144,010010000,207.19 +148,10010100,208.125,212.95,398,110001110,208.16 +149,10010101,209.53125,213.82,146,010010010,209.73 +150,10010110,210.9375,215.77,147,010010011,210.69 +151,10010111,212.34375,216.73,148,010010100,212.95 +152,10011000,213.75,219.53,404,110010100,213.73 +153,10011001,215.15625,220.54,405,110010101,214.57 +154,10011010,216.5625,222.39,406,110010110,216.54 +155,10011011,217.96875,223.4,407,110010111,217.49 +156,10011100,219.375,226.49,152,010011000,219.53 +157,10011101,220.78125,227.44,153,010011001,220.54 +158,10011110,222.1875,229.62,154,010011010,222.39 +159,10011111,223.59375,230.59,155,010011011,223.4 +160,10100000,225,229.33,411,110011011,224.32 +161,10100001,226.40625,230.2,156,010011100,226.49 +162,10100010,227.8125,232.08,157,010011101,227.44 +163,10100011,229.21875,233.02,160,010100000,229.33 +164,10100100,230.625,234.94,159,010011111,230.59 +165,10100101,232.03125,235.82,162,010100010,232.08 +166,10100110,233.4375,237.95,419,110100011,233.75 +167,10100111,234.84375,238.88,164,010100100,234.94 +168,10101000,236.25,242.15,421,110100101,236.54 +169,10101001,237.65625,243.18,166,010100110,237.95 +170,10101010,239.0625,245.18,167,010100111,238.88 +171,10101011,240.46875,246.22,423,110100111,239.65 +172,10101100,241.875,248.83,168,010101000,242.15 +173,10101101,243.28125,249.86,169,010101001,243.18 +174,10101110,244.6875,252.07,170,010101010,245.18 +175,10101111,246.09375,253.11,426,110101010,246.01 +176,10110000,247.5,252.09,427,110101011,247.09 +177,10110001,248.90625,253,172,010101100,248.83 +178,10110010,250.3125,254.8,429,110101101,250.65 +179,10110011,251.71875,255.72,174,010101110,252.07 +180,10110100,253.125,257.69,175,010101111,253.11 +181,10110101,254.53125,258.52,178,010110010,254.8 +182,10110110,255.9375,260.62,179,010110011,255.72 +183,10110111,257.34375,261.55,180,010110100,257.69 +184,10111000,258.75,264.53,181,010110101,258.52 +185,10111001,260.15625,265.52,182,010110110,260.62 +186,10111010,261.5625,267.42,183,010110111,261.55 +187,10111011,262.96875,268.47,439,110110111,262.28 +188,10111100,264.375,271.09,184,010111000,264.53 +189,10111101,265.78125,272.08,185,010111001,265.52 +190,10111110,267.1875,274.19,186,010111010,267.42 +191,10111111,268.59375,275.26,187,010111011,268.47 +192,11000000,270,274.09,443,110111011,269.34 +193,11000001,271.40625,274.95,188,010111100,271.09 +194,11000010,272.8125,276.78,445,110111101,272.92 +195,11000011,274.21875,277.69,190,010111110,274.19 +196,11000100,275.625,280.43,449,111000001,275.72 +197,11000101,277.03125,281.24,194,011000010,276.78 +198,11000110,278.4375,283.43,451,111000011,278.48 +199,11000111,279.84375,284.34,196,011000100,280.43 +200,11001000,281.25,287.79,197,011000101,281.24 +201,11001001,282.65625,288.75,453,111000101,282.03 +202,11001010,284.0625,290.93,199,011000111,284.34 +203,11001011,285.46875,291.9,455,111000111,285.12 +204,11001100,286.875,295.56,200,011001000,287.79 +205,11001101,288.28125,296.54,456,111001000,288.74 +206,11001110,289.6875,298.98,457,111001001,289.67 +207,11001111,291.09375,299.98,202,011001010,290.93 +208,11010000,292.5,297.2,459,111001011,292.89 +209,11010001,293.90625,298.1,459,111001011,292.89 +210,11010010,295.3125,299.83,204,011001100,295.56 +211,11010011,296.71875,300.69,205,011001101,296.54 +212,11010100,298.125,303.47,209,011010001,298.1 +213,11010101,299.53125,304.34,210,011010010,299.83 +214,11010110,300.9375,306.41,463,111001111,301 +215,11010111,302.34375,307.28,467,111010011,301.51 +216,11011000,303.75,310.49,212,011010100,303.47 +217,11011001,305.15625,311.44,469,111010101,305.1 +218,11011010,306.5625,313.5,214,011010110,306.41 +219,11011011,307.96875,314.49,471,111010111,308.13 +220,11011100,309.375,318.06,216,011011000,310.49 +221,11011101,310.78125,319.06,216,011011000,310.49 +222,11011110,312.1875,321.37,473,111011001,312.37 +223,11011111,313.59375,322.38,218,011011010,313.5 +224,11100000,315,320.13,475,111011011,315.43 +225,11100001,316.40625,320.96,475,111011011,315.43 +226,11100010,317.8125,322.71,220,011011100,318.06 +227,11100011,319.21875,323.56,221,011011101,319.06 +228,11100100,320.625,326.76,480,111100000,320.91 +229,11100101,322.03125,327.52,478,111011110,322.35 +230,11100110,323.4375,329.71,482,111100010,323.49 +231,11100111,324.84375,330.53,483,111100011,324.36 +232,11101000,326.25,333.88,228,011100100,326.76 +233,11101001,327.65625,334.82,484,111100100,327.58 +234,11101010,329.0625,336.98,230,011100110,329.71 +235,11101011,330.46875,337.96,231,011100111,330.53 +236,11101100,331.875,342.24,487,111100111,331.42 +237,11101101,333.28125,343.16,232,011101000,333.88 +238,11101110,334.6875,345.72,233,011101001,334.82 +239,11101111,336.09375,346.65,489,111101001,335.78 +240,11110000,337.5,343.36,235,011101011,337.96 +241,11110001,338.90625,344.16,491,111101011,338.95 +242,11110010,340.3125,345.86,491,111101011,338.95 +243,11110011,341.71875,346.71,236,011101100,342.24 +244,11110100,343.125,349.92,237,011101101,343.16 +245,11110101,344.53125,350.79,241,011110001,344.16 +246,11110110,345.9375,352.78,242,011110010,345.86 +247,11110111,347.34375,353.61,499,111110011,347.56 +248,11111000,348.75,356.72,495,111101111,347.7 +249,11111001,350.15625,357.65,244,011110100,349.92 +250,11111010,351.5625,359.73,501,111110101,351.57 +251,11111011,352.96875,360.63,246,011110110,352.78 +252,11111100,354.375,364.88,503,111110111,354.5 +253,11111101,355.78125,365.84,248,011111000,356.72 +254,11111110,357.1875,368.26,249,011111001,357.65 +255,11111111,358.59375,369.23,505,111111001,358.64 diff --git a/docs/README.md b/docs/README.md index bd7c038..e073467 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,37 +1,18 @@ -# BeamControl documentation - -These documents describe the software and firmware contained in this repository. - -## System model - -```text -Operator / browser / beamctl - | -Raspberry Pi 5 + CAN HAT controller node 0 - | - CAN 2.0B bus - | -STM32 receiver board receiver node 1..30 - |- RF channel 0 - |- RF channel 1 - |- RF channel 2 - `- RF channel 3 -``` - -One complete STM32 receiver board is one CAN node. Its four RF paths are channels inside that -node. Board-local components such as phase shifters and DVGAs are not CAN nodes. - -## Documents - -- [Project overview](overview.md) — architecture, original STM32 comparison, command flow, simulation, and test scope -- [CAN protocol v1.1](can-protocol.md) — implemented Python/C wire contract -- [Firmware](firmware.md) — STM32 target and build entry points -- [Developer setup](operations/developer-setup.md) — local environment and checks -- [Raspberry Pi 5 deployment](operations/pi-provisioning.md) — installation and verification -- [Release process](operations/releases.md) — CI artifacts and tagged releases -- [Virtual end-to-end simulation](../simulation/README.md) — Docker, SocketCAN, and Renode - -Component-specific details are also documented next to their code: - -- [Raspberry Pi controller](../pi/README.md) -- [STM32 receiver firmware](../stm32/README.md) +# Documentation + +- [Overview](overview.md): system roles and test layers +- [CAN protocol v2.1](can-protocol.md): wire contract +- [RF encoding](rf-control.md): PE44820/F0480 commands and ACK limits +- [Firmware](firmware.md): target and build +- [Developer setup](operations/developer-setup.md) +- [Pi deployment](operations/pi-provisioning.md) +- [Releases](operations/releases.md) +- [Virtual E2E](../simulation/README.md) +- [Pi controller](../pi/README.md) +- [STM32 firmware](../stm32/README.md) + +Reference files: + +- `pere_s_a0006625230_1-2279326.pdf`: PE44820 +- `REN_F0480_DST_20150427_1.pdf`: F0480 +- `PE44820_Lookup_2.4GHz.csv`: calibrated phase lookup diff --git a/docs/REN_F0480_DST_20150427_1.pdf b/docs/REN_F0480_DST_20150427_1.pdf new file mode 100644 index 0000000..b626b9c Binary files /dev/null and b/docs/REN_F0480_DST_20150427_1.pdf differ diff --git a/docs/can-protocol.md b/docs/can-protocol.md index b8878b1..89516f4 100644 --- a/docs/can-protocol.md +++ b/docs/can-protocol.md @@ -1,141 +1,131 @@ -# BeamControl CAN protocol v1.1 +# BeamControl CAN protocol v2.1 -This is the implemented wire contract shared by the Raspberry Pi software, STM32 firmware, -and generated protocol-vector tests. +Implemented by the Pi client, STM32 firmware, and shared vectors. -## Bus profile +## Bus | Property | Value | |:--|:--| -| Format | Classical CAN 2.0B extended data frames | -| Identifier | 29-bit | -| Bitrate | 500,000 bit/s | -| Sample point | 87.5% | -| SJW | 1 TQ | -| Controller node | Raspberry Pi, address `0` | -| Receiver nodes | One STM32 board each, addresses `1..30` | -| Broadcast destination | `31` | -| Maximum payload | 8 bytes | -| Protocol version | 1.1 | +| Frame | Classical CAN 2.0B extended data | +| Bitrate | 500 kbit/s | +| Sample point / SJW | 87.5% / 1 TQ | +| Controller | Node `0` | +| Receivers | Nodes `1..30` | +| Broadcast | `31` | +| Payload | 0..8 bytes | -## Terminology - -- A **receiver node** is one complete BeamControl board with one STM32. -- An **RF channel** is one of the four signal paths inside a receiver node (`0..3`). -- Phase shifters, DVGAs, antenna elements, and other peripherals are not CAN nodes. - -## Identifier layout +## Identifier ```text -Bits 28:26 TYPE 3 bits -Bits 25:21 DEST 5 bits -Bits 20:16 SOURCE 5 bits -Bits 15:0 SEQUENCE 16 bits +28:26 TYPE +25:21 DEST +20:16 SOURCE +15:0 SEQUENCE ``` ```text -identifier = (type << 26) | (destination << 21) | (source << 16) | sequence +id = (type << 26) | (dest << 21) | (source << 16) | sequence ``` -Responses swap source and destination and retain the request sequence. `ENTER_SAFE` has the -lowest numeric message type and therefore the highest arbitration priority used by this -protocol. +Responses swap source/destination and preserve sequence. `ENTER_SAFE` has the highest command priority because its type is `0`. -## Message types +## Channels -| Type | Message | Direction | Payload | -|:--|:--|:--|:--| -| `0` | `ENTER_SAFE` | Controller → receiver | `[channel]` | -| `1` | `SET_COMBINED` | Controller → receiver | `[phase_state, channel, attenuation_db]` | -| `2` | `SET_PHASE` | Controller → receiver | `[phase_state, channel]` | -| `3` | `SET_VGA` | Controller → receiver | `[attenuation_db]` | -| `4` | `PING` | Controller → receiver | empty | -| `5` | `STATUS` | Receiver → controller | 8-byte status | -| `6` | `ACK` | Receiver → controller | `[command_type, result]` | -| `7` | `ERROR` | Receiver → controller | `[command_type, result]` | +Software channels are `0..3`. Bulk array indexes map to PE44820 addresses `1..4`. -Valid application values are: +## Messages -- `channel`: `0..3` -- `phase_state`: `0..255` -- `attenuation_db`: `0..23` - -Unused payload bytes must be zero. A command may use its minimum DLC or a longer zero-padded -DLC, but an exact retry must preserve the original identifier, DLC, and payload bytes. - -## Responses and retries +| Type | Name | Direction | Exact payload | +|:--|:--|:--|:--| +| `0` | `ENTER_SAFE` | Controller -> receiver | `[channel]` | +| `1` | `SET_COMBINED` | Controller -> receiver | `[state, channel, atten]` or `[PS1..PS4, VGA1..VGA4]` | +| `2` | `SET_PHASE` | Controller -> receiver | `[state, channel]` or `[PS1, PS2, PS3, PS4]` | +| `3` | `SET_VGA` | Controller -> receiver | `[atten, channel]` or `[VGA1, VGA2, VGA3, VGA4]` | +| `4` | `PING` | Controller -> receiver | empty | +| `5` | `STATUS` | Receiver -> controller | 8 bytes | +| `6` | `ACK` | Receiver -> controller | `[command_type, result]` | +| `7` | `ERROR` | Receiver -> controller | `[command_type, result]` | -A unicast state-changing command produces one terminal response: +Ranges: -- `ACK` when the command completes successfully; -- `ERROR` when validation or execution rejects the command. +- `channel`: `0..3` +- phase state: `0..255`, index into the calibrated 2.4 GHz enum +- attenuation: `0..23` dB -The controller matches responses by source, destination, sequence, and command type. It keeps -at most one outstanding transaction per receiver node. The default host policy uses a 20 ms -timeout and two exact-message retries. A final timeout means the resulting hardware state is -unknown. +RF commands require exact DLCs: -Duplicate requests are handled by a replay cache keyed by source, type, and sequence: +| Command | Individual | Bulk | +|:--|--:|--:| +| `SET_PHASE` | 2 | 4 | +| `SET_VGA` | 2 | 4 | +| `SET_COMBINED` | 3 | 8 | -- identical request: replay the cached response; -- same key with different DLC or payload: return sequence-reuse error. +Every command uses an exact DLC. `ENTER_SAFE` is 1 byte and `PING` is empty. -Broadcast address `31` accepts only idempotent `ENTER_SAFE`. Broadcast requests never produce -ACK or ERROR frames. +## Execution order -## Safe output transitions +```mermaid +flowchart LR + vga[SET_VGA] --> vgaWrite["Write attenuation(s)"] + phase[SET_PHASE] --> phaseSafe["23 dB"] --> phaseWrite["Write phase(s)"] --> restore["Restore attenuation(s)"] + combined[SET_COMBINED] --> combinedSafe["23 dB"] --> combinedPhase["Write phase(s)"] --> apply["Apply attenuation(s)"] + safe[ENTER_SAFE] --> safeAtten["23 dB"] --> safePhase["Phase state 0"] +``` -`SET_PHASE` and `SET_COMBINED` apply outputs in this order: +The planner validates the complete frame before hardware writes. Broadcast is allowed only for `ENTER_SAFE`. -1. Apply 23 dB attenuation. -2. Program the phase shifter. -3. Apply the final requested attenuation. +## ACK, retries, replay -An ACK is sent only after all steps complete. Recoverable failures leave attenuation at 23 dB. -Fatal SPI failures reset the firmware, so the controller observes a missing response. +A unicast state-changing request returns: -## Capability discovery +- `ACK` after validation and planned STM32 SPI transfers complete +- `ERROR` on decode, validation, sequence, or execution failure -Sequence `0xFFFF` is reserved for discovery. A receiver answers `PING` at that sequence with -its normal status and a `PROTOCOL_INFO` status frame: +ACK is not RF-device readback; neither RF interface has MISO connected. -| Byte | Value | -|:--|:--| -| 0 | `0xF0` subtype | -| 1 | Major version | -| 2 | Minor version | -| 3 | Patch version | -| 4–5 | Feature flags, little-endian | -| 6 | Compiled receiver node ID | -| 7 | Reserved, zero | +The client matches source, destination, sequence, and command type. Default timeout: 20 ms. Default retries: two, using identical ID, DLC, and bytes. A final timeout means hardware state is unknown. -A receiver without `PROTOCOL_INFO` is treated as protocol v1.0. Configured receivers must -advertise v1.1 or newer and all required feature flags before the monitor reports them healthy. +The receiver caches the latest successful unicast state-changing request: -## Physical configuration +- identical retry: replay ACK, no RF writes +- same source/type/sequence with different DLC or data: `SEQUENCE_REUSE` +- broadcast: no cache, no response -The repository configures: +## Results -- STM32F072 bxCAN at 48 MHz: prescaler 6, BS1 13, BS2 2; -- Raspberry Pi SocketCAN at 500 kbit/s, 87.5% sample point, SJW 1; -- automatic bus restart after 100 ms on the Pi. +| Value | Name | Meaning | +|:--|:--|:--| +| `0` | `OK` | Completed on STM32 | +| `1` | `INVALID_LENGTH` | Unsupported DLC | +| `2` | `INVALID_PAYLOAD` | Invalid channel or attenuation | +| `3` | `UNSUPPORTED` | Not a controller command | +| `4` | `HARDWARE` | Hardware operation failed | +| `5` | `BUSY` | Cannot accept command | +| `6` | `SEQUENCE_REUSE` | Same key, different request bytes | +| `7` | `BROADCAST_NOT_ALLOWED` | Non-safe broadcast | -The bus requires exactly two 120 Ω termination resistors, a common CAN reference, a linear -trunk, and short stubs. +## Status and discovery -## Acceptance filters +`PING` returns one STATUS frame: -The STM32 accepts only extended data frames from controller node `0` addressed to its own node -ID or broadcast address `31`. Hardware filters reject standard and remote frames; firmware -rechecks frame format, source, and destination before dispatch. +```text +0 protocol major (2) +1 protocol minor (1) +2 protocol patch (0) +3 node ID +4 health flags +5 RX-drop count low byte +6 TX-drop count low byte +7 invalid-command count low byte +``` -## Source of truth +Version matching is exact. -The executable contract is maintained in: +## Contract vectors -- `protocol/v1.1-vectors.toml` -- `protocol/generated/protocol_vectors.h` -- `pi/src/beamcontrol/protocol.py` -- `stm32/app/src/can_protocol.c` +- Source: `protocol/v2.1-vectors.toml` +- Generated C: `protocol/generated/protocol_vectors.h` -Run `make protocol-check` after any protocol change. +```bash +python3 tools/generate-protocol-vectors.py +``` diff --git a/docs/firmware.md b/docs/firmware.md index a4d00d5..8a42c12 100644 --- a/docs/firmware.md +++ b/docs/firmware.md @@ -1,25 +1,11 @@ # Firmware -The active firmware is the C implementation under [`../stm32/`](../stm32/). It targets the -STM32F072R8T6 on one four-channel receiver board and uses libopencm3. - -Each flashed board is one CAN receiver node. Its node ID is compiled into the image and must -be selected explicitly: +The active firmware is under [`../stm32/`](../stm32/). It targets STM32F072R8T6 with libopencm3. ```bash make firmware NODE=<1..30> ``` -The build produces `beamcontrol.elf`, `beamcontrol.bin`, and `beamcontrol.map` under -`stm32/app/build/`. - -The firmware: - -- controls four local RF channels; -- implements [CAN protocol v1.1](can-protocol.md); -- applies safe phase and attenuation transitions; -- validates commands and sends ACK or ERROR responses; and -- records retained reset and fault diagnostics. +Outputs: `stm32/app/build/beamcontrol.{elf,bin,map}`. -The detailed source layout, hardware pins, and test commands are in the -[STM32 README](../stm32/README.md). +The firmware implements CAN protocol 2.1, four local RF channels, safe phase transitions, ACK/ERROR responses, watchdog recovery, and retained diagnostics. See the [STM32 README](../stm32/README.md). diff --git a/docs/operations/developer-setup.md b/docs/operations/developer-setup.md index 04f1331..54eddbb 100644 --- a/docs/operations/developer-setup.md +++ b/docs/operations/developer-setup.md @@ -1,20 +1,15 @@ # Developer setup -The supported development environment is Linux. Windows developers use WSL2 -with an Ubuntu distribution; native Windows is not supported. +Supported host: Linux. On Windows, use Ubuntu in WSL2 and clone inside the WSL filesystem. -## Linux +## Install -Install the small set of host prerequisites. On Debian or Ubuntu: +Debian/Ubuntu: ```bash sudo apt update sudo apt install --yes build-essential git make python3 -``` - -Clone the repository and run the repository-managed setup: -```bash git clone https://github.com/uorocketry/beamforming-software.git cd beamforming-software make setup @@ -23,86 +18,46 @@ make test make firmware NODE=1 ``` -`make setup` is idempotent. Before changing anything, it prints the directories -it will use and refuses to run as root unless `ALLOW_ROOT_SETUP=1` is set for an -intentional root-owned container. It installs pinned tooling and dependencies -only in repository-local, gitignored directories: +Host Python must be 3.10+; the project environment is Python 3.11. + +`make setup` is idempotent and writes only to: -- `.tools/` for `uv`, the ARM GNU toolchain, uv's cache, and any uv-managed - Python interpreter; -- `stm32/.deps/` for libopencm3; and -- `pi/.venv/` for the Python environment. +- `.tools/`: `uv`, ARM toolchain, caches +- `stm32/.deps/`: libopencm3 +- `pi/.venv/`: Python environment -The bootstrap host interpreter must be Python 3.10 or newer. BeamControl itself -supports Python 3.11 only, and `make setup` creates a Python 3.11 project -environment. Setup does not use `sudo`, install OS packages, modify system -Python, write to uv's normal per-user cache, or replace a system compiler. +It does not install OS packages, modify system Python, or use `sudo`. Root setup requires `ALLOW_ROOT_SETUP=1`. -To remove every downloaded development tool, dependency, environment, cache, -and build output, run: +Remove generated tools and builds: ```bash make distclean ``` -This is the development equivalent of an uninstall. It only removes generated -paths inside the repository and leaves source files untouched. - -## Windows with WSL2 - -From an elevated PowerShell prompt: +## WSL2 ```powershell wsl --install -d Ubuntu ``` -Then open Ubuntu and follow the Linux instructions above. Clone the repository -inside the WSL filesystem, for example `~/src/beamforming-software`. Avoid -working under `/mnt/c/...`; Windows-mounted paths are slower and can cause file -permission or executable-bit problems. +Use a path such as `~/src/beamforming-software`, not `/mnt/c/...`. ## Diagnostics -`make doctor` is read-only. It reports required build blockers separately from -optional capabilities such as `cppcheck`, `ip`, `can-utils`, and a live -`can0` SocketCAN interface. - -```bash -make doctor -``` - -`make help` lists the stable command interface: - ```bash +make doctor # read-only checks make help +make quickstart # setup + doctor + host tests ``` -For a new checkout, `make quickstart` runs setup, diagnostics, and the host test -suite in sequence. - -## Optional packages - -Install these only for the associated workflows: +Optional packages: ```bash sudo apt install --yes can-utils cppcheck iproute2 ``` -- `can-utils` provides tools such as `candump` for physical CAN testing. -- `cppcheck` is used by `python3 stm32/scripts/verify.py`. -- `iproute2` provides `ip`, which configures the Linux `can0` interface. Python - sends and receives frames through SocketCAN after the interface is configured; - the standard library does not provide a high-level replacement for CAN - bitrate, sample-point, and link-state configuration. - -The offline Raspberry Pi bundle is a gzip-compressed tar archive created and -extracted with Python's standard-library `tarfile` module. It does not require -external `tar`, `gzip`, or `zstd` commands. - -## Offline use +- `can-utils`: physical CAN inspection +- `cppcheck`: `python3 stm32/scripts/verify.py` +- `iproute2`: configure SocketCAN links -After `make setup` has completed, normal tests and firmware builds use the -repository-local pinned toolchain and cached Python dependencies. Creating a -fresh checkout or rebuilding a missing dependency still requires network access. -The flight Raspberry Pi deployment path is separately packaged as an offline -bundle; see [Pi provisioning](pi-provisioning.md). +After setup, tests and firmware builds use local caches. A fresh checkout still needs network access. Pi deployment uses an offline bundle. diff --git a/docs/operations/pi-provisioning.md b/docs/operations/pi-provisioning.md index 6d8289b..aa157e7 100644 --- a/docs/operations/pi-provisioning.md +++ b/docs/operations/pi-provisioning.md @@ -1,68 +1,45 @@ -# Raspberry Pi 5 provisioning and deployment +# Raspberry Pi 5 deployment -BeamControl is deployed to a Raspberry Pi 5 running the qualified 64-bit -Raspberry Pi OS Bookworm image. The deployment installer rejects other hardware, -non-ARM64 systems, and unsupported Python versions before making system changes. +## Qualified target -## Supported target - -| Field | Required value | -| --- | --- | -| Computer | Raspberry Pi 5 | -| Architecture | ARM64 (`aarch64`) | -| Distribution | Raspberry Pi OS Lite, 64-bit, Bookworm | +| Field | Required | +|:--|:--| +| Hardware | Raspberry Pi 5 | +| Architecture | `aarch64` | +| OS | Raspberry Pi OS Lite 64-bit, Bookworm | | Python | 3.11 | -| Qualified image | `2025-05-13-raspios-bookworm-arm64-lite.img.xz` | -| Image SHA256 | `62d025b9bc7ca0e1facfec74ae56ac13978b6745c58177f081d39fbb8041ed45` | - -The offline bundle is built for Python 3.11. Moving to another Raspberry Pi OS -release requires rebuilding and requalifying the bundle and updating the OS check -in `pi/deploy/provision_os.py`. - -## What CI verifies - -Every pull request and `main` push builds the real ARM64 offline bundle on a -GitHub-hosted ARM64 runner. CI extracts the archive, installs every dependency -from its wheelhouse into a clean Python 3.11 virtual environment without network -access, imports `beamcontrol`, and runs `beamctl --help` and `beamd --help`. +| Image | `2025-05-13-raspios-bookworm-arm64-lite.img.xz` | +| SHA256 | `62d025b9bc7ca0e1facfec74ae56ac13978b6745c58177f081d39fbb8041ed45` | -CI cannot emulate Raspberry Pi device-tree overlays, the MCP2515 controllers, -physical CAN wiring, `can0`, or systemd startup on the flight computer. Those are -covered by the verification steps at the end of this guide. +Other OS releases require a rebuilt/requalified bundle and updated checks in `pi/deploy/provision_os.py`. -## Flash the Pi and enable SSH +## CI coverage -1. Verify the downloaded image before flashing: +CI builds the ARM64 offline bundle, installs it without network access into a clean Python 3.11 venv, imports `beamcontrol`, and runs `beamctl --help` and `beamd --help`. - ```bash - echo "62d025b9bc7ca0e1facfec74ae56ac13978b6745c58177f081d39fbb8041ed45 2025-05-13-raspios-bookworm-arm64-lite.img.xz" | sha256sum --check - ``` +CI does not test device-tree overlays, MCP2515 hardware, physical CAN, `can0`, or systemd on the target. -2. In Raspberry Pi Imager, choose **Use custom** and select the qualified image. -3. In the Imager customization screen: - - set a hostname such as `beamcontrol-pi`; - - create a non-root user; - - configure Wi-Fi or plan to use Ethernet; - - enable SSH, preferably with your public key. -4. Boot the Pi 5 and connect from your development computer: +## Flash and connect - ```bash - ssh @beamcontrol-pi.local - ``` +Verify the image: - When multicast DNS is unavailable, find the Pi's address from your router and - connect with `ssh @`. +```bash +echo "62d025b9bc7ca0e1facfec74ae56ac13978b6745c58177f081d39fbb8041ed45 2025-05-13-raspios-bookworm-arm64-lite.img.xz" | sha256sum --check +``` -The examples below use these local shell variables: +Flash it with Raspberry Pi Imager **Use custom**. Set hostname, non-root user, network, and SSH key. ```bash export PI_USER= export PI_HOST=beamcontrol-pi.local +ssh "$PI_USER@$PI_HOST" ``` -## Provision over SSH +Use the Pi's IP address if mDNS is unavailable. -From the repository root on your development computer: +## Provision OS + +From the repository root: ```bash ssh "$PI_USER@$PI_HOST" 'mkdir -p /tmp/beamcontrol-provision' @@ -73,46 +50,19 @@ ssh -t "$PI_USER@$PI_HOST" \ ssh -t "$PI_USER@$PI_HOST" 'sudo reboot' ``` -The provisioning script verifies the Raspberry Pi 5 model and Bookworm, installs -`python3`, `python3-venv`, `can-utils`, and `iproute2`, configures the CAN device- -tree overlays, and creates the `beamcontrol` service account and directories. -The SSH connection will close during reboot. +Provisioning validates the target, installs Python/venv, `can-utils`, and `iproute2`, configures CAN overlays, and creates the service account/directories. -Reconnect after the Pi comes back: +## Obtain a bundle -```bash -ssh "$PI_USER@$PI_HOST" -``` - -## Obtain a deployment bundle - -### Tagged release, recommended for deployment - -Pushing a version tag such as `v0.1.0` automatically runs the complete release -workflow and publishes firmware plus `beamcontrol-pi-*.tar.gz` on the repository's -GitHub Releases page. The tag must match the version in `pi/pyproject.toml`. +Preferred: download `beamcontrol-pi-*.tar.gz` and `SHA256SUMS` from a tagged release. -Download the archive and its `SHA256SUMS` file to your development computer, then -verify it before copying it to the Pi. - -### CI artifact, useful for testing a branch - -Every CI run uploads a 14-day artifact named `beamcontrol-pi-`. In the -GitHub web interface, open **Actions**, select the CI run, and download the artifact -from the run summary. - -With GitHub CLI, a specific run can be downloaded with: +Branch testing: download the 14-day CI artifact: ```bash gh run download --name beamcontrol-pi- --dir build/deployment ``` -CI artifacts are intended for testing commits. Prefer a tagged release for the -flight image. - -### Build directly on the Pi 5 - -For a manual networked build, clone the repository on the Pi and run: +Manual networked build on Pi 5: ```bash git clone https://github.com/uorocketry/beamforming-software.git @@ -120,13 +70,7 @@ cd beamforming-software make pi-bundle-smoke ``` -The command builds the archive and verifies a clean offline installation before -returning. The resulting archive is written under `build/`. This path downloads -build-time Python dependencies; the deployment archive itself installs offline. - -## Install the bundle over SSH - -On the development computer, set `BUNDLE` to the downloaded archive: +## Install ```bash export BUNDLE=build/beamcontrol-pi-.tar.gz @@ -141,36 +85,16 @@ ssh -t "$PI_USER@$PI_HOST" ' ' ``` -The installer verifies Raspberry Pi 5, ARM64, and Python 3.11; installs the wheel -and dependencies into a versioned virtual environment under -`/opt/uorocketry/beamcontrol/releases/`; updates the `current` symlink atomically; -and installs, enables, and starts both systemd units. - -## Fully manual installation on the Pi - -When the archive reaches the Pi through a USB drive or another transfer method: - -```bash -mkdir -p ~/beamcontrol-install -cd ~/beamcontrol-install -cp /path/to/beamcontrol-pi-*.tar.gz . -python3 -m tarfile -e beamcontrol-pi-*.tar.gz . -sudo python3 ./beamcontrol-pi-*/install.py -``` - -No SSH-specific behavior is required. The same installer performs the target and -version checks. +For USB/local transfer, extract with `python3 -m tarfile -e` and run the same `install.py`. -## Configure nodes +The installer validates Pi 5/ARM64/Python 3.11, installs a versioned release under `/opt/uorocketry/beamcontrol/releases/`, switches `current` atomically, and installs/enables both services. Upgrades preserve `/etc/uorocketry/beamcontrol.toml`. -Edit the installed configuration: +## Configure ```bash sudo nano /etc/uorocketry/beamcontrol.toml ``` -The default structure is: - ```toml [beamcontrol] channel = "can0" @@ -178,19 +102,16 @@ source_node = 0 poll_interval_s = 1.0 can_timeout_s = 0.020 can_retries = 2 -# Empty means discover all receiver-board nodes 1 through 30. -nodes = [] +nodes = [] # empty: discover nodes 1..30 web_host = "0.0.0.0" web_port = 8080 ``` -Restart the daemon after changing configuration: - ```bash sudo systemctl restart beamcontrol.service ``` -## Verify the installation +## Verify ```bash cat /proc/device-tree/model; echo @@ -202,54 +123,31 @@ systemctl --no-pager --full status beamcontrol.service sudo journalctl -u beamcontrol.service -n 100 --no-pager /opt/uorocketry/beamcontrol/current/venv/bin/beamctl discover curl --fail http://127.0.0.1:8080/healthz +candump can0 ``` -Expected results: - -- the model starts with `Raspberry Pi 5 Model`; -- the architecture is `aarch64`; -- Python is 3.11; -- `can0` is `UP` at 500 kbit/s; -- both services are active without restart loops; -- `beamctl discover` reports the connected receiver nodes; and -- the dashboard health endpoint returns `{"status":"ok"}`. +Expected: Pi 5, `aarch64`, Python 3.11, `can0` up at 500 kbit/s, active services, discovered receivers, and `{"status":"ok"}`. -Physical CAN verification should also include `candump can0` while commands and -status frames are being exchanged. +## Dashboard -## Open the web dashboard +Default: `http://beamcontrol-pi.local:8080/`. -The default configuration listens on port `8080` on every Pi network interface: - -```text -http://beamcontrol-pi.local:8080/ -``` - -The dashboard is read-only but does not provide authentication. Use it only on a trusted -network. For localhost-only access, set: +The dashboard is read-only and unauthenticated. On untrusted networks: ```toml web_host = "127.0.0.1" ``` -Then restart the service and create an SSH tunnel from the operator computer: - ```bash sudo systemctl restart beamcontrol.service -ssh -L 8080:127.0.0.1:8080 @beamcontrol-pi.local +ssh -L 8080:127.0.0.1:8080 "$PI_USER@$PI_HOST" ``` -Open `http://127.0.0.1:8080/` locally. Other useful endpoints are: +Endpoints: -| Endpoint | Purpose | +| Path | Purpose | |:--|:--| -| `/api/status` | Complete JSON status snapshot | -| `/healthz` | Web-process liveness | -| `/readyz` | `200` only when CAN and required receivers are healthy | -| `/api/docs` | FastAPI endpoint documentation | - -## Upgrade - -Install a newer verified bundle using the same extraction and `install.py` steps. -Releases are stored in versioned directories and the `current` symlink is switched -atomically. Existing `/etc/uorocketry/beamcontrol.toml` configuration is preserved. +| `/api/status` | JSON snapshot | +| `/healthz` | Process liveness | +| `/readyz` | CAN/receiver readiness | +| `/api/docs` | FastAPI docs | diff --git a/docs/operations/releases.md b/docs/operations/releases.md index feb7473..2f5e091 100644 --- a/docs/operations/releases.md +++ b/docs/operations/releases.md @@ -1,56 +1,39 @@ # Releases -GitHub Releases publish the Raspberry Pi 5 deployment bundle. STM32 firmware is not -pre-addressed or attached because every physical receiver board needs an explicitly chosen, -unique CAN node ID. +Tagged releases publish the ARM64 Raspberry Pi bundle. STM32 images are built separately because each board needs a unique CAN node ID. -## Create a release +## Create -1. Update the package version in `pi/pyproject.toml`. -2. Regenerate the lockfile: +1. Update `pi/pyproject.toml`. +2. Run: ```bash .tools/bin/uv lock --project pi - ``` - -3. Run the local gate: - - ```bash make check ``` -4. Commit the release changes and push a matching tag. For version `0.1.0`: +3. Commit and push a matching tag: ```bash git tag -a v0.1.0 -m "BeamControl v0.1.0" git push origin v0.1.0 ``` -The release workflow verifies that the tag matches the package version, runs the full test and -representative firmware-build gate, builds the ARM64 Pi bundle, smoke-tests its offline -installation, creates a checksum, and publishes the GitHub Release. - -## Release contents +The workflow verifies the version, runs checks, builds and offline-smoke-tests the Pi bundle, creates checksums, and publishes the release. -A tagged release contains: +## Contents - `beamcontrol-pi-.tar.gz` - `beamcontrol-pi-SHA256SUMS.txt` -Build STM32 firmware separately for each board: +Build board firmware explicitly: ```bash make firmware NODE=<1..30> ``` -Do not flash two boards with the same node ID on the same CAN bus. - ## CI artifacts -Every push and pull request builds and smoke-tests the Pi bundle on an ARM64 GitHub runner. -That temporary artifact is useful for testing a branch. Tagged GitHub Releases are the stable -deployment source. +Every push/PR produces a temporary ARM64 bundle for branch testing. Use tagged releases for deployment. -GitHub CI cannot test physical CAN wiring, MCP2515 hardware, device-tree overlays, or systemd -startup on the flight Pi. Complete those checks using the -[Pi deployment guide](pi-provisioning.md#verify-the-installation). +CI does not test physical CAN, MCP2515 overlays, systemd on the flight Pi, or RF hardware. See [Pi verification](pi-provisioning.md#verify). diff --git a/docs/overview.md b/docs/overview.md index d1e3ef4..4c93abd 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,193 +1,70 @@ # BeamControl overview -## 30-second explanation - -BeamControl lets a Raspberry Pi control one or more four-channel RF receiver boards over CAN. -The Pi is the controller and hosts the command-line tools, status monitor, and web dashboard. -Each receiver board has one STM32 microcontroller that receives CAN commands and programs the -board's phase shifters and attenuation hardware over SPI. - -```text -Operator or browser - | -Raspberry Pi 5 CAN node 0 - | - CAN - | -STM32 receiver board CAN node 1..30 - |- RF channel 0 - |- RF channel 1 - |- RF channel 2 - `- RF channel 3 -``` - -A complete receiver board is one CAN node. The four RF paths are channels inside that node; -they are not separate CAN nodes. - -## What each part does - -### Raspberry Pi controller - -The Pi runs the Python software: - -- `beamd` monitors receiver boards and serves the read-only web dashboard; -- `beamctl` sends operator commands and diagnostics; and -- SocketCAN provides the connection to the physical CAN HAT. +BeamControl splits Pi supervision from deterministic STM32 RF control. -The Pi uses CAN node address `0`. +## Pi -### STM32 receiver board +- Sends individual or four-channel commands. +- Discovers and monitors receivers. +- Serves the read-only dashboard. +- Uses Linux SocketCAN. -Each receiver board runs the STM32F072 firmware and has a unique CAN node address from `1` to -`30`. The firmware: +## STM32 -- validates incoming commands; -- controls phase and attenuation through SPI and GPIO; -- attenuates before changing phase to reduce unsafe output transitions; -- returns ACK, ERROR, and STATUS messages; -- handles retries without repeating an already completed operation; and -- uses watchdog recovery and retained fault diagnostics. +- Validates CAN frames. +- Converts phase indexes and attenuation values to RF serial words. +- Attenuates before phase changes. +- Returns ACK, ERROR, and STATUS. +- Uses replay protection, watchdog recovery, and retained diagnostics. -### RF channels and components +A board is one CAN node; RF devices are local peripherals. -Each board contains four RF channels, numbered `0` through `3`. A command therefore identifies: +## Command path -1. the receiver-board CAN node; and -2. the channel inside that board when the operation is channel-specific. +```mermaid +sequenceDiagram + participant Operator as beamctl + participant Pi as Pi client + participant STM32 as STM32 receiver + participant RF as RF devices -Phase shifters, amplifiers, filters, detectors, and antenna elements are components of a board, -not CAN nodes. - -## Example command path - -```text -beamctl set-phase 1 --channel 2 --state 128 - | - | CAN command to receiver node 1 - v -STM32 validates the request - | - | SPI and GPIO transaction - v -Phase shifter for channel 2 is programmed - | - | CAN ACK - v -Pi confirms that the command completed + Operator->>Pi: Individual or bulk command + Pi->>STM32: CAN request + STM32->>STM32: Decode, validate, plan + STM32->>RF: SPI/GPIO writes + STM32-->>Pi: ACK or ERROR + Pi-->>Operator: Result ``` -## What was added beyond the original STM32 prototype - -The original `stm32beamforming` code was a hardware bring-up program. It configured SPI, -wrote one hard-coded phase-shifter command, and then stopped. The VGA driver was present, but -the active program did not initialize or use it. +ACK confirms STM32 transfer completion, not RF-device readback. -BeamControl keeps the same basic SPI device-control idea and expands it into production-style -receiver firmware: +## Compared with the original prototype -| Area | Original STM32 prototype | BeamControl | +| Area | Prototype | BeamControl | |:--|:--|:--| -| Main behavior | Sends one phase command and stops | Runs continuously as a CAN-controlled receiver node | -| Remote control | None | CAN commands for discovery, phase, attenuation, combined updates, and safe mode | -| Addressing | Hard-coded local device address | Receiver-board node `1..30` plus RF channel `0..3` | -| VGA use | Driver existed but was unused by `main` | Initialized at boot and controlled through CAN | -| SPI completion | Wrote data and raised latch/CS immediately | Waits for TX-ready and transfer completion with bounded timeouts | -| Safe phase changes | None | Applies maximum attenuation before changing phase, then restores attenuation | -| Validation | Minimal | Checks CAN IDs, frame type, lengths, ranges, reserved bytes, and broadcast rules | -| Retries | None | Sequence numbers and replay handling avoid repeating completed RF operations | -| Fault handling | Debug breakpoint or infinite loop | Records faults, resets, and can enter safe lockout after repeated incomplete boots | -| Watchdog | None | Independent watchdog supervises the running firmware | -| Diagnostics | None | Boot count, reset flags, fault history, clock source, and last RF commands are retained | -| CAN robustness | No CAN | Acceptance filters, RX/TX queues, priorities, retransmission, and bus-off recovery | -| Testing | Manual hardware experiments | Native C tests, Python/C protocol tests, cross-builds, and Docker/Renode E2E simulation | - -The project also corrected issues present in the old codebase, including a phase-conversion -integer-division error, inconsistent function types that prevented modern compilation, and -raising SPI latch signals without explicitly waiting for the transfer to finish. - -In simple terms, the original code proved that the STM32 could talk to the RF control chips. -BeamControl turns that proof of concept into a remotely operated, continuously supervised, -testable receiver-board controller. - -The current firmware is still not a substitute for hardware qualification. It cannot confirm -that an external RF device physically accepted an SPI command because there is no device -readback, and real CAN electrical behavior and RF performance must still be measured on the -board. - -## Web dashboard - -`beamd` serves a compact FastAPI/Jinja2/HTMX dashboard. It shows: - -- Pi and service status; -- CAN availability; -- receiver-board health; -- protocol versions and response times; and -- recent monitor events. - -The current dashboard is read-only. It remains available when CAN hardware or receiver boards -are offline so it can explain the failure state. - -## Virtual end-to-end simulation - -The repository can run the controller and the actual compiled STM32 firmware without physical -boards: - -```text -Docker Compose - |- real beamd and dashboard - |- container-local SocketCAN vcan0 - |- Renode running the real STM32 ELF - `- automated end-to-end assertions -``` - -Run it with: - -```bash -make simulation-test -``` - -The test verifies that real CAN commands reach the real firmware, produce the expected ACKs, -and become the expected SPI and GPIO writes. It covers discovery, phase, attenuation, combined -safe transitions, safe mode, and dashboard health. - -## What the simulation proves - -It verifies the software path end to end: +| Runtime | One hard-coded write | Continuous CAN node | +| Commands | None | Discovery, individual/bulk RF updates, safe mode | +| VGA | Driver unused by `main` | Initialized and controlled | +| Phase safety | None | Attenuate, phase, restore/apply | +| SPI | No bounded completion wait | Guarded transfer and timeout | +| Validation | Minimal | ID, type, DLC, ranges, broadcast, replay | +| Recovery | Debug/infinite loop | Fault record, reset, safe lockout, watchdog | +| CAN | None | Filters, queues, priorities, bus-off recovery | +| Tests | Manual | Python, native C, contract, ARM build, Renode E2E | -```text -Python controller -> SocketCAN -> STM32 firmware -> SPI/GPIO behavior -> CAN response -``` +## Dashboard -It does not verify electrical or RF behavior such as voltage levels, RF phase accuracy, signal -integrity, CAN termination, oscillator tolerance, brownouts, or physical component failures. -Those require hardware-in-the-loop and RF acceptance testing. +`beamd` shows Pi/service, CAN, receiver health, protocol, latency, and recent events. It is read-only and remains available during CAN failures. -## Testing layers +## Test layers -| Layer | Purpose | +| Layer | Coverage | |:--|:--| -| Unit and protocol tests | Check Python and C logic quickly | -| Docker + Renode simulation | Exercise the real controller and STM32 firmware together | -| ARM64 deployment test | Verify the offline Raspberry Pi bundle | -| Physical hardware test | Validate CAN electrical behavior, GPIO/SPI timing, and RF performance | - -## Simple demonstration +| Unit/contract | Python and C logic | +| Renode E2E | Pi client, SocketCAN, real STM32 ELF, SPI/GPIO | +| ARM64 bundle test | Offline Pi installation | +| Hardware validation | Electrical timing, CAN wiring, RF performance | ```bash make simulation-test ``` - -A successful run demonstrates that: - -1. the virtual CAN bus starts; -2. the real STM32 firmware boots in Renode; -3. the real Pi controller discovers receiver node `1`; -4. phase, attenuation, combined, and safe commands are acknowledged; -5. the expected SPI/GPIO transactions occur; and -6. the dashboard reports the receiver as healthy. - -The main message is: - -> BeamControl separates high-level supervision on the Raspberry Pi from deterministic, -> safety-aware RF control on each STM32 receiver board, and the complete software path is -> tested automatically before physical hardware testing. diff --git a/docs/pere_s_a0006625230_1-2279326.pdf b/docs/pere_s_a0006625230_1-2279326.pdf new file mode 100644 index 0000000..8bfc93a Binary files /dev/null and b/docs/pere_s_a0006625230_1-2279326.pdf differ diff --git a/docs/rf-control.md b/docs/rf-control.md new file mode 100644 index 0000000..316022c --- /dev/null +++ b/docs/rf-control.md @@ -0,0 +1,68 @@ +# RF control encoding + +CAN carries logical values; STM32 builds device serial words. + +## PE44820 + +Each phase value is a `0..255` lookup index. Firmware maps it to a calibrated 2.4 GHz `optimizedPhaseState_e` value from `rf/phase_states_2_4ghz.h`: + +```text +bit 8 OPT +bits 7:0 D7:D0 +``` + +Source data: `docs/PE44820_Lookup_2.4GHz.csv`. Do not cast the index directly to the enum; the mapping is nonsequential and contains intentional duplicates. + +PE44820 serial order: + +```text +D0 D1 D2 D3 D4 D5 D6 D7 OPT A0 A1 A2 A3 +``` + +STM32 SPI sends MSB-first, so firmware reverses the 8-bit phase field and 4-bit address before packing: + +```text +state = GetOptimizedPhaseState(index) +command = + (reverseBits(state & 0xff, 8) << 5) + | (((state >> 8) & 1) << 4) + | reverseBits(address, 4) +``` + +Example: index `146` -> word `0b010001101` (`0x08D`); address `3` -> command `0x162C`. + +CANDev-compatible names retained: `optimizedPhaseState_e`, `GetOptimizedPhaseState()`, `reverseBits()`, `MakePSCommand()`, `pe448spisetup()`. + +## F0480 + +Attenuation is `0..23` dB. Command bits `D6:D2` encode `16,8,4,2,1` dB; `D7,D1,D0` are zero. + +```text +command = attenuation_db << 2 +``` + +| dB | Byte | +|--:|:--| +| 0 | `0x00` | +| 3 | `0x0C` | +| 8 | `0x20` | +| 16 | `0x40` | +| 23 | `0x5C` | + +The driver retains channel `0..3`, but the checked-in board map identifies only SPI1 and PA4 CS. Add the final four-device selector net mapping in `vga.c`; do not infer pins. + +## ACK limit + +Both buses are transmit-only. ACK confirms: + +1. CAN validation passed. +2. The operation plan completed. +3. STM32 SPI finished before latch/CS release. + +ACK does not confirm RF-device decode or measured RF state. + +## References + +- `docs/pere_s_a0006625230_1-2279326.pdf` +- `docs/REN_F0480_DST_20150427_1.pdf` +- `docs/PE44820_Lookup_2.4GHz.csv` diff --git a/pi/README.md b/pi/README.md index df041e1..318488d 100644 --- a/pi/README.md +++ b/pi/README.md @@ -1,22 +1,18 @@ # Raspberry Pi controller -Python 3.11 software for the Raspberry Pi 5 CAN controller. - -The Pi is CAN node `0`. It communicates with complete STM32 receiver boards at node IDs -`1..30`. Each receiver board contains four RF channels (`0..3`). +Python 3.11 controller for Raspberry Pi 5, CAN node `0`. Receiver boards use nodes `1..30` and channels `0..3`. ## Package -`src/beamcontrol/` provides: +`src/beamcontrol/` contains: -- `BeamControlClient` for commands, retries, discovery, and response matching; -- `SocketCanTransport` using Linux SocketCAN through `python-can`; -- `beamctl` for operator and diagnostic commands; -- `beamd` for periodic receiver status and the read-only web dashboard; -- TOML configuration loading. +- `BeamControlClient`: commands, retries, discovery, response matching +- `SocketCanTransport`: Linux SocketCAN via `python-can` +- `beamctl`: operator CLI +- `beamd`: monitor and read-only dashboard +- TOML configuration -The Pi CAN HAT is controlled by the Linux MCP2515 driver. `can_setup.py` uses `ip` to bring -up `can0` at 500 kbit/s and an 87.5% sample point. +`can_setup.py` configures `can0` at 500 kbit/s, 87.5% sample point. ## Develop @@ -26,55 +22,26 @@ make pi-test make pi-lint ``` -Development installs the package only in `pi/.venv`; it does not register a system daemon. - ## Commands ```bash beamctl discover beamctl ping 1 -beamctl set-phase 1 --channel 2 --state 128 -beamctl set-vga 1 --attenuation 23 +beamctl set-phase 1 --state 128 --channel 2 +beamctl set-phase 1 --states 128 64 32 16 +beamctl set-vga 1 --attenuation 8 --channel 2 +beamctl set-vga 1 --attenuations 8 9 10 11 +beamctl set-combined 1 --state 128 --attenuation 8 --channel 2 +beamctl set-combined 1 --states 64 65 66 67 --attenuations 12 13 14 15 beamctl enter-safe 1 --channel 2 ``` -The first number is the receiver-board CAN node. `--channel` selects a local RF channel on -that board. - -## Web dashboard - -`beamd` serves a compact FastAPI/Jinja2/HTMX dashboard with: +## Dashboard -- Raspberry Pi service uptime and configuration; -- SocketCAN interface state; -- discovered receiver-board protocol and health information; and -- recent monitor events. +`beamd` serves FastAPI/Jinja2/HTMX status on port `8080`. It shows service, CAN, node health, protocol, response time, and recent events. `/readyz` returns `503` when CAN or required nodes are unhealthy. -The CAN monitor runs in a background thread inside the same `beamd` process as Uvicorn, so -the dashboard does not create a second CAN client. CAN or receiver failures keep the page -online, mark the system degraded/offline, and cause `/readyz` to return `503`. - -The first version is read-only. It has no phase, attenuation, reboot, or service-control -actions. By default the deployed configuration listens on all interfaces at port `8080`: - -```text -http://beamcontrol-pi.local:8080/ -``` - -Only expose this unauthenticated status page on a trusted network. Set `web_host` to -`127.0.0.1` and use an SSH tunnel when remote network access is not appropriate. - -## Deploy to Raspberry Pi 5 - -Tagged releases contain an offline ARM64 bundle: - -```bash -scp beamcontrol-pi-*.tar.gz @beamcontrol-pi.local:/tmp/ -ssh @beamcontrol-pi.local \ - 'cd /tmp && python3 -m tarfile -e beamcontrol-pi-*.tar.gz . && sudo python3 ./beamcontrol-pi-*/install.py' -``` +The dashboard is read-only and unauthenticated. Bind `web_host = "127.0.0.1"` and use SSH tunneling outside a trusted network. -The installer creates a versioned venv under `/opt/uorocketry/beamcontrol/`, installs the -systemd services, and preserves the existing configuration on upgrades. +## Deploy -See the full [Raspberry Pi 5 deployment guide](../docs/operations/pi-provisioning.md). +Tagged releases contain an offline ARM64 bundle. See [Pi deployment](../docs/operations/pi-provisioning.md). diff --git a/pi/can_setup.py b/pi/can_setup.py index 12538a9..42d3d37 100755 --- a/pi/can_setup.py +++ b/pi/can_setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Bring up a SocketCAN interface for BeamControl protocol v1.1.""" +"""Bring up a SocketCAN interface for BeamControl protocol v2.1.""" from __future__ import annotations diff --git a/pi/src/beamcontrol/__init__.py b/pi/src/beamcontrol/__init__.py index 521a6f6..d5ddb2d 100644 --- a/pi/src/beamcontrol/__init__.py +++ b/pi/src/beamcontrol/__init__.py @@ -1,6 +1,6 @@ """uORocketry BeamControl CAN controller package.""" from . import protocol -from .client import BeamControlClient, BeamControlError, ProtocolInfo +from .client import BeamControlClient, BeamControlError, NodeStatus -__all__ = ["protocol", "BeamControlClient", "BeamControlError", "ProtocolInfo"] +__all__ = ["protocol", "BeamControlClient", "BeamControlError", "NodeStatus"] diff --git a/pi/src/beamcontrol/cli.py b/pi/src/beamcontrol/cli.py index 5cfb579..f37baad 100644 --- a/pi/src/beamcontrol/cli.py +++ b/pi/src/beamcontrol/cli.py @@ -40,15 +40,55 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("node", type=int, help="receiver-board CAN node 1-30") _add_common_args(p) - p = sub.add_parser("set-phase", help="set one receiver-board RF channel phase") + p = sub.add_parser("set-phase", help="set one or all phase states") p.add_argument("node", type=int, help="receiver-board CAN node 1-30") - p.add_argument("--channel", type=int, required=True, help="RF channel 0-3") - p.add_argument("--state", type=int, required=True, help="phase state 0-255") + phase = p.add_mutually_exclusive_group(required=True) + phase.add_argument("--state", type=int, help="one phase-state index 0-255") + phase.add_argument( + "--states", + nargs=4, + type=int, + metavar=("PS1", "PS2", "PS3", "PS4"), + help="four phase-state indexes 0-255", + ) + p.add_argument("--channel", type=int, help="RF channel 0-3 for --state") + _add_common_args(p) + + p = sub.add_parser("set-vga", help="set one or all VGA attenuations") + p.add_argument("node", type=int, help="receiver-board CAN node 1-30") + vga = p.add_mutually_exclusive_group(required=True) + vga.add_argument("--attenuation", type=int, help="one attenuation value 0-23 dB") + vga.add_argument( + "--attenuations", + nargs=4, + type=int, + metavar=("VGA1", "VGA2", "VGA3", "VGA4"), + help="four attenuation values, each 0-23 dB", + ) + p.add_argument("--channel", type=int, help="RF channel 0-3 for --attenuation") _add_common_args(p) - p = sub.add_parser("set-vga", help="set receiver-board DVGA attenuation (dB)") + p = sub.add_parser("set-combined", help="set phase and VGA for one or all channels") p.add_argument("node", type=int, help="receiver-board CAN node 1-30") - p.add_argument("--attenuation", type=int, required=True, help="dB 0-23") + phase = p.add_mutually_exclusive_group(required=True) + phase.add_argument("--state", type=int, help="one phase-state index 0-255") + phase.add_argument( + "--states", + nargs=4, + type=int, + metavar=("PS1", "PS2", "PS3", "PS4"), + help="four phase-state indexes 0-255", + ) + vga = p.add_mutually_exclusive_group(required=True) + vga.add_argument("--attenuation", type=int, help="one attenuation value 0-23 dB") + vga.add_argument( + "--attenuations", + nargs=4, + type=int, + metavar=("VGA1", "VGA2", "VGA3", "VGA4"), + help="four attenuation values, each 0-23 dB", + ) + p.add_argument("--channel", type=int, help="RF channel 0-3 for individual mode") _add_common_args(p) p = sub.add_parser("enter-safe", help="enter safe state on one RF channel") @@ -59,34 +99,75 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _validate_command_arguments(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + if args.command == "set-phase": + if args.states is not None and args.channel is not None: + parser.error("--channel is valid only with --state") + if args.state is not None and args.channel is None: + parser.error("--state requires --channel") + elif args.command == "set-vga": + if args.attenuations is not None and args.channel is not None: + parser.error("--channel is valid only with --attenuation") + if args.attenuation is not None and args.channel is None: + parser.error("--attenuation requires --channel") + elif args.command == "set-combined": + bulk = args.states is not None and args.attenuations is not None + individual = args.state is not None and args.attenuation is not None + if not (bulk or individual): + parser.error("use --state with --attenuation, or --states with --attenuations") + if bulk and args.channel is not None: + parser.error("--channel is valid only for individual mode") + if individual and args.channel is None: + parser.error("individual mode requires --channel") + + def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) + parser = build_parser() + args = parser.parse_args(argv) + _validate_command_arguments(parser, args) c = _client(args) try: if args.command == "discover": for node in range(1, 31): try: - _, info = c.discover(node) - if info is not None: - print( - f"node {node:2d}: v{info.major}.{info.minor}.{info.patch} " - f"flags=0x{info.feature_flags:04x} node_id={info.node_id}" - ) - else: - print(f"node {node:2d}: legacy v1.0") + status = c.discover(node) + print( + f"node {node:2d}: v{status.major}.{status.minor}.{status.patch} " + f"health=0x{status.health_flags:02x} " + f"rx_drop={status.rx_dropped} tx_drop={status.tx_dropped} " + f"invalid={status.invalid_commands}" + ) except BeamControlError: pass elif args.command == "ping": - _, info = c.discover(args.node) - print("PROTOCOL_INFO:", info) + print("STATUS:", c.discover(args.node)) elif args.command == "set-phase": - print("ACK", c.set_phase(args.node, args.state, args.channel).hex()) + if args.states is not None: + reply = c.set_phase(args.node, args.states) + else: + reply = c.set_phase_channel(args.node, args.channel, args.state) + print("ACK", reply.hex()) elif args.command == "set-vga": - print("ACK", c.set_vga(args.node, args.attenuation).hex()) + if args.attenuations is not None: + reply = c.set_vga(args.node, args.attenuations) + else: + reply = c.set_vga_channel(args.node, args.channel, args.attenuation) + print("ACK", reply.hex()) + elif args.command == "set-combined": + if args.states is not None: + reply = c.set_combined(args.node, args.states, args.attenuations) + else: + reply = c.set_combined_channel( + args.node, + args.channel, + args.state, + args.attenuation, + ) + print("ACK", reply.hex()) elif args.command == "enter-safe": print("ACK", c.enter_safe(args.node, args.channel).hex()) return 0 - except BeamControlError as e: + except (BeamControlError, ValueError) as e: print(f"error: {e}", file=sys.stderr) return 1 diff --git a/pi/src/beamcontrol/client.py b/pi/src/beamcontrol/client.py index cf7766a..a2cdc87 100644 --- a/pi/src/beamcontrol/client.py +++ b/pi/src/beamcontrol/client.py @@ -1,9 +1,8 @@ -"""BeamControlClient: drive BeamControl receiver boards over CAN per protocol v1.1. +"""BeamControlClient: drive BeamControl receiver boards over CAN per protocol v2.1. Contract (must hold for the firmware's single-entry replay cache to be safe): - At most one state-changing transaction outstanding per receiver node. -- A retry reuses the EXACT serialized message (same arbitration ID, DLC, and - data bytes) — never rebuild with different zero padding. +- A retry reuses the exact serialized message. - A sequence is never reused for a different command while still in flight. - Responses are matched by destination, source, sequence, and the ACK/ERROR payload's command type. @@ -16,7 +15,7 @@ import threading import time -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any import can @@ -36,20 +35,48 @@ class BeamControlProtocolError(BeamControlError): """A response from the addressed node violated the wire protocol.""" -class ProtocolInfo: - __slots__ = ("major", "minor", "patch", "feature_flags", "node_id") +class NodeStatus: + __slots__ = ( + "major", + "minor", + "patch", + "node_id", + "health_flags", + "rx_dropped", + "tx_dropped", + "invalid_commands", + ) - def __init__(self, major, minor, patch, feature_flags, node_id): + def __init__( + self, + major: int, + minor: int, + patch: int, + node_id: int, + health_flags: int, + rx_dropped: int, + tx_dropped: int, + invalid_commands: int, + ) -> None: self.major = major self.minor = minor self.patch = patch - self.feature_flags = feature_flags self.node_id = node_id + self.health_flags = health_flags + self.rx_dropped = rx_dropped + self.tx_dropped = tx_dropped + self.invalid_commands = invalid_commands + + @property + def version(self) -> tuple[int, int, int]: + return self.major, self.minor, self.patch - def __repr__(self): + def __repr__(self) -> str: return ( - f"ProtocolInfo(major={self.major}, minor={self.minor}, " - f"patch={self.patch}, flags=0x{self.feature_flags:04x}, node={self.node_id})" + f"NodeStatus(version={self.major}.{self.minor}.{self.patch}, " + f"node={self.node_id}, health=0x{self.health_flags:02x}, " + f"rx_dropped={self.rx_dropped}, tx_dropped={self.tx_dropped}, " + f"invalid={self.invalid_commands})" ) @@ -89,8 +116,6 @@ def socketcan(cls, channel: str = "can0", **kwargs: Any) -> BeamControlClient: # -- helpers --------------------------------------------------------------- def _next_seq(self) -> int: self._seq = (self._seq + 1) & P.SEQ_MASK - if self._seq == P.SEQ_CAPABILITIES: # never use the discovery seq for config - self._seq = 0 return self._seq def _make_msg(self, msg_type: int, destination: int, payload) -> tuple[can.Message, int]: @@ -166,26 +191,51 @@ def _transact_locked(self, destination: int, msg_type: int, payload) -> bytes: raise BeamControlError(f"timeout node {destination}; final state unknown") def enter_safe(self, destination: int, channel: int) -> bytes: - P.validate_payload(channel) + P.validate_channel(channel) return self._transact(destination, P.ENTER_SAFE, [channel]) - def set_phase(self, destination: int, phase_state: int, channel: int) -> bytes: - P.validate_payload(channel, phase_state=phase_state) + def set_phase(self, destination: int, phase_states: Sequence[int]) -> bytes: + return self._transact(destination, P.SET_PHASE, P.validate_phase_states(phase_states)) + + def set_phase_channel(self, destination: int, channel: int, phase_state: int) -> bytes: + P.validate_channel(channel) + P.validate_phase_state(phase_state) return self._transact(destination, P.SET_PHASE, [phase_state, channel]) - def set_vga(self, destination: int, atten_db: int) -> bytes: - P.validate_payload(0, atten_db=atten_db) - return self._transact(destination, P.SET_VGA, [atten_db]) + def set_vga(self, destination: int, attenuation_db: Sequence[int]) -> bytes: + return self._transact(destination, P.SET_VGA, P.validate_attenuations(attenuation_db)) + + def set_vga_channel(self, destination: int, channel: int, attenuation_db: int) -> bytes: + P.validate_channel(channel) + P.validate_attenuation(attenuation_db) + return self._transact(destination, P.SET_VGA, [attenuation_db, channel]) def set_combined( - self, destination: int, phase_state: int, channel: int, atten_db: int + self, destination: int, phase_states: Sequence[int], attenuation_db: Sequence[int] + ) -> bytes: + phase_payload = P.validate_phase_states(phase_states) + vga_payload = P.validate_attenuations(attenuation_db) + return self._transact(destination, P.SET_COMBINED, phase_payload + vga_payload) + + def set_combined_channel( + self, + destination: int, + channel: int, + phase_state: int, + attenuation_db: int, ) -> bytes: - P.validate_payload(channel, phase_state=phase_state, atten_db=atten_db) - return self._transact(destination, P.SET_COMBINED, [phase_state, channel, atten_db]) + P.validate_channel(channel) + P.validate_phase_state(phase_state) + P.validate_attenuation(attenuation_db) + return self._transact( + destination, + P.SET_COMBINED, + [phase_state, channel, attenuation_db], + ) def broadcast_enter_safe(self, channel: int) -> None: """Broadcast ENTER_SAFE to all nodes (dest 31). No response is sent.""" - P.validate_payload(channel) + P.validate_channel(channel) with self._io_lock: message = can.Message( arbitration_id=P.build_id( @@ -198,58 +248,46 @@ def broadcast_enter_safe(self, channel: int) -> None: self._transport.send(message) # -- discovery / status ----------------------------------------------------- - def discover(self, destination: int) -> tuple[bytes, ProtocolInfo | None]: + def discover(self, destination: int) -> NodeStatus: self._validate_unicast_destination(destination) with self._io_lock: return self._discover_locked(destination) - def _discover_locked(self, destination: int) -> tuple[bytes, ProtocolInfo | None]: - """PING sequence 0xFFFF. Returns (legacy_status_bytes, ProtocolInfo|None). - - The node answers with the legacy STATUS frame first, then a - PROTOCOL_INFO STATUS frame. A v1.0 node sends only the legacy STATUS. - """ + def _discover_locked(self, destination: int) -> NodeStatus: + sequence = self._next_seq() msg = can.Message( - arbitration_id=P.build_id(P.PING, destination, self._source, P.SEQ_CAPABILITIES), + arbitration_id=P.build_id(P.PING, destination, self._source, sequence), is_extended_id=True, is_remote_frame=False, data=bytearray(), ) self._transport.send(msg) deadline = self._clock() + self._timeout - legacy = None - info = None while self._clock() < deadline: reply = self._transport.recv(timeout=max(0.0, deadline - self._clock())) - if reply is None or not reply.is_extended_id: + if reply is None or not reply.is_extended_id or reply.is_remote_frame: continue f = P.parse_id(reply.arbitration_id) if ( f["source"] != destination or f["dest"] != self._source or f["type"] != P.STATUS - or f["sequence"] != P.SEQ_CAPABILITIES + or f["sequence"] != sequence ): continue d = bytes(reply.data) if len(d) != 8: raise BeamControlProtocolError("STATUS must have DLC 8") - if d[0] == P.STATUS_SUBTYPE_PROTOCOL_INFO: - # node id must match, reserved byte must be zero - if d[6] != destination or d[7] != 0: - continue - info = ProtocolInfo(d[1], d[2], d[3], (d[5] << 8) | d[4], d[6]) - elif legacy is None: - # legacy STATUS: byte0 = supported major version, byte1 = node id - if d[0] != 1 or d[1] != destination: - continue - legacy = d - if legacy is not None and info is not None: - if info.major != legacy[0]: - raise BeamControlProtocolError( - "legacy STATUS and PROTOCOL_INFO major versions disagree" - ) - break - if legacy is None: - raise BeamControlError(f"no STATUS from node {destination}") - return legacy, info + if d[3] != destination: + continue + return NodeStatus( + d[0], + d[1], + d[2], + d[3], + d[4], + d[5], + d[6], + d[7], + ) + raise BeamControlError(f"no STATUS from node {destination}") diff --git a/pi/src/beamcontrol/monitor.py b/pi/src/beamcontrol/monitor.py index b714498..8ed606a 100644 --- a/pi/src/beamcontrol/monitor.py +++ b/pi/src/beamcontrol/monitor.py @@ -17,7 +17,7 @@ import can from . import protocol as P -from .client import BeamControlClient, BeamControlError, BeamControlProtocolError, ProtocolInfo +from .client import BeamControlClient, BeamControlError, BeamControlProtocolError, NodeStatus from .config import BeamControlConfig from .transport import SocketCanTransport @@ -25,7 +25,7 @@ class ClientLike(Protocol): - def discover(self, destination: int) -> tuple[bytes, ProtocolInfo | None]: ... + def discover(self, destination: int) -> NodeStatus: ... ClientFactory = Callable[[BeamControlConfig], tuple[ClientLike, Callable[[], None]]] @@ -38,7 +38,6 @@ class NodeRecord: node_id: int health: str = "offline" protocol_version: str | None = None - feature_flags: int | None = None response_ms: float | None = None last_seen: datetime | None = None error: str | None = None @@ -51,19 +50,13 @@ class EventRecord: message: str -def configured_node_failure(node: int, info: ProtocolInfo | None) -> str | None: +def configured_node_failure(node: int, status: NodeStatus) -> str | None: """Return why a receiver node fails the production protocol gate.""" - if info is None: - return f"receiver node {node} does not support protocol v1.1" - if info.major != 1 or info.minor < 1: + if status.version != P.PROTOCOL_VERSION: return ( f"receiver node {node} has incompatible protocol version " - f"{info.major}.{info.minor}.{info.patch}" + f"{status.major}.{status.minor}.{status.patch}" ) - required = P.FEATURE_FLAGS - if info.feature_flags & required != required: - missing = required & ~info.feature_flags - return f"receiver node {node} is missing required features 0x{missing:04x}" return None @@ -226,7 +219,7 @@ def poll_once(self) -> None: break request_started = self._clock() try: - _, info = self._client.discover(node) + status = self._client.discover(node) except BeamControlProtocolError as error: self._mark_node_failure(node, f"protocol error: {error}") except BeamControlError as error: @@ -245,23 +238,20 @@ def poll_once(self) -> None: log.exception("unexpected receiver polling failure") else: response_ms = (self._clock() - request_started) * 1000.0 - self._mark_node_success(node, info, response_ms) + self._mark_node_success(node, status, response_ms) self._finish_cycle(cycle_started) - def _mark_node_success(self, node: int, info: ProtocolInfo | None, response_ms: float) -> None: - failure = configured_node_failure(node, info) + def _mark_node_success(self, node: int, status: NodeStatus, response_ms: float) -> None: + failure = configured_node_failure(node, status) health = "healthy" if failure is None else "degraded" - protocol_version = ( - "1.0 legacy" if info is None else f"{info.major}.{info.minor}.{info.patch}" - ) + protocol_version = f"{status.major}.{status.minor}.{status.patch}" now = self._wall_clock() with self._lock: record = self._nodes.setdefault(node, NodeRecord(node)) previous_health = record.health record.health = health record.protocol_version = protocol_version - record.feature_flags = None if info is None else info.feature_flags record.response_ms = response_ms record.last_seen = now record.error = failure @@ -303,9 +293,6 @@ def snapshot(self) -> dict[str, object]: "node_id": record.node_id, "health": record.health, "protocol_version": record.protocol_version or "—", - "feature_flags": ( - "—" if record.feature_flags is None else f"0x{record.feature_flags:04x}" - ), "response_ms": ( None if record.response_ms is None else round(record.response_ms, 1) ), diff --git a/pi/src/beamcontrol/protocol.py b/pi/src/beamcontrol/protocol.py index 03814a5..ae9f738 100644 --- a/pi/src/beamcontrol/protocol.py +++ b/pi/src/beamcontrol/protocol.py @@ -1,10 +1,14 @@ -"""BeamControl CAN protocol v1.1 constants and helpers (29-bit extended @ 500 kbit/s). +"""BeamControl CAN protocol v2.1 constants and helpers (29-bit extended @ 500 kbit/s). See docs/can-protocol.md for the full spec. """ from __future__ import annotations +from collections.abc import Sequence + +PROTOCOL_VERSION = (2, 1, 0) + # --- Identifier bit fields (29-bit extended) --- TYPE_SHIFT = 26 DEST_SHIFT = 21 @@ -36,40 +40,14 @@ RES_UNSUPPORTED = 3 RES_HW_FAIL = 4 RES_BUSY = 5 -RES_RESERVED_BYTES = 6 -RES_SEQUENCE_REUSE = 7 -RES_BROADCAST_NOT_ALLOWED = 8 - -# --- Capability-discovery feature bits (mirror firmware CAN_PROTOCOL_FEATURE_*) --- -FEATURE_STRICT_CHANNELS = 1 << 0 -FEATURE_ENTER_SAFE_ONLY_BROADCAST = 1 << 1 -FEATURE_RESERVED_ZERO_VALIDATION = 1 << 2 -FEATURE_DUPLICATE_REPLAY = 1 << 3 -FEATURE_PROTOCOL_INFO = 1 << 4 -FEATURE_SAFE_TRANSITIONS = 1 << 5 -FEATURE_TERMINAL_RESPONSE = 1 << 6 -FEATURE_STATUS_SUBTYPES = 1 << 7 -FEATURE_FLAGS = ( - FEATURE_STRICT_CHANNELS - | FEATURE_ENTER_SAFE_ONLY_BROADCAST - | FEATURE_RESERVED_ZERO_VALIDATION - | FEATURE_DUPLICATE_REPLAY - | FEATURE_PROTOCOL_INFO - | FEATURE_SAFE_TRANSITIONS - | FEATURE_TERMINAL_RESPONSE - | FEATURE_STATUS_SUBTYPES -) - -# --- Status subtypes / health flags --- -STATUS_SUBTYPE_PROTOCOL_INFO = 0xF0 - -# --- Capability discovery --- -SEQ_CAPABILITIES = 0xFFFF +RES_SEQUENCE_REUSE = 6 +RES_BROADCAST_NOT_ALLOWED = 7 # Field limits -PHASE_STATE_MAX = 255 -PHASE_ADDR_MAX = 3 # BeamControl has 4 channels (0-3) -ATTEN_DB_MAX = 23 +PHASE_STATE_MAX = 255 # logical index into the receiver's calibrated phase lookup +RF_CHANNEL_COUNT = 4 +PHASE_ADDR_MAX = RF_CHANNEL_COUNT - 1 +ATTEN_DB_MAX = 23 # F0480 supports every 1 dB step from 0 through 23 dB def build_id(msg_type: int, destination: int, source: int, sequence: int) -> int: @@ -94,10 +72,45 @@ def parse_id(can_id: int) -> dict: } -def validate_payload(channel: int, phase_state: int = 0, atten_db: int = 0) -> None: - if not (0 <= channel <= PHASE_ADDR_MAX): +def validate_channel(channel: int) -> None: + if ( + not isinstance(channel, int) + or isinstance(channel, bool) + or not (0 <= channel <= PHASE_ADDR_MAX) + ): raise ValueError(f"channel must be 0..{PHASE_ADDR_MAX}") - if not (0 <= phase_state <= PHASE_STATE_MAX): - raise ValueError(f"phase_state must be 0..{PHASE_STATE_MAX}") - if not (0 <= atten_db <= ATTEN_DB_MAX): - raise ValueError(f"atten_db must be 0..{ATTEN_DB_MAX}") + + +def _validated_four(name: str, values: Sequence[int], maximum: int) -> bytes: + try: + items = list(values) + except TypeError as exc: + raise ValueError(f"{name} must contain exactly {RF_CHANNEL_COUNT} values") from exc + if len(items) != RF_CHANNEL_COUNT: + raise ValueError(f"{name} must contain exactly {RF_CHANNEL_COUNT} values") + for value in items: + if not isinstance(value, int) or isinstance(value, bool) or not (0 <= value <= maximum): + raise ValueError(f"each {name} value must be 0..{maximum}") + return bytes(items) + + +def validate_phase_states(phase_states: Sequence[int]) -> bytes: + return _validated_four("phase_states", phase_states, PHASE_STATE_MAX) + + +def validate_attenuations(attenuation_db: Sequence[int]) -> bytes: + return _validated_four("attenuation_db", attenuation_db, ATTEN_DB_MAX) + + +def _validated_value(name: str, value: int, maximum: int) -> int: + if not isinstance(value, int) or isinstance(value, bool) or not (0 <= value <= maximum): + raise ValueError(f"{name} must be 0..{maximum}") + return value + + +def validate_phase_state(phase_state: int) -> int: + return _validated_value("phase_state", phase_state, PHASE_STATE_MAX) + + +def validate_attenuation(attenuation_db: int) -> int: + return _validated_value("attenuation_db", attenuation_db, ATTEN_DB_MAX) diff --git a/pi/src/beamcontrol/web/templates/fragments/dashboard.html b/pi/src/beamcontrol/web/templates/fragments/dashboard.html index 94f3443..af50f93 100644 --- a/pi/src/beamcontrol/web/templates/fragments/dashboard.html +++ b/pi/src/beamcontrol/web/templates/fragments/dashboard.html @@ -59,7 +59,6 @@
Protocol
{{ node.protocol_version }}
-
Features
{{ node.feature_flags }}
Response
{{ node.response_ms ~ " ms" if node.response_ms is not none else "—" }}
Last seen
{{ node.last_seen }}
diff --git a/pi/tests/contract/test_protocol_vectors.py b/pi/tests/contract/test_protocol_vectors.py index 0995f65..db9035a 100644 --- a/pi/tests/contract/test_protocol_vectors.py +++ b/pi/tests/contract/test_protocol_vectors.py @@ -1,7 +1,4 @@ -"""Contract test: the Python protocol implementation must agree with the -shared protocol vectors (protocol/v1.1-vectors.toml). The C firmware consumes -the same vectors via the generated header, so this catches Python<->C drift. -""" +"""Contract test: Python and C must agree with protocol/v2.1-vectors.toml.""" from __future__ import annotations @@ -12,7 +9,7 @@ from beamcontrol import protocol as P -VECTORS = Path(__file__).resolve().parents[3] / "protocol" / "v1.1-vectors.toml" +VECTORS = Path(__file__).resolve().parents[3] / "protocol" / "v2.1-vectors.toml" DATA = tomllib.loads(VECTORS.read_text(encoding="utf-8")) @@ -31,25 +28,45 @@ def test_id_vector_decodes(v): assert f["sequence"] == v["sequence"] +def _validate_command(msg_type: int, payload: list[int]) -> None: + if msg_type == P.SET_PHASE: + if len(payload) == 2: + P.validate_phase_state(payload[0]) + P.validate_channel(payload[1]) + elif len(payload) == 4: + P.validate_phase_states(payload) + else: + raise ValueError("SET_PHASE requires DLC 2 or 4") + elif msg_type == P.SET_VGA: + if len(payload) == 2: + P.validate_attenuation(payload[0]) + P.validate_channel(payload[1]) + elif len(payload) == 4: + P.validate_attenuations(payload) + else: + raise ValueError("SET_VGA requires DLC 2 or 4") + elif msg_type == P.SET_COMBINED: + if len(payload) == 3: + P.validate_phase_state(payload[0]) + P.validate_channel(payload[1]) + P.validate_attenuation(payload[2]) + elif len(payload) == 8: + P.validate_phase_states(payload[:4]) + P.validate_attenuations(payload[4:]) + else: + raise ValueError("SET_COMBINED requires DLC 3 or 8") + elif msg_type == P.ENTER_SAFE: + if len(payload) != 1: + raise ValueError("ENTER_SAFE requires DLC 1") + P.validate_channel(payload[0]) + + @pytest.mark.parametrize("v", DATA["commands"], ids=lambda v: v["name"]) def test_command_validation(v): payload = list(v["data"]) if v["valid"]: - # Valid command: payload-specific validation must accept it. - if v["type"] == P.SET_PHASE: - P.validate_payload(payload[1], phase_state=payload[0]) - elif v["type"] == P.SET_VGA: - P.validate_payload(0, atten_db=payload[0]) - elif v["type"] == P.ENTER_SAFE: - P.validate_payload(payload[0]) - else: - # Invalid command: expect validation to reject channel/attenuation. - # Reserved-byte rejection (result 6) is a decode-layer check, not - # covered by validate_payload here. - if v["result"] in (2,): # INVALID_PAYLOAD - if v["type"] == P.SET_PHASE: - with pytest.raises(ValueError): - P.validate_payload(payload[1], phase_state=payload[0]) - elif v["type"] == P.SET_VGA: - with pytest.raises(ValueError): - P.validate_payload(0, atten_db=payload[0]) + _validate_command(v["type"], payload) + return + + with pytest.raises(ValueError): + _validate_command(v["type"], payload) diff --git a/pi/tests/integration/fake_beamcontrol_node.py b/pi/tests/integration/fake_beamcontrol_node.py index 7675c4c..b2eb5ef 100644 --- a/pi/tests/integration/fake_beamcontrol_node.py +++ b/pi/tests/integration/fake_beamcontrol_node.py @@ -1,9 +1,7 @@ -"""A fake BeamControl node that listens on a CAN bus and replies per protocol v1.1. +"""A fake BeamControl node that listens on a CAN bus and replies per protocol v2.1. Used for virtual-bus integration tests (python-can `virtual` interface). It -implements just enough of the wire protocol to exercise the client: SET_* -commands get an ACK, invalid ones an ERROR, and PING@0xFFFF gets a legacy -STATUS followed by PROTOCOL_INFO. +implements enough of the wire protocol to exercise commands and STATUS. """ from __future__ import annotations @@ -41,29 +39,39 @@ def service(self, count: int = 10) -> int: f = P.parse_id(msg.arbitration_id) data = bytes(msg.data) if f["type"] == P.PING: + major, minor, patch = P.PROTOCOL_VERSION self._reply( P.STATUS, f["source"], f["sequence"], - bytes([0x01, self.node_id, 0, 0, 23, 0, 0, 0]), + bytes( + [ + major, + minor, + patch, + self.node_id, + 0, + 0, + 0, + 0, + ] + ), ) - if f["sequence"] == P.SEQ_CAPABILITIES: - self._reply( - P.STATUS, - f["source"], - f["sequence"], - bytes( - [P.STATUS_SUBTYPE_PROTOCOL_INFO, 1, 1, 0, 0xFF, 0x00, self.node_id, 0] - ), - ) elif f["type"] in (P.SET_PHASE, P.SET_VGA, P.SET_COMBINED, P.ENTER_SAFE): - ok = True - if f["type"] in (P.SET_PHASE, P.ENTER_SAFE): - ok = len(data) > 1 and data[1] <= 3 + if f["type"] == P.SET_PHASE: + ok = len(data) == 4 or (len(data) == 2 and data[1] <= P.PHASE_ADDR_MAX) elif f["type"] == P.SET_VGA: - ok = bool(data) and data[0] <= 23 - else: # SET_COMBINED - ok = len(data) > 2 and data[1] <= 3 and data[2] <= 23 + ok = (len(data) == 4 and all(value <= P.ATTEN_DB_MAX for value in data)) or ( + len(data) == 2 and data[0] <= P.ATTEN_DB_MAX and data[1] <= P.PHASE_ADDR_MAX + ) + elif f["type"] == P.SET_COMBINED: + ok = ( + len(data) == 8 and all(value <= P.ATTEN_DB_MAX for value in data[4:]) + ) or ( + len(data) == 3 and data[1] <= P.PHASE_ADDR_MAX and data[2] <= P.ATTEN_DB_MAX + ) + else: + ok = len(data) == 1 and data[0] <= P.PHASE_ADDR_MAX if ok: self._reply(P.ACK, f["source"], f["sequence"], bytes([f["type"], P.RES_OK])) else: diff --git a/pi/tests/integration/test_virtual_can.py b/pi/tests/integration/test_virtual_can.py index 2c834d6..5b924de 100644 --- a/pi/tests/integration/test_virtual_can.py +++ b/pi/tests/integration/test_virtual_can.py @@ -68,40 +68,57 @@ def test_set_phase_round_trip(buses): driver.start() try: client = BeamControlClient(VirtualTransport(ctrl), timeout=0.5, retries=1) - out = client.set_phase(3, 128, 2) + phases = [128, 64, 32, 16] + out = client.set_phase(3, phases) assert out == bytes([P.SET_PHASE, P.RES_OK]) f = P.parse_id(fake.seen[0].arbitration_id) assert f["type"] == P.SET_PHASE assert f["dest"] == 3 and f["source"] == 0 - assert bytes(fake.seen[0].data) == bytes([128, 2]) + assert bytes(fake.seen[0].data) == bytes(phases) finally: driver.stop() -def test_invalid_channel_rejected_client_side(buses): - """Channel 4 is rejected by the client's strict validation before any CAN traffic.""" +def test_individual_round_trips(buses): + ctrl, node = buses + fake = FakeBeamControlNode(node, 3) + driver = NodeDriver(fake) + driver.start() + try: + client = BeamControlClient(VirtualTransport(ctrl), timeout=0.5, retries=0) + assert client.set_phase_channel(3, 2, 128) == bytes([P.SET_PHASE, P.RES_OK]) + assert client.set_vga_channel(3, 1, 8) == bytes([P.SET_VGA, P.RES_OK]) + assert client.set_combined_channel(3, 3, 64, 12) == bytes([P.SET_COMBINED, P.RES_OK]) + assert [bytes(message.data) for message in fake.seen[:3]] == [ + bytes([128, 2]), + bytes([8, 1]), + bytes([64, 3, 12]), + ] + finally: + driver.stop() + + +def test_invalid_bulk_payload_rejected_client_side(buses): + """Invalid bulk lengths are rejected before any CAN traffic.""" ctrl, node = buses driver = NodeDriver(FakeBeamControlNode(node, 3)) driver.start() try: client = BeamControlClient(VirtualTransport(ctrl), timeout=0.5, retries=0) with pytest.raises(ValueError): - client.set_phase(3, 128, 4) + client.set_phase(3, [128, 64, 32]) finally: driver.stop() -def test_discover_returns_protocol_info(buses): +def test_discover_returns_status(buses): ctrl, node = buses driver = NodeDriver(FakeBeamControlNode(node, 3)) driver.start() try: client = BeamControlClient(VirtualTransport(ctrl), timeout=0.5, retries=0) - legacy, info = client.discover(3) - assert info is not None - assert info.major == 1 and info.minor == 1 - assert info.node_id == 3 - assert info.feature_flags == 0xFF - assert legacy[0] == 0x01 + status = client.discover(3) + assert status.version == P.PROTOCOL_VERSION + assert status.node_id == 3 finally: driver.stop() diff --git a/pi/tests/unit/test_cli.py b/pi/tests/unit/test_cli.py new file mode 100644 index 0000000..babc8db --- /dev/null +++ b/pi/tests/unit/test_cli.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest + +from beamcontrol import cli + + +class FakeClient: + def __init__(self) -> None: + self.calls: list[tuple] = [] + + def set_phase(self, node, states): + self.calls.append(("set_phase", node, states)) + return b"\x02\x00" + + def set_phase_channel(self, node, channel, state): + self.calls.append(("set_phase_channel", node, channel, state)) + return b"\x02\x00" + + def set_vga(self, node, attenuations): + self.calls.append(("set_vga", node, attenuations)) + return b"\x03\x00" + + def set_vga_channel(self, node, channel, attenuation): + self.calls.append(("set_vga_channel", node, channel, attenuation)) + return b"\x03\x00" + + def set_combined(self, node, states, attenuations): + self.calls.append(("set_combined", node, states, attenuations)) + return b"\x01\x00" + + def set_combined_channel(self, node, channel, state, attenuation): + self.calls.append(("set_combined_channel", node, channel, state, attenuation)) + return b"\x01\x00" + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + (["set-phase", "1", "--state", "128", "--channel", "2"], ("set_phase_channel", 1, 2, 128)), + (["set-phase", "1", "--states", "1", "2", "3", "4"], ("set_phase", 1, [1, 2, 3, 4])), + (["set-vga", "1", "--attenuation", "8", "--channel", "1"], ("set_vga_channel", 1, 1, 8)), + (["set-vga", "1", "--attenuations", "1", "2", "3", "4"], ("set_vga", 1, [1, 2, 3, 4])), + ( + ["set-combined", "1", "--state", "64", "--attenuation", "12", "--channel", "3"], + ("set_combined_channel", 1, 3, 64, 12), + ), + ( + [ + "set-combined", + "1", + "--states", + "1", + "2", + "3", + "4", + "--attenuations", + "5", + "6", + "7", + "8", + ], + ("set_combined", 1, [1, 2, 3, 4], [5, 6, 7, 8]), + ), + ], +) +def test_rf_cli_modes(monkeypatch, argv, expected): + client = FakeClient() + monkeypatch.setattr(cli, "_client", lambda _args: client) + assert cli.main(argv) == 0 + assert client.calls == [expected] + + +@pytest.mark.parametrize( + "argv", + [ + ["set-phase", "1", "--state", "128"], + ["set-phase", "1", "--states", "1", "2", "3", "4", "--channel", "1"], + ["set-combined", "1", "--state", "64", "--attenuations", "1", "2", "3", "4"], + ], +) +def test_rf_cli_rejects_mixed_or_incomplete_modes(monkeypatch, argv): + monkeypatch.setattr( + cli, "_client", lambda _args: pytest.fail("client opened before validation") + ) + with pytest.raises(SystemExit): + cli.main(argv) diff --git a/pi/tests/unit/test_client.py b/pi/tests/unit/test_client.py index 7472f99..b9e49f6 100644 --- a/pi/tests/unit/test_client.py +++ b/pi/tests/unit/test_client.py @@ -9,6 +9,9 @@ from .fake_transport import FakeTransport +PHASES = [128, 64, 32, 16] +ATTENUATIONS = [0, 8, 12, 23] + class FakeClock: def __init__(self) -> None: @@ -39,6 +42,23 @@ def _error(node: int, seq: int, cmd_type: int, result: int) -> _Reply: return _Reply(P.build_id(P.ERROR, 0, node, seq), bytes([cmd_type, result])) +def _status( + node: int, + seq: int = 1, + *, + version: tuple[int, int, int] = P.PROTOCOL_VERSION, + health: int = 0, + rx_dropped: int = 0, + tx_dropped: int = 0, + invalid: int = 0, +) -> _Reply: + major, minor, patch = version + return _Reply( + P.build_id(P.STATUS, 0, node, seq), + bytes([major, minor, patch, node, health, rx_dropped, tx_dropped, invalid]), + ) + + def _make(replies, retries=2, timeout=0.02): clock = FakeClock() transport = FakeTransport(replies, clock=clock, advance=clock.advance) @@ -56,7 +76,7 @@ def test_exact_retry_reuses_same_message(): """On timeout, the retry resends the identical arbitration ID + bytes.""" client, _, transport = _make([None], retries=2) with pytest.raises(BeamControlError): - client.set_phase(3, 128, 2) + client.set_phase(3, PHASES) sent = transport.sent assert len(sent) == 3 # 1 initial + 2 retries ids = {m.arbitration_id for m in sent} @@ -67,7 +87,7 @@ def test_exact_retry_reuses_same_message(): def test_ack_matching(): client, _, _ = _make([_ack(3, 42, P.SET_PHASE)]) client._seq = 41 # force next seq to 42 - out = client.set_phase(3, 128, 2) + out = client.set_phase(3, PHASES) assert out == bytes([P.SET_PHASE, P.RES_OK]) @@ -77,79 +97,90 @@ def test_wrong_node_rejected(): right = _ack(3, 42, P.SET_PHASE) client, _, _ = _make([wrong, right]) client._seq = 41 - assert client.set_phase(3, 128, 2) == bytes([P.SET_PHASE, P.RES_OK]) + assert client.set_phase(3, PHASES) == bytes([P.SET_PHASE, P.RES_OK]) def test_error_raises(): client, _, _ = _make([_error(3, 42, P.SET_PHASE, P.RES_BAD_VALUE)]) client._seq = 41 with pytest.raises(BeamControlError) as exc: - client.set_phase(3, 128, 2) + client.set_phase(3, PHASES) assert exc.value.result == P.RES_BAD_VALUE def test_timeout_is_unknown_state(): client, _, _ = _make([None], retries=0) with pytest.raises(BeamControlError) as exc: - client.set_vga(3, 23) + client.set_vga(3, ATTENUATIONS) assert "final state unknown" in str(exc.value) -def test_sequence_ffff_reserved(): - """The discovery seq 0xFFFF is never used for a config transaction.""" - client, _, transport = _make([None], retries=0) - with pytest.raises(BeamControlError): - client.enter_safe(3, 0) - sent = transport.sent - assert all(P.parse_id(m.arbitration_id)["sequence"] != P.SEQ_CAPABILITIES for m in sent) +def test_sequence_ffff_is_normal(): + client, _, transport = _make([_ack(3, 0xFFFF, P.ENTER_SAFE)], retries=0) + client._seq = 0xFFFE + client.enter_safe(3, 0) + assert P.parse_id(transport.sent[0].arbitration_id)["sequence"] == 0xFFFF -def test_strict_channel_validation(): +def test_bulk_payload_validation(): client, _, _ = _make([None], retries=0) with pytest.raises(ValueError): - client.set_phase(3, 128, 4) # channel out of 0..3 + client.set_phase(3, [1, 2, 3]) + with pytest.raises(ValueError): + client.set_phase(3, [1, 2, 3, 256]) with pytest.raises(ValueError): - client.set_vga(3, 24) # attenuation out of 0..23 + client.set_vga(3, [0, 8, 24, 23]) + with pytest.raises(ValueError): + client.set_combined(3, PHASES, [0, 1, 2]) -def test_discovery_legacy_only(): - legacy = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), bytes([0x01, 3, 0, 0, 23, 0, 0, 0]) - ) - client, _, _ = _make([legacy]) - legacy_data, info = client.discover(3) - assert info is None - assert legacy_data[0] == 0x01 +@pytest.mark.parametrize( + ("method", "args", "command_type", "payload"), + [ + ("set_phase_channel", (2, 128), P.SET_PHASE, bytes([128, 2])), + ("set_vga_channel", (1, 8), P.SET_VGA, bytes([8, 1])), + ("set_combined_channel", (3, 64, 12), P.SET_COMBINED, bytes([64, 3, 12])), + ], +) +def test_individual_methods_send_exact_payload(method, args, command_type, payload): + client, _, transport = _make([_ack(3, 1, command_type)], retries=0) + result = getattr(client, method)(3, *args) + assert result == bytes([command_type, P.RES_OK]) + assert bytes(transport.sent[0].data) == payload -def test_discovery_with_protocol_info(): - legacy = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), bytes([0x01, 3, 0, 0, 23, 0, 0, 0]) - ) - info = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), - bytes([P.STATUS_SUBTYPE_PROTOCOL_INFO, 1, 1, 0, 0xFF, 0x00, 3, 0]), - ) - client, _, _ = _make([legacy, info]) - _, pi = client.discover(3) - assert pi is not None - assert pi.major == 1 and pi.minor == 1 - assert pi.node_id == 3 - assert pi.feature_flags == 0xFF +def test_individual_method_validation(): + client, _, _ = _make([], retries=0) + with pytest.raises(ValueError): + client.set_phase_channel(3, 4, 128) + with pytest.raises(ValueError): + client.set_phase_channel(3, 2, 256) + with pytest.raises(ValueError): + client.set_vga_channel(3, 2, 24) + with pytest.raises(ValueError): + client.set_combined_channel(3, 2, 64, 24) + + +def test_discovery_returns_current_status(): + client, _, _ = _make([_status(3, health=0x12, rx_dropped=2, tx_dropped=3, invalid=7)]) + status = client.discover(3) + assert status.version == P.PROTOCOL_VERSION + assert status.node_id == 3 + assert status.health_flags == 0x12 + assert status.rx_dropped == 2 + assert status.tx_dropped == 3 + assert status.invalid_commands == 7 def test_discovery_sends_ping(): - """discover() must transmit the PING@0xFFFF request, not just listen.""" - legacy = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), bytes([0x01, 3, 0, 0, 23, 0, 0, 0]) - ) - client, _, transport = _make([legacy]) + client, _, transport = _make([_status(3)]) client.discover(3) assert len(transport.sent) == 1 f = P.parse_id(transport.sent[0].arbitration_id) assert f["type"] == P.PING - assert f["sequence"] == P.SEQ_CAPABILITIES + assert f["sequence"] == 1 assert f["dest"] == 3 + assert bytes(transport.sent[0].data) == b"" # --- malformed / mismatched response handling ------------------------------ @@ -161,7 +192,7 @@ def test_ack_wrong_command_type_raises(): client, _, _ = _make([wrong]) client._seq = 41 with pytest.raises(BeamControlProtocolError): - client.set_phase(3, 128, 2) + client.set_phase(3, PHASES) def test_ack_bad_dlc_raises(): @@ -170,7 +201,7 @@ def test_ack_bad_dlc_raises(): client, _, _ = _make([wrong]) client._seq = 41 with pytest.raises(BeamControlProtocolError): - client.set_phase(3, 128, 2) + client.set_phase(3, PHASES) def test_error_wrong_command_type_raises(): @@ -178,22 +209,18 @@ def test_error_wrong_command_type_raises(): client, _, _ = _make([wrong]) client._seq = 41 with pytest.raises(BeamControlProtocolError): - client.set_phase(3, 128, 2) + client.set_phase(3, PHASES) def test_discovery_wrong_sequence_ignored(): - wrong = _Reply(P.build_id(P.STATUS, 0, 3, 7), bytes([0x01, 3, 0, 0, 23, 0, 0, 0])) - client, _, _ = _make([wrong], retries=0) + client, _, _ = _make([_status(3, seq=7)], retries=0) with pytest.raises(BeamControlError) as exc: client.discover(3) assert "no STATUS" in str(exc.value) -def test_discovery_short_protocol_info_raises(): - short = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), - bytes([P.STATUS_SUBTYPE_PROTOCOL_INFO, 1, 1]), - ) +def test_discovery_short_status_raises(): + short = _Reply(P.build_id(P.STATUS, 0, 3, 1), bytes([2, 1, 0])) client, _, _ = _make([short], retries=0) with pytest.raises(BeamControlProtocolError): client.discover(3) @@ -201,19 +228,8 @@ def test_discovery_short_protocol_info_raises(): def test_discovery_mismatched_node_id_ignored(): bad = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), - bytes([P.STATUS_SUBTYPE_PROTOCOL_INFO, 1, 1, 0, 0xFF, 0x00, 7, 0]), # node 7 != 3 - ) - client, _, _ = _make([bad], retries=0) - with pytest.raises(BeamControlError) as exc: - client.discover(3) - assert "no STATUS" in str(exc.value) - - -def test_discovery_nonzero_reserved_ignored(): - bad = _Reply( - P.build_id(P.STATUS, 0, 3, P.SEQ_CAPABILITIES), - bytes([P.STATUS_SUBTYPE_PROTOCOL_INFO, 1, 1, 0, 0xFF, 0x00, 3, 1]), # byte7 != 0 + P.build_id(P.STATUS, 0, 3, 1), + bytes([2, 1, 0, 7, 0, 0, 0, 0]), ) client, _, _ = _make([bad], retries=0) with pytest.raises(BeamControlError) as exc: @@ -230,7 +246,7 @@ def test_unicast_destination_enforced(): with pytest.raises(ValueError): client.enter_safe(bad, 0) with pytest.raises(ValueError): - client.set_phase(bad, 128, 2) + client.set_phase(bad, PHASES) with pytest.raises(ValueError): client.discover(bad) @@ -268,7 +284,7 @@ def test_sequence_reuse_recovers_with_fresh_sequence(): ack = _ack(3, 2, P.SET_PHASE) # seq 2 -> accepted client, _, transport = _make([reuse, ack], retries=0) client._seq = 0 - out = client.set_phase(3, 128, 2) + out = client.set_phase(3, PHASES) assert out == bytes([P.SET_PHASE, P.RES_OK]) seqs = [P.parse_id(m.arbitration_id)["sequence"] for m in transport.sent] assert seqs == [1, 2] @@ -281,5 +297,5 @@ def test_sequence_reuse_persisting_raises(): client, _, _ = _make([reuse1, reuse2], retries=0) client._seq = 0 with pytest.raises(BeamControlError) as exc: - client.set_phase(3, 128, 2) + client.set_phase(3, PHASES) assert exc.value.result == P.RES_SEQUENCE_REUSE diff --git a/pi/tests/unit/test_monitor.py b/pi/tests/unit/test_monitor.py index 82f2cc2..6a45eec 100644 --- a/pi/tests/unit/test_monitor.py +++ b/pi/tests/unit/test_monitor.py @@ -4,18 +4,15 @@ from collections.abc import Callable -from beamcontrol import protocol as P -from beamcontrol.client import BeamControlError, ProtocolInfo +from beamcontrol.client import BeamControlError, NodeStatus from beamcontrol.config import BeamControlConfig from beamcontrol.monitor import BeamControlMonitor, configured_node_failure -FLAGS = P.FEATURE_FLAGS - class FakeClient: - def discover(self, destination: int) -> tuple[bytes, ProtocolInfo | None]: + def discover(self, destination: int) -> NodeStatus: if destination == 1: - return bytes([1, 1, 0, 0, 0, 0, 0, 0]), ProtocolInfo(1, 1, 0, P.FEATURE_FLAGS, 1) + return NodeStatus(2, 1, 0, 1, 0, 0, 0, 0) raise BeamControlError(f"no STATUS from node {destination}") @@ -64,25 +61,17 @@ def unavailable(_: BeamControlConfig): assert can_status["error"] == "can0 does not exist" -def test_v10_no_protocol_info_rejected() -> None: - assert configured_node_failure(3, None) is not None - - -def test_v10_with_info_rejected() -> None: - assert configured_node_failure(3, ProtocolInfo(1, 0, 0, FLAGS, 3)) is not None - - -def test_v20_rejected() -> None: - assert configured_node_failure(3, ProtocolInfo(2, 0, 0, FLAGS, 3)) is not None +def test_wrong_major_rejected() -> None: + assert configured_node_failure(3, NodeStatus(1, 9, 0, 3, 0, 0, 0, 0)) is not None -def test_v11_accepted() -> None: - assert configured_node_failure(3, ProtocolInfo(1, 1, 0, FLAGS, 3)) is None +def test_newer_major_rejected() -> None: + assert configured_node_failure(3, NodeStatus(3, 0, 0, 3, 0, 0, 0, 0)) is not None -def test_v12_accepted() -> None: - assert configured_node_failure(3, ProtocolInfo(1, 2, 0, FLAGS, 3)) is None +def test_wrong_minor_rejected() -> None: + assert configured_node_failure(3, NodeStatus(2, 0, 0, 3, 0, 0, 0, 0)) is not None -def test_missing_features_rejected() -> None: - assert configured_node_failure(3, ProtocolInfo(1, 1, 0, 0, 3)) is not None +def test_exact_version_accepted() -> None: + assert configured_node_failure(3, NodeStatus(2, 1, 0, 3, 0, 0, 0, 0)) is None diff --git a/pi/tests/unit/test_protocol.py b/pi/tests/unit/test_protocol.py index 216acad..b461b9f 100644 --- a/pi/tests/unit/test_protocol.py +++ b/pi/tests/unit/test_protocol.py @@ -1,4 +1,4 @@ -"""Host-runnable regression tests for the BeamControl v1.1 protocol module. +"""Host-runnable regression tests for the BeamControl v2.1 protocol module. Run: pytest pi/tests/unit/test_protocol.py -v """ @@ -49,24 +49,42 @@ def test_invalid_fields(self): class TestValidation(unittest.TestCase): def test_strict_channels_0_3(self): - for ch in (0, 1, 2, 3): - P.validate_payload(ch) - for ch in (4, 5, 15, 255): + for channel in (0, 1, 2, 3): + P.validate_channel(channel) + for channel in (4, 5, 15, 255): with self.assertRaises(ValueError): - P.validate_payload(ch) - - def test_attenuation_0_23(self): - for a in (0, 23): - P.validate_payload(0, atten_db=a) - for a in (24, 255): - with self.assertRaises(ValueError): - P.validate_payload(0, atten_db=a) + P.validate_channel(channel) + + def test_bulk_attenuation_uses_every_1_db_step(self): + for attenuation in range(P.ATTEN_DB_MAX + 1): + self.assertEqual( + P.validate_attenuations([attenuation] * P.RF_CHANNEL_COUNT), + bytes([attenuation] * P.RF_CHANNEL_COUNT), + ) + with self.assertRaises(ValueError): + P.validate_attenuations([0, 1, 2, 24]) + with self.assertRaises(ValueError): + P.validate_attenuations([0, 1, 2]) - def test_phase_state_0_255(self): - for p in (0, 255): - P.validate_payload(0, phase_state=p) + def test_bulk_phase_states_0_255(self): + self.assertEqual( + P.validate_phase_states([0, 1, 254, 255]), + bytes([0, 1, 254, 255]), + ) with self.assertRaises(ValueError): - P.validate_payload(0, phase_state=256) + P.validate_phase_states([0, 1, 2, 256]) + with self.assertRaises(ValueError): + P.validate_phase_states([0, 1, 2]) + + def test_individual_values(self): + self.assertEqual(P.validate_phase_state(255), 255) + self.assertEqual(P.validate_attenuation(23), 23) + for invalid in (-1, 256, True): + with self.assertRaises(ValueError): + P.validate_phase_state(invalid) + for invalid in (-1, 24, True): + with self.assertRaises(ValueError): + P.validate_attenuation(invalid) class TestConstants(unittest.TestCase): @@ -84,8 +102,9 @@ def test_nodes(self): self.assertEqual(P.CONTROLLER_NODE, 0) self.assertEqual(P.BROADCAST_NODE, 31) - def test_discovery_sequence_reserved(self): - self.assertEqual(P.SEQ_CAPABILITIES, 0xFFFF) + def test_protocol_version(self): + self.assertEqual(P.PROTOCOL_VERSION, (2, 1, 0)) + self.assertEqual(P.RF_CHANNEL_COUNT, 4) if __name__ == "__main__": diff --git a/pi/tests/unit/test_web.py b/pi/tests/unit/test_web.py index 9bd397f..594f047 100644 --- a/pi/tests/unit/test_web.py +++ b/pi/tests/unit/test_web.py @@ -64,8 +64,7 @@ def snapshot(self) -> dict[str, object]: { "node_id": 1, "health": "healthy", - "protocol_version": "1.1.0", - "feature_flags": "0x00ff", + "protocol_version": "2.1.0", "response_ms": 3.1, "last_seen": "03:00:12", "error": None, diff --git a/protocol/generated/protocol_vectors.h b/protocol/generated/protocol_vectors.h index 21f907e..b20488d 100644 --- a/protocol/generated/protocol_vectors.h +++ b/protocol/generated/protocol_vectors.h @@ -1,5 +1,5 @@ /* - * Generated by tools/generate-protocol-vectors.py from protocol/v1.1-vectors.toml. + * Generated by tools/generate-protocol-vectors.py from protocol/v2.1-vectors.toml. * Do not edit by hand. Run `python3 tools/generate-protocol-vectors.py` to regenerate. */ #ifndef PROTOCOL_VECTORS_H @@ -38,14 +38,19 @@ static const uint32_t PROTOCOL_ID_VECTORS_COUNT = 3u; static const protocol_command_vector_t PROTOCOL_COMMAND_VECTORS[] = { - { "set_phase_ch2_state128", 2u, 3u, 0u, 42u, {0x80u, 0x02u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 2u, true, 0u }, - { "set_vga_23db", 3u, 3u, 0u, 44u, {0x17u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 1u, true, 0u }, - { "reject_channel_4", 2u, 3u, 0u, 43u, {0x80u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 2u, false, 2u }, - { "reject_attenuation_24", 3u, 3u, 0u, 45u, {0x18u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 1u, false, 2u }, - { "reject_reserved_bytes", 2u, 3u, 0u, 46u, {0x80u, 0x02u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u}, 4u, false, 6u }, - { "enter_safe_ch0", 0u, 3u, 0u, 47u, {0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 1u, true, 0u }, + { "set_phase_individual", 2u, 3u, 0u, 40u, {0x80u, 0x02u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 2u, true, 0u }, + { "set_phase_bulk", 2u, 3u, 0u, 41u, {0x80u, 0x40u, 0x20u, 0x10u, 0x00u, 0x00u, 0x00u, 0x00u}, 4u, true, 0u }, + { "set_vga_individual", 3u, 3u, 0u, 42u, {0x08u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 2u, true, 0u }, + { "set_vga_bulk", 3u, 3u, 0u, 43u, {0x00u, 0x08u, 0x0cu, 0x17u, 0x00u, 0x00u, 0x00u, 0x00u}, 4u, true, 0u }, + { "set_combined_individual", 1u, 3u, 0u, 44u, {0x40u, 0x03u, 0x0cu, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 3u, true, 0u }, + { "set_combined_bulk", 1u, 3u, 0u, 45u, {0x0au, 0x14u, 0x1eu, 0x28u, 0x01u, 0x02u, 0x03u, 0x04u}, 8u, true, 0u }, + { "reject_phase_dlc_3", 2u, 3u, 0u, 46u, {0x80u, 0x02u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 3u, false, 1u }, + { "reject_vga_channel_4", 3u, 3u, 0u, 47u, {0x08u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 2u, false, 2u }, + { "reject_bulk_attenuation_24", 3u, 3u, 0u, 48u, {0x00u, 0x08u, 0x18u, 0x17u, 0x00u, 0x00u, 0x00u, 0x00u}, 4u, false, 2u }, + { "reject_combined_dlc_4", 1u, 3u, 0u, 49u, {0x40u, 0x03u, 0x0cu, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 4u, false, 1u }, + { "enter_safe_ch0", 0u, 3u, 0u, 50u, {0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u}, 1u, true, 0u }, }; -static const uint32_t PROTOCOL_COMMAND_VECTORS_COUNT = 6u; +static const uint32_t PROTOCOL_COMMAND_VECTORS_COUNT = 11u; #endif /* PROTOCOL_VECTORS_H */ diff --git a/protocol/v1.1-vectors.toml b/protocol/v1.1-vectors.toml deleted file mode 100644 index c414319..0000000 --- a/protocol/v1.1-vectors.toml +++ /dev/null @@ -1,87 +0,0 @@ -# Shared protocol v1.1 test vectors (language-neutral contract). -# Consumed by: -# - Python: pi/tests/contract/test_protocol_vectors.py (via tomllib) -# - C: generated protocol_vectors.h (from tools/generate-protocol-vectors.py) -# Any change here must keep both implementations in agreement; CI regenerates -# the header and fails if it drifts. - -[[id_vectors]] -name = "set_phase_node_3_seq_42" -type = 2 -destination = 3 -source = 0 -sequence = 42 -extended_id = 0x0860002A - -[[id_vectors]] -name = "enter_safe_broadcast" -type = 0 -destination = 31 -source = 0 -sequence = 0 -extended_id = 0x03E00000 - -[[id_vectors]] -name = "status_from_node_7_seq_1" -type = 5 -destination = 0 -source = 7 -sequence = 1 -extended_id = 0x14070001 - -[[commands]] -name = "set_phase_ch2_state128" -type = 2 -destination = 3 -source = 0 -sequence = 42 -data = [128, 2] -valid = true - -[[commands]] -name = "set_vga_23db" -type = 3 -destination = 3 -source = 0 -sequence = 44 -data = [23] -valid = true - -[[commands]] -name = "reject_channel_4" -type = 2 -destination = 3 -source = 0 -sequence = 43 -data = [128, 4] -valid = false -result = 2 - -[[commands]] -name = "reject_attenuation_24" -type = 3 -destination = 3 -source = 0 -sequence = 45 -data = [24] -valid = false -result = 2 - -[[commands]] -name = "reject_reserved_bytes" -type = 2 -destination = 3 -source = 0 -sequence = 46 -data = [128, 2, 0, 1] -valid = false -result = 6 - -[[commands]] -name = "enter_safe_ch0" -type = 0 -destination = 3 -source = 0 -sequence = 47 -data = [0] -valid = true diff --git a/protocol/v2.1-vectors.toml b/protocol/v2.1-vectors.toml new file mode 100644 index 0000000..d11490c --- /dev/null +++ b/protocol/v2.1-vectors.toml @@ -0,0 +1,128 @@ +# Shared protocol v2.1 vectors for Python and C contract tests. + +[[id_vectors]] +name = "set_phase_node_3_seq_42" +type = 2 +destination = 3 +source = 0 +sequence = 42 +extended_id = 0x0860002A + +[[id_vectors]] +name = "enter_safe_broadcast" +type = 0 +destination = 31 +source = 0 +sequence = 0 +extended_id = 0x03E00000 + +[[id_vectors]] +name = "status_from_node_7_seq_1" +type = 5 +destination = 0 +source = 7 +sequence = 1 +extended_id = 0x14070001 + +[[commands]] +name = "set_phase_individual" +type = 2 +destination = 3 +source = 0 +sequence = 40 +data = [128, 2] +valid = true + +[[commands]] +name = "set_phase_bulk" +type = 2 +destination = 3 +source = 0 +sequence = 41 +data = [128, 64, 32, 16] +valid = true + +[[commands]] +name = "set_vga_individual" +type = 3 +destination = 3 +source = 0 +sequence = 42 +data = [8, 1] +valid = true + +[[commands]] +name = "set_vga_bulk" +type = 3 +destination = 3 +source = 0 +sequence = 43 +data = [0, 8, 12, 23] +valid = true + +[[commands]] +name = "set_combined_individual" +type = 1 +destination = 3 +source = 0 +sequence = 44 +data = [64, 3, 12] +valid = true + +[[commands]] +name = "set_combined_bulk" +type = 1 +destination = 3 +source = 0 +sequence = 45 +data = [10, 20, 30, 40, 1, 2, 3, 4] +valid = true + +[[commands]] +name = "reject_phase_dlc_3" +type = 2 +destination = 3 +source = 0 +sequence = 46 +data = [128, 2, 0] +valid = false +result = 1 + +[[commands]] +name = "reject_vga_channel_4" +type = 3 +destination = 3 +source = 0 +sequence = 47 +data = [8, 4] +valid = false +result = 2 + +[[commands]] +name = "reject_bulk_attenuation_24" +type = 3 +destination = 3 +source = 0 +sequence = 48 +data = [0, 8, 24, 23] +valid = false +result = 2 + +[[commands]] +name = "reject_combined_dlc_4" +type = 1 +destination = 3 +source = 0 +sequence = 49 +data = [64, 3, 12, 0] +valid = false +result = 1 + +[[commands]] +name = "enter_safe_ch0" +type = 0 +destination = 3 +source = 0 +sequence = 50 +data = [0] +valid = true diff --git a/simulation/README.md b/simulation/README.md index 97115e3..247c18b 100644 --- a/simulation/README.md +++ b/simulation/README.md @@ -1,50 +1,40 @@ # Virtual end-to-end simulation -The simulation runs the real BeamControl controller and STM32 firmware without physical CAN -hardware: - -```text -beamd + FastAPI ── SocketCAN vcan0 ── Renode STM32F072 - ├── SPI1 VGA sink - └── SPI2 phase-shifter sink +```mermaid +flowchart LR + host["Host browser"] --> beamd + + subgraph docker["Docker Compose network namespace"] + beamd["beamd + FastAPI"] <--> can["SocketCAN vcan0"] + can <--> renode["Renode STM32F072"] + renode --> vga["SPI1 VGA sink"] + renode --> phase["SPI2 phase sink"] + end ``` -Docker Compose owns the network namespace and creates `vcan0` inside it. The controller, -Renode, and test runner share that namespace. No CAN interface is created on the host. +Docker Compose creates `vcan0` inside its network namespace; the host gets no CAN interface. ## Run -Docker and Docker Compose are required. The Linux Docker kernel must support SocketCAN `vcan`, -and the module must be loaded. On distributions that build it as a module, run -`sudo modprobe vcan` once before starting the simulation. The repository setup must already -provide the ARM toolchain and libopencm3. +Requirements: Docker Compose, Linux `vcan`, ARM toolchain, libopencm3. ```bash +sudo modprobe vcan # when built as a module make simulation-test ``` -The test builds node-1 firmware, starts the stack, and verifies: - -- dashboard liveness and readiness; -- protocol v1.1 discovery; -- phase, VGA, combined, and safe CAN transactions; -- ACK responses from the real STM32 ELF; -- complete SPI data-register values; and -- phase-latch, serial-select, and VGA chip-select GPIO writes. +The test verifies dashboard health, protocol 2.1 discovery, individual and bulk RF commands, safe mode, ACKs, SPI words, and GPIO latch/CS writes. -For interactive inspection: +Interactive mode: ```bash make simulation-up -# dashboard: http://127.0.0.1:18080 +# http://127.0.0.1:18080 make simulation-down ``` -Set `BEAMCONTROL_SIM_PORT` to publish a different host port. +Set `BEAMCONTROL_SIM_PORT` to change the host port. ## Scope -The platform overlay supplies deterministic RCC, flash-control, and watchdog register behavior -that Renode 1.16.1 does not model for STM32F072. The test is a functional digital simulation; -it does not validate RF performance, voltages, oscillator tolerance, CAN termination, physical -SPI timing, brownouts, or watchdog expiry. Those remain hardware-in-the-loop acceptance tests. +Renode validates digital software behavior, not RF performance, voltages, physical timing, CAN termination, oscillator tolerance, brownouts, or watchdog expiry. Those require hardware tests. diff --git a/simulation/tests/e2e.py b/simulation/tests/e2e.py index 1b497d4..1d986ab 100644 --- a/simulation/tests/e2e.py +++ b/simulation/tests/e2e.py @@ -80,16 +80,22 @@ def main() -> int: transport = SocketCanTransport("vcan0") try: client = BeamControlClient(transport, timeout=0.5, retries=2) - legacy, info = client.discover(1) - assert legacy[0] == 1 and legacy[1] == 1 - assert info is not None - assert (info.major, info.minor, info.patch) == (1, 1, 0) - assert info.node_id == 1 - assert info.feature_flags == P.FEATURE_FLAGS - - assert client.set_phase(1, 128, 2) == bytes([P.SET_PHASE, P.RES_OK]) - assert client.set_vga(1, 8) == bytes([P.SET_VGA, P.RES_OK]) - assert client.set_combined(1, 64, 1, 12) == bytes([P.SET_COMBINED, P.RES_OK]) + status = client.discover(1) + assert status.version == P.PROTOCOL_VERSION + assert status.node_id == 1 + + assert client.set_phase(1, [128, 64, 32, 16]) == bytes([P.SET_PHASE, P.RES_OK]) + assert client.set_vga(1, [8, 9, 10, 11]) == bytes([P.SET_VGA, P.RES_OK]) + assert client.set_combined( + 1, + [64, 65, 66, 67], + [12, 13, 14, 15], + ) == bytes([P.SET_COMBINED, P.RES_OK]) + assert client.set_phase_channel(1, 2, 146) == bytes([P.SET_PHASE, P.RES_OK]) + assert client.set_vga_channel(1, 1, 7) == bytes([P.SET_VGA, P.RES_OK]) + assert client.set_combined_channel(1, 0, 32, 6) == bytes( + [P.SET_COMBINED, P.RES_OK] + ) assert client.enter_safe(1, 1) == bytes([P.ENTER_SAFE, P.RES_OK]) finally: transport.close() @@ -100,20 +106,60 @@ def main() -> int: assert snapshot["configuration"]["target_nodes"] == [1] assert snapshot["nodes"][0]["node_id"] == 1 assert snapshot["nodes"][0]["health"] == "healthy" - assert snapshot["nodes"][0]["protocol_version"] == "1.1.0" + assert snapshot["nodes"][0]["protocol_version"] == "2.1.0" - text = wait_for_log("SIM_SPI2 offset=0xC value=0x8") + text = wait_for_log("SIM_SPI2 offset=0xC value=0x1628") - # Actual STM32 data-register writes. These include startup, a direct phase - # change, a combined safe transition, and ENTER_SAFE. + # Actual STM32 data-register writes. The expected order mirrors the + # control planner comments: startup, bulk phase, bulk VGA, combined safe + # transition, and finally one-channel ENTER_SAFE. assert_subsequence( values(text, "SPI1", 0xC), - [0x5C, 0x20, 0x5C, 0x30, 0x5C], + [ + 0x5C, + 0x5C, + 0x5C, + 0x5C, # startup: 23 dB requested for channels 1..4 + 0x20, + 0x24, + 0x28, + 0x2C, # SET_VGA: 8, 9, 10, 11 dB + 0x5C, + 0x5C, + 0x5C, + 0x5C, # SET_COMBINED stage 1: maximum attenuation + 0x30, + 0x34, + 0x38, + 0x3C, # bulk combined stage 3: 12, 13, 14, 15 dB + 0x5C, + 0x38, # individual phase: protect and restore channel 3 + 0x1C, # individual VGA: 7 dB on channel 2 + 0x5C, + 0x18, # individual combined: protect then apply 6 dB on channel 1 + 0x5C, # ENTER_SAFE channel 2 + ], "VGA SPI writes", ) assert_subsequence( values(text, "SPI2", 0xC), - [0x092C, 0x0024, 0x0058, 0x0008], + [ + 0x1628, + 0x1624, + 0x162C, + 0x1622, # startup: calibrated state 146, addresses 1..4 + 0x17C8, + 0x0054, + 0x008C, + 0x0102, # SET_PHASE: states 128, 64, 32, 16 + 0x0058, + 0x0F94, + 0x085C, + 0x1852, # bulk combined: states 64, 65, 66, 67 + 0x162C, # individual phase: state 146, address 3 + 0x0088, # individual combined: state 32, address 1 + 0x0004, # ENTER_SAFE: state 0, address 2 + ], "phase-shifter SPI writes", ) @@ -127,8 +173,8 @@ def main() -> int: print("BeamControl virtual end-to-end test passed") print(" real Python controller and FastAPI dashboard: healthy") - print(" real STM32F072 ELF in Renode: protocol v1.1 node 1") - print(" SocketCAN round trips: discover, phase, VGA, combined, safe") + print(" real STM32F072 ELF in Renode: protocol v2.1 node 1") + print(" SocketCAN round trips: discovery, individual/bulk RF updates, safe") print(" SPI/GPIO transaction sequence: verified") return 0 diff --git a/stm32/.gitignore b/stm32/.gitignore index 65064d8..98343b3 100644 --- a/stm32/.gitignore +++ b/stm32/.gitignore @@ -1,12 +1,13 @@ app/build/ app/generated.*.ld -tests/test_protocol +tests/test_rf_commands +tests/test_rf_plan tests/test_diagnostics tests/test_can_protocol -tests/test_can_queue -tests/test_can_control -tests/test_can_filter +tests/test_can_rx_queue +tests/test_can_tx_queue tests/test_build_info +tests/test_protocol_vectors __pycache__/ *.pyc *.o diff --git a/stm32/README.md b/stm32/README.md index 00ecd7e..fc95142 100644 --- a/stm32/README.md +++ b/stm32/README.md @@ -1,103 +1,89 @@ -# STM32 receiver-board firmware +# STM32 receiver firmware -Bare-metal C firmware for the STM32F072R8T6 on one BeamControl receiver PCB. +Bare-metal C/libopencm3 firmware for STM32F072R8T6. Each board is one CAN node (`1..30`) with four RF channels (`0..3`). -## Role in the system +## Behavior -One flashed STM32 board is one CAN receiver node. The Raspberry Pi is controller node `0`; -receiver boards use unique addresses from `1` through `30`. - -Each receiver board controls four RF channels (`0..3`). The phase shifters and DVGAs are local -peripherals on that board, not separate CAN nodes. - -```text -CAN receiver node N - |- channel 0: phase shifter + DVGA - |- channel 1: phase shifter + DVGA - |- channel 2: phase shifter + DVGA - `- channel 3: phase shifter + DVGA -``` - -## Implemented behavior - -The firmware: - -- starts the watchdog and configures a bounded 48 MHz clock path; -- initializes the phase-shifter, DVGA, and CAN peripherals; -- starts in maximum attenuation; -- accepts CAN commands only from controller node `0`; -- applies safe phase transitions by attenuating before changing phase; -- sends ACK, ERROR, and STATUS responses defined by protocol v1.1; -- filters frames for its own node ID or broadcast address `31`; -- records retained reset and fault diagnostics; and -- enters safe lockout after repeated incomplete boots of the same image. - -See [`../docs/can-protocol.md`](../docs/can-protocol.md) for the wire format. +- 48 MHz clock with bounded HSI48 fallback +- watchdog and retained fault/reset diagnostics +- safe maximum-attenuation startup +- CAN protocol 2.1 from controller node `0` +- individual and bulk phase/VGA updates +- attenuation before phase changes +- filtered RX, prioritized TX, replay-safe retries, bus-off recovery ## Build -The CAN node ID is compiled into the image. It is intentionally required at build time: - ```bash make firmware NODE=1 make firmware-size NODE=1 ``` -Valid IDs are `1..30`. Assign a different ID to every receiver board on the same bus. Building -without `NODE` fails instead of silently producing another node-1 image. - -Outputs are written to `stm32/app/build/`: - -- `beamcontrol.elf` -- `beamcontrol.bin` -- `beamcontrol.map` - -CI compiles one representative node-1 image to verify the firmware. It does not publish -pre-addressed firmware images. Build the required node ID explicitly before flashing a board. +`NODE` is required and must be unique. Outputs are under `stm32/app/build/`. ## Test -From the repository root: - ```bash make stm32-test make check ``` -The native C tests cover protocol encoding, command validation, queue behavior, safe output -ordering, diagnostics, build identity, and generated protocol vectors. `make check` also runs -Python tests and one representative cross-compiled firmware build. - -## Hardware target +## Target | Property | Value | |:--|:--| -| MCU | STM32F072R8T6 | -| Core | Arm Cortex-M0 | -| Flash | 64 KiB | -| RAM | 16 KiB | +| MCU | STM32F072R8T6, Cortex-M0 | +| Flash / RAM | 64 KiB / 16 KiB | | Clock | 48 MHz, HSE/PLL with HSI48 fallback | -| CAN | Classical CAN 2.0B extended frames, 500 kbit/s | -| CAN pins | PA11 RX, PA12 TX, AF4 | -| Phase-shifter bus | SPI2 | -| DVGA bus | SPI1 | - -Deployment still depends on PCB-level safe defaults during power-up. See -[`docs/HARDWARE_VALIDATION.md`](docs/HARDWARE_VALIDATION.md). - -## Source layout +| CAN | 2.0B extended, 500 kbit/s, PA11/PA12 AF4 | +| Phase bus | SPI2 | +| VGA bus | SPI1 | + +Both RF buses are transmit-only. ACK means the STM32 completed the transfer, not that an RF IC accepted or applied it. + +## Source map + +```mermaid +flowchart LR + main["main.c
startup + service loop"] + + subgraph can["can/"] + bus["bus + queues"] + protocol["protocol codec"] + runtime["runtime + replay"] + end + + subgraph rf["rf/"] + commands["serial commands"] + plan["safe planner"] + execute["plan executor"] + drivers["PE44820 + F0480 drivers"] + end + + subgraph platform["platform/"] + board["board pins"] + support["clock, time, watchdog, faults, diagnostics"] + end + + main --> runtime + main --> plan --> execute --> drivers + bus --> runtime --> protocol + runtime --> plan + runtime --> execute + plan --> commands + drivers --> board + main --> support + runtime --> support +``` ```text -app/src/main.c startup and main loop -app/src/can_runtime.c command execution and responses -app/src/can_protocol.c CAN identifier and payload codec -app/src/can_bus.c bxCAN transport and interrupt handling -app/src/can_filter.c acceptance-filter encoding -app/src/can_queue.c ISR-to-main receive queue -app/src/can_control.c safe hardware-operation planning -app/src/phaseShifter.c phase-shifter driver -app/src/vga.c DVGA driver -app/src/diagnostics.c retained diagnostic storage -app/src/faults.c reset-on-fault handlers -tests/ native C tests +app/src/main.c startup and service loop +app/src/can/ CAN hardware, codec, queues, runtime +app/src/rf/ RF encoding, planning, execution, drivers +app/src/platform/ board map, clock, time, faults, diagnostics +app/include/ matching public headers +bringup/ archived bench code and evidence; not compiled +tests/ native unit and contract tests ``` + +See [RF encoding](../docs/rf-control.md) and [hardware validation](docs/HARDWARE_VALIDATION.md). diff --git a/stm32/app/Makefile b/stm32/app/Makefile index 31d1df2..afc9469 100644 --- a/stm32/app/Makefile +++ b/stm32/app/Makefile @@ -7,27 +7,28 @@ OPENCM3_DEFS := -DSTM32F0 CFILES := \ src/main.c \ - src/beamforming_protocol.c \ - src/build_info.c \ - src/can_bus.c \ - src/can_tx_queue.c \ - src/can_control.c \ - src/can_filter.c \ - src/can_protocol.c \ - src/can_queue.c \ - src/can_runtime.c \ - src/clock_control.c \ - src/diagnostic_record.c \ - src/diagnostics.c \ - src/faults.c \ - src/phaseShifter.c \ - src/spi_guard.c \ - src/timebase.c \ - src/vga.c \ - src/watchdog.c + src/can/bus.c \ + src/can/filter.c \ + src/can/protocol.c \ + src/can/runtime.c \ + src/can/rx_queue.c \ + src/can/tx_queue.c \ + src/rf/commands.c \ + src/rf/execute.c \ + src/rf/phase_shifter.c \ + src/rf/plan.c \ + src/rf/vga.c \ + src/platform/build_info.c \ + src/platform/clock.c \ + src/platform/diagnostic_record.c \ + src/platform/diagnostics.c \ + src/platform/faults.c \ + src/platform/spi_guard.c \ + src/platform/timebase.c \ + src/platform/watchdog.c INCLUDES += -Iinclude -CSTD := -std=c17 +CSTD := -std=c2x OPT ?= -Os GIT_REVISION ?= $(shell git rev-parse --verify HEAD 2>/dev/null || printf unknown) diff --git a/stm32/app/include/PhaseShifter.h b/stm32/app/include/PhaseShifter.h deleted file mode 100644 index 6d31ada..0000000 --- a/stm32/app/include/PhaseShifter.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifndef PHASE_SHIFTER_H -#define PHASE_SHIFTER_H - -#include "spi_guard.h" - -#include - -#define SPI2_PS_LE_PORT GPIOB -#define SPI2_PS_LE_PIN GPIO12 -#define SPI2_PS_SP_PORT GPIOC -#define SPI2_PS_SP_PIN GPIO10 -#define SPI2_PS_CLK_PORT GPIOB -#define SPI2_PS_CLK_PIN GPIO13 -#define SPI2_PS_MOSI_PORT GPIOB -#define SPI2_PS_MOSI_PIN GPIO15 - -void phase_shifter_setup(void); -spi_guard_status_t phase_shifter_write(uint16_t command, uint32_t timeout_millis); - -#endif diff --git a/stm32/app/include/Vga.h b/stm32/app/include/Vga.h deleted file mode 100644 index 4ae8fd9..0000000 --- a/stm32/app/include/Vga.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef VGA_H -#define VGA_H - -#include "spi_guard.h" - -#include - -#define SPI1_VGA_CSB_PORT GPIOA -#define SPI1_VGA_CSB_PIN GPIO4 -#define SPI1_VGA_CLK_PORT GPIOA -#define SPI1_VGA_CLK_PIN GPIO5 -#define SPI1_VGA_MOSI_PORT GPIOA -#define SPI1_VGA_MOSI_PIN GPIO7 - -void vga_setup(void); -spi_guard_status_t vga_write(uint8_t command, uint32_t timeout_millis); - -#endif diff --git a/stm32/app/include/beamforming_protocol.h b/stm32/app/include/beamforming_protocol.h deleted file mode 100644 index f2a46ff..0000000 --- a/stm32/app/include/beamforming_protocol.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef BEAMFORMING_PROTOCOL_H -#define BEAMFORMING_PROTOCOL_H - -#include -#include - -#define PHASE_STATE_COUNT 256u -#define PHASE_FULL_TURN_MILLIDEGREES 360000u -#define PHASE_OPTION_STATE_BIT 0x40u -#define PHASE_COMMAND_OPTION_MASK 0x0010u -#define PHASE_COMMAND_MAX 0x1fffu - -#define VGA_MAX_ATTENUATION_DB 23u -#define VGA_ATTENUATION_SHIFT 2u - -uint16_t beamforming_reverse_bits(uint16_t word, uint8_t bit_count); -bool phase_state_from_millidegrees( - uint32_t requested_shift_millidegrees, - uint8_t *phase_state); -bool phase_command_from_state(uint8_t phase_state, uint8_t unit_address, uint16_t *command); -bool phase_command_from_millidegrees( - uint32_t requested_shift_millidegrees, - uint8_t unit_address, - uint16_t *command); -bool vga_command_from_attenuation(uint8_t attenuation_db, uint8_t *command); - -#endif diff --git a/stm32/app/include/can_bus.h b/stm32/app/include/can/bus.h similarity index 65% rename from stm32/app/include/can_bus.h rename to stm32/app/include/can/bus.h index b9f2c29..eae0d8a 100644 --- a/stm32/app/include/can_bus.h +++ b/stm32/app/include/can/bus.h @@ -1,20 +1,12 @@ #ifndef CAN_BUS_H #define CAN_BUS_H -#include "can_protocol.h" +#include "can/protocol.h" +#include "can/tx_queue.h" #include #include -/* Software TX-queue priority. Lower numeric value = higher priority. */ -typedef enum can_tx_priority { - CAN_TX_PRIORITY_ERROR = 0, - CAN_TX_PRIORITY_ACK = 1, - CAN_TX_PRIORITY_REQUESTED_STATUS = 2, - CAN_TX_PRIORITY_TELEMETRY = 3, - CAN_TX_PRIORITY_COUNT = 4 -} can_tx_priority_t; - bool can_bus_setup(uint8_t self_node); bool can_bus_receive(can_frame_t *frame); diff --git a/stm32/app/include/can/protocol.h b/stm32/app/include/can/protocol.h new file mode 100644 index 0000000..5e0394c --- /dev/null +++ b/stm32/app/include/can/protocol.h @@ -0,0 +1,176 @@ +#ifndef CAN_PROTOCOL_H +#define CAN_PROTOCOL_H + +#include "rf/limits.h" + +#include +#include + +#define CAN_PROTOCOL_VERSION_MAJOR 2u +#define CAN_PROTOCOL_VERSION_MINOR 1u +#define CAN_PROTOCOL_VERSION_PATCH 0u + +#define CAN_EXTENDED_ID_MAX 0x1fffffffu + +#define CAN_NODE_CONTROLLER 0u +#define CAN_NODE_MIN 1u +#define CAN_NODE_MAX 30u +#define CAN_NODE_BROADCAST 31u + +#define CAN_MAX_DATA_LENGTH 8u + +#define CAN_ID_TYPE_SHIFT 26u +#define CAN_ID_DESTINATION_SHIFT 21u +#define CAN_ID_SOURCE_SHIFT 16u + +#define CAN_ID_TYPE_MASK 0x07u +#define CAN_ID_NODE_MASK 0x1fu +#define CAN_ID_SEQUENCE_MASK 0xffffu + +#define CAN_ENTER_SAFE_LENGTH 1u +#define CAN_SET_PHASE_INDIVIDUAL_LENGTH 2u +#define CAN_SET_PHASE_BULK_LENGTH 4u +#define CAN_SET_VGA_INDIVIDUAL_LENGTH 2u +#define CAN_SET_VGA_BULK_LENGTH 4u +#define CAN_SET_COMBINED_INDIVIDUAL_LENGTH 3u +#define CAN_SET_COMBINED_BULK_LENGTH 8u +#define CAN_PING_LENGTH 0u + +#define CAN_ACK_LENGTH 2u +#define CAN_ERROR_LENGTH 2u +#define CAN_STATUS_LENGTH 8u + +typedef enum can_message_type { + CAN_MESSAGE_ENTER_SAFE = 0, + CAN_MESSAGE_SET_COMBINED = 1, + CAN_MESSAGE_SET_PHASE = 2, + CAN_MESSAGE_SET_VGA = 3, + CAN_MESSAGE_PING = 4, + CAN_MESSAGE_STATUS = 5, + CAN_MESSAGE_ACK = 6, + CAN_MESSAGE_ERROR = 7, + CAN_MESSAGE_TYPE_COUNT = 8 +} can_message_type_t; + +typedef enum can_decode_result { + CAN_DECODE_OK = 0, + CAN_DECODE_IGNORED = 1, + CAN_DECODE_INVALID_ARGUMENT = 2, + CAN_DECODE_INVALID_ID = 3, + CAN_DECODE_INVALID_LENGTH = 4, + CAN_DECODE_INVALID_PAYLOAD = 5, + CAN_DECODE_UNSUPPORTED_TYPE = 6, + CAN_DECODE_BROADCAST_NOT_ALLOWED = 7 +} can_decode_result_t; + +typedef enum can_command_result { + CAN_COMMAND_RESULT_OK = 0, + CAN_COMMAND_RESULT_INVALID_LENGTH = 1, + CAN_COMMAND_RESULT_INVALID_PAYLOAD = 2, + CAN_COMMAND_RESULT_UNSUPPORTED = 3, + CAN_COMMAND_RESULT_HARDWARE = 4, + CAN_COMMAND_RESULT_BUSY = 5, + CAN_COMMAND_RESULT_SEQUENCE_REUSE = 6, + CAN_COMMAND_RESULT_BROADCAST_NOT_ALLOWED = 7 +} can_command_result_t; + +typedef enum can_health_flag { + CAN_HEALTH_NONE = 0, + CAN_HEALTH_CLOCK_FALLBACK = 1u << 0, + CAN_HEALTH_RX_DROPPED = 1u << 1, + CAN_HEALTH_INVALID_COMMAND = 1u << 2, + CAN_HEALTH_SAFE_LOCKOUT = 1u << 3, + CAN_HEALTH_BUS_OFF = 1u << 4, + CAN_HEALTH_TX_DROPPED = 1u << 5 +} can_health_flag_t; + +typedef struct can_frame { + uint32_t id; + bool extended; + bool remote; + uint8_t length; + uint8_t data[CAN_MAX_DATA_LENGTH]; +} can_frame_t; + +typedef struct can_command { + can_message_type_t type; + uint8_t destination; + uint8_t source; + uint16_t sequence; + + /* Fixed order: array index 0..3 maps to RF channel / PE44820 address 1..4. */ + uint8_t phase_states[RF_CHANNEL_COUNT]; + uint8_t attenuation_db[RF_CHANNEL_COUNT]; + + /* Individual RF commands and ENTER_SAFE use this zero-based channel. */ + uint8_t channel; + bool bulk_update; +} can_command_t; + +typedef struct can_status_payload { + uint8_t node_id; + uint8_t health_flags; + uint16_t rx_dropped; + uint16_t tx_dropped; + uint16_t invalid_commands; +} can_status_payload_t; + +/* + * STATUS wire payload: + * Byte 0: protocol major + * Byte 1: protocol minor + * Byte 2: protocol patch + * Byte 3: source/node ID + * Byte 4: health flags + * Byte 5: receive-drop count, low byte + * Byte 6: transmit-drop count, low byte + * Byte 7: invalid-command count, low byte + */ + +bool can_protocol_make_id( + can_message_type_t type, + uint8_t destination, + uint8_t source, + uint16_t sequence, + uint32_t *id); + +bool can_protocol_parse_id( + uint32_t id, + can_message_type_t *type, + uint8_t *destination, + uint8_t *source, + uint16_t *sequence); + +can_decode_result_t can_protocol_decode_command( + const can_frame_t *frame, + uint8_t self_node, + can_command_t *command); + +bool can_protocol_make_ack( + uint8_t source_node, + uint8_t destination_node, + uint16_t sequence, + can_message_type_t command_type, + can_frame_t *frame); + +bool can_protocol_make_error( + uint8_t source_node, + uint8_t destination_node, + uint16_t sequence, + can_message_type_t command_type, + can_command_result_t result, + can_frame_t *frame); + +bool can_protocol_make_status( + uint8_t source_node, + uint8_t destination_node, + uint16_t sequence, + const can_status_payload_t *status, + can_frame_t *frame); + +bool can_protocol_is_state_changing_type(can_message_type_t type); + +can_command_result_t can_protocol_result_from_decode( + can_decode_result_t result); + +#endif diff --git a/stm32/app/include/can_runtime.h b/stm32/app/include/can/runtime.h similarity index 73% rename from stm32/app/include/can_runtime.h rename to stm32/app/include/can/runtime.h index 6839f14..0a6ea6c 100644 --- a/stm32/app/include/can_runtime.h +++ b/stm32/app/include/can/runtime.h @@ -1,16 +1,16 @@ #ifndef CAN_RUNTIME_H #define CAN_RUNTIME_H -#include "can_control.h" -#include "diagnostic_record.h" +#include "rf/plan.h" +#include "platform/diagnostic_record.h" #include #include typedef struct can_runtime { - can_control_state_t state; - uint16_t phase_command; - uint8_t vga_command; + rf_state_t state; + uint16_t last_phase_command; + uint8_t last_vga_command; uint8_t node_id; firmware_clock_t clock_source; bool safe_lockout; @@ -22,7 +22,7 @@ bool can_runtime_start( uint8_t node_id, firmware_clock_t clock_source, bool safe_lockout, - const can_control_state_t *initial_state, + const rf_state_t *initial_state, uint16_t initial_phase_command, uint8_t initial_vga_command); bool can_runtime_service_next(can_runtime_t *runtime); diff --git a/stm32/app/include/can_queue.h b/stm32/app/include/can/rx_queue.h similarity index 88% rename from stm32/app/include/can_queue.h rename to stm32/app/include/can/rx_queue.h index c0759b7..49e2d45 100644 --- a/stm32/app/include/can_queue.h +++ b/stm32/app/include/can/rx_queue.h @@ -1,7 +1,7 @@ -#ifndef CAN_QUEUE_H -#define CAN_QUEUE_H +#ifndef CAN_RX_QUEUE_H +#define CAN_RX_QUEUE_H -#include "can_protocol.h" +#include "can/protocol.h" #include #include diff --git a/stm32/app/include/can_tx_queue.h b/stm32/app/include/can/tx_queue.h similarity index 86% rename from stm32/app/include/can_tx_queue.h rename to stm32/app/include/can/tx_queue.h index 56fcf56..af50715 100644 --- a/stm32/app/include/can_tx_queue.h +++ b/stm32/app/include/can/tx_queue.h @@ -1,14 +1,22 @@ #ifndef CAN_TX_QUEUE_H #define CAN_TX_QUEUE_H -#include "can_bus.h" -#include "can_protocol.h" +#include "can/protocol.h" #include #include #define CAN_TX_QUEUE_CAPACITY 8u +/* Lower numeric value means higher transmit priority. */ +typedef enum can_tx_priority { + CAN_TX_PRIORITY_ERROR = 0, + CAN_TX_PRIORITY_ACK = 1, + CAN_TX_PRIORITY_REQUESTED_STATUS = 2, + CAN_TX_PRIORITY_TELEMETRY = 3, + CAN_TX_PRIORITY_COUNT = 4 +} can_tx_priority_t; + typedef struct can_tx_queue_entry { can_frame_t frame; can_tx_priority_t priority; diff --git a/stm32/app/include/can_control.h b/stm32/app/include/can_control.h deleted file mode 100644 index 96c8d04..0000000 --- a/stm32/app/include/can_control.h +++ /dev/null @@ -1,37 +0,0 @@ -#ifndef CAN_CONTROL_H -#define CAN_CONTROL_H - -#include "can_protocol.h" - -#include - -#define CAN_CONTROL_MAX_OPERATIONS 3u - -typedef enum can_control_operation_type { - CAN_CONTROL_OPERATION_PHASE = 0, - CAN_CONTROL_OPERATION_VGA = 1 -} can_control_operation_type_t; - -typedef struct can_control_operation { - can_control_operation_type_t type; - uint16_t command; -} can_control_operation_t; - -typedef struct can_control_state { - uint8_t phase_state; - uint8_t phase_address; - uint8_t attenuation_db; -} can_control_state_t; - -typedef struct can_control_plan { - can_control_operation_t operations[CAN_CONTROL_MAX_OPERATIONS]; - uint8_t operation_count; - can_control_state_t resulting_state; -} can_control_plan_t; - -can_command_result_t can_control_plan_command( - const can_command_t *command, - const can_control_state_t *current_state, - can_control_plan_t *plan); - -#endif diff --git a/stm32/app/include/can_protocol.h b/stm32/app/include/can_protocol.h deleted file mode 100644 index 6de045b..0000000 --- a/stm32/app/include/can_protocol.h +++ /dev/null @@ -1,307 +0,0 @@ -#ifndef CAN_PROTOCOL_H -#define CAN_PROTOCOL_H - -#include -#include - -/* - * Protocol versioning - * - * CAN_PROTOCOL_VERSION is retained because the legacy STATUS payload places - * the major protocol version in byte 0. - */ -#define CAN_PROTOCOL_VERSION_MAJOR 1u -#define CAN_PROTOCOL_VERSION_MINOR 1u -#define CAN_PROTOCOL_VERSION_PATCH 0u -#define CAN_PROTOCOL_VERSION CAN_PROTOCOL_VERSION_MAJOR - -/* - * A PING carrying sequence 0xffff requests both: - * - * 1. The legacy STATUS frame. - * 2. A STATUS/PROTOCOL_INFO frame. - * - * Other message types retain their normal sequence semantics, including - * sequence 0xffff, for backward compatibility with protocol 1.0 controllers. - */ -#define CAN_PROTOCOL_DISCOVERY_SEQUENCE 0xffffu - -#define CAN_EXTENDED_ID_MAX 0x1fffffffu - -#define CAN_NODE_CONTROLLER 0u -#define CAN_NODE_MIN 1u -#define CAN_NODE_MAX 30u -#define CAN_NODE_BROADCAST 31u - -#define CAN_MAX_DATA_LENGTH 8u - -#define CAN_ID_TYPE_SHIFT 26u -#define CAN_ID_DESTINATION_SHIFT 21u -#define CAN_ID_SOURCE_SHIFT 16u - -#define CAN_ID_TYPE_MASK 0x07u -#define CAN_ID_NODE_MASK 0x1fu -#define CAN_ID_SEQUENCE_MASK 0xffffu - -/* - * BeamControl has four physical RF channels. - * - * Keep CAN_PHASE_ADDRESS_MAX as a compatibility alias so existing code in - * can_control.c does not need to change. - */ -#define CAN_RF_CHANNEL_MIN 0u -#define CAN_RF_CHANNEL_MAX 3u -#define CAN_RF_CHANNEL_COUNT 4u -#define CAN_PHASE_ADDRESS_MAX CAN_RF_CHANNEL_MAX - -#define CAN_VGA_ATTENUATION_MAX_DB 23u - -/* - * Number of semantically used payload bytes for each command. - * - * DLC rules: - * - * - DLC must be at least the corresponding USED_LENGTH. - * - DLC may be larger, up to 8. - * - Every transmitted byte after USED_LENGTH must be zero. - * - * This keeps existing short-DLC protocol 1.0 messages valid while allowing - * fixed-width, zero-padded frames. - */ -#define CAN_ENTER_SAFE_USED_LENGTH 1u -#define CAN_SET_COMBINED_USED_LENGTH 3u -#define CAN_SET_PHASE_USED_LENGTH 2u -#define CAN_SET_VGA_USED_LENGTH 1u -#define CAN_PING_USED_LENGTH 0u - -#define CAN_ACK_LENGTH 2u -#define CAN_ERROR_LENGTH 2u -#define CAN_STATUS_LENGTH 8u - -/* - * Legacy STATUS byte 0 contains CAN_PROTOCOL_VERSION, currently 0x01. - * - * Values 0xf0 through 0xff are reserved for explicitly subtyped STATUS - * messages. Protocol 1.1 defines 0xf0 as PROTOCOL_INFO. - */ -#define CAN_STATUS_SUBTYPE_PROTOCOL_INFO 0xf0u - -typedef enum can_protocol_feature { - /* - * Commands containing a phase/channel address accept only channels 0-3. - */ - CAN_PROTOCOL_FEATURE_STRICT_CHANNELS = - 1u << 0, - - /* - * Broadcast destination 31 accepts ENTER_SAFE only. - */ - CAN_PROTOCOL_FEATURE_ENTER_SAFE_ONLY_BROADCAST = - 1u << 1, - - /* - * Payload bytes beyond the command's used length must be zero. - */ - CAN_PROTOCOL_FEATURE_RESERVED_ZERO_VALIDATION = - 1u << 2, - - /* - * Successfully completed state-changing commands are replay-cached. - */ - CAN_PROTOCOL_FEATURE_DUPLICATE_REPLAY = - 1u << 3, - - /* - * PING sequence 0xffff produces PROTOCOL_INFO. - */ - CAN_PROTOCOL_FEATURE_PROTOCOL_INFO = - 1u << 4, - - /* - * Phase-changing operations use the existing safe three-step transition. - */ - CAN_PROTOCOL_FEATURE_SAFE_TRANSITIONS = - 1u << 5, - - /* - * Unicast state-changing commands produce ACK or ERROR. - */ - CAN_PROTOCOL_FEATURE_TERMINAL_RESPONSE = - 1u << 6, - - /* - * STATUS values 0xf0-0xff are interpreted as explicit subtypes. - */ - CAN_PROTOCOL_FEATURE_STATUS_SUBTYPES = - 1u << 7 -} can_protocol_feature_t; - -#define CAN_PROTOCOL_FEATURE_FLAGS \ - ((uint16_t)(CAN_PROTOCOL_FEATURE_STRICT_CHANNELS | \ - CAN_PROTOCOL_FEATURE_ENTER_SAFE_ONLY_BROADCAST | \ - CAN_PROTOCOL_FEATURE_RESERVED_ZERO_VALIDATION | \ - CAN_PROTOCOL_FEATURE_DUPLICATE_REPLAY | \ - CAN_PROTOCOL_FEATURE_PROTOCOL_INFO | \ - CAN_PROTOCOL_FEATURE_SAFE_TRANSITIONS | \ - CAN_PROTOCOL_FEATURE_TERMINAL_RESPONSE | \ - CAN_PROTOCOL_FEATURE_STATUS_SUBTYPES)) - -typedef enum can_message_type { - CAN_MESSAGE_ENTER_SAFE = 0, - CAN_MESSAGE_SET_COMBINED = 1, - CAN_MESSAGE_SET_PHASE = 2, - CAN_MESSAGE_SET_VGA = 3, - CAN_MESSAGE_PING = 4, - CAN_MESSAGE_STATUS = 5, - CAN_MESSAGE_ACK = 6, - CAN_MESSAGE_ERROR = 7, - CAN_MESSAGE_TYPE_COUNT = 8 -} can_message_type_t; - -typedef enum can_decode_result { - CAN_DECODE_OK = 0, - CAN_DECODE_IGNORED = 1, - CAN_DECODE_INVALID_ARGUMENT = 2, - CAN_DECODE_INVALID_ID = 3, - CAN_DECODE_INVALID_LENGTH = 4, - CAN_DECODE_INVALID_PAYLOAD = 5, - CAN_DECODE_UNSUPPORTED_TYPE = 6, - - /* - * Added in protocol 1.1. Append-only: existing numeric values above stay - * unchanged. - */ - CAN_DECODE_RESERVED_BYTES = 7, - CAN_DECODE_BROADCAST_NOT_ALLOWED = 8 -} can_decode_result_t; - -typedef enum can_command_result { - CAN_COMMAND_RESULT_OK = 0, - CAN_COMMAND_RESULT_INVALID_LENGTH = 1, - CAN_COMMAND_RESULT_INVALID_PAYLOAD = 2, - CAN_COMMAND_RESULT_UNSUPPORTED = 3, - CAN_COMMAND_RESULT_HARDWARE = 4, - CAN_COMMAND_RESULT_BUSY = 5, - - /* - * Added in protocol 1.1. - */ - CAN_COMMAND_RESULT_RESERVED_BYTES = 6, - CAN_COMMAND_RESULT_SEQUENCE_REUSE = 7, - CAN_COMMAND_RESULT_BROADCAST_NOT_ALLOWED = 8 -} can_command_result_t; - -typedef enum can_health_flag { - CAN_HEALTH_NONE = 0, - CAN_HEALTH_CLOCK_FALLBACK = 1u << 0, - CAN_HEALTH_RX_DROPPED = 1u << 1, - CAN_HEALTH_INVALID_COMMAND = 1u << 2, - CAN_HEALTH_SAFE_LOCKOUT = 1u << 3, - CAN_HEALTH_BUS_OFF = 1u << 4, - CAN_HEALTH_TX_DROPPED = 1u << 5 -} can_health_flag_t; - -typedef struct can_frame { - uint32_t id; - bool extended; - bool remote; - uint8_t length; - uint8_t data[CAN_MAX_DATA_LENGTH]; -} can_frame_t; - -typedef struct can_command { - can_message_type_t type; - uint8_t destination; - uint8_t source; - uint16_t sequence; - - uint8_t phase_state; - uint8_t phase_address; - uint8_t attenuation_db; -} can_command_t; - -typedef struct can_status_payload { - uint8_t node_id; - uint8_t phase_state; - uint8_t phase_address; - uint8_t attenuation_db; - uint8_t health_flags; - uint16_t rx_dropped; - uint16_t invalid_commands; -} can_status_payload_t; - -/* - * STATUS/PROTOCOL_INFO wire payload: - * - * Byte 0: 0xf0, CAN_STATUS_SUBTYPE_PROTOCOL_INFO - * Byte 1: protocol major - * Byte 2: protocol minor - * Byte 3: protocol patch - * Byte 4: feature flags bits 7:0 - * Byte 5: feature flags bits 15:8 - * Byte 6: source/node ID - * Byte 7: reserved, zero - */ -typedef struct can_protocol_info_payload { - uint8_t major; - uint8_t minor; - uint8_t patch; - uint16_t feature_flags; - uint8_t node_id; -} can_protocol_info_payload_t; - -bool can_protocol_make_id( - can_message_type_t type, - uint8_t destination, - uint8_t source, - uint16_t sequence, - uint32_t *id); - -bool can_protocol_parse_id( - uint32_t id, - can_message_type_t *type, - uint8_t *destination, - uint8_t *source, - uint16_t *sequence); - -can_decode_result_t can_protocol_decode_command( - const can_frame_t *frame, - uint8_t self_node, - can_command_t *command); - -bool can_protocol_make_ack( - uint8_t source_node, - uint8_t destination_node, - uint16_t sequence, - can_message_type_t command_type, - can_frame_t *frame); - -bool can_protocol_make_error( - uint8_t source_node, - uint8_t destination_node, - uint16_t sequence, - can_message_type_t command_type, - can_command_result_t result, - can_frame_t *frame); - -bool can_protocol_make_status( - uint8_t source_node, - uint8_t destination_node, - uint16_t sequence, - const can_status_payload_t *status, - can_frame_t *frame); - -bool can_protocol_make_protocol_info_status( - uint8_t source_node, - uint8_t destination_node, - uint16_t sequence, - can_frame_t *frame); - -bool can_protocol_is_state_changing_type(can_message_type_t type); - -bool can_protocol_is_discovery_ping(const can_command_t *command); - -can_command_result_t can_protocol_result_from_decode( - can_decode_result_t result); - -#endif diff --git a/stm32/app/include/clock_control.h b/stm32/app/include/clock_control.h deleted file mode 100644 index a6b048b..0000000 --- a/stm32/app/include/clock_control.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef CLOCK_CONTROL_H -#define CLOCK_CONTROL_H - -#include "diagnostic_record.h" - -#include - -#define FIRMWARE_CORE_CLOCK_HZ 48000000u - -bool clock_control_setup(firmware_clock_t *clock_source); - -#endif diff --git a/stm32/app/include/platform/board.h b/stm32/app/include/platform/board.h new file mode 100644 index 0000000..83f0a54 --- /dev/null +++ b/stm32/app/include/platform/board.h @@ -0,0 +1,39 @@ +#ifndef PLATFORM_BOARD_H +#define PLATFORM_BOARD_H + +#include + +/* bxCAN on PA11/PA12, alternate function 4. */ +#define BOARD_CAN_PORT GPIOA +#define BOARD_CAN_RX_PIN GPIO11 +#define BOARD_CAN_TX_PIN GPIO12 +#define BOARD_CAN_AF GPIO_AF4 + +/* PE44820 serial interface on SPI2. */ +#define BOARD_PHASE_LE_PORT GPIOB +#define BOARD_PHASE_LE_PIN GPIO12 +#define BOARD_PHASE_SP_PORT GPIOC +#define BOARD_PHASE_SP_PIN GPIO10 +#define BOARD_PHASE_CLK_PORT GPIOB +#define BOARD_PHASE_CLK_PIN GPIO13 +#define BOARD_PHASE_MOSI_PORT GPIOB +#define BOARD_PHASE_MOSI_PIN GPIO15 +#define BOARD_PHASE_AF GPIO_AF0 + +/* F0480 serial interface on SPI1. */ +#define BOARD_VGA_CS_PORT GPIOA +#define BOARD_VGA_CS_PIN GPIO4 +#define BOARD_VGA_CLK_PORT GPIOA +#define BOARD_VGA_CLK_PIN GPIO5 +#define BOARD_VGA_MOSI_PORT GPIOA +#define BOARD_VGA_MOSI_PIN GPIO7 +#define BOARD_VGA_AF GPIO_AF0 + +/* Two core cycles between the final SPI edge and a control-line transition. */ +static inline void board_control_line_margin(void) +{ + __asm__ volatile("nop"); + __asm__ volatile("nop"); +} + +#endif diff --git a/stm32/app/include/build_info.h b/stm32/app/include/platform/build_info.h similarity index 100% rename from stm32/app/include/build_info.h rename to stm32/app/include/platform/build_info.h diff --git a/stm32/app/include/platform/clock.h b/stm32/app/include/platform/clock.h new file mode 100644 index 0000000..2d37f73 --- /dev/null +++ b/stm32/app/include/platform/clock.h @@ -0,0 +1,12 @@ +#ifndef PLATFORM_CLOCK_H +#define PLATFORM_CLOCK_H + +#include "platform/diagnostic_record.h" + +#include + +#define FIRMWARE_CORE_CLOCK_HZ 48000000u + +bool clock_setup(firmware_clock_t *clock_source); + +#endif diff --git a/stm32/app/include/diagnostic_record.h b/stm32/app/include/platform/diagnostic_record.h similarity index 100% rename from stm32/app/include/diagnostic_record.h rename to stm32/app/include/platform/diagnostic_record.h diff --git a/stm32/app/include/diagnostics.h b/stm32/app/include/platform/diagnostics.h similarity index 93% rename from stm32/app/include/diagnostics.h rename to stm32/app/include/platform/diagnostics.h index 47fc97b..042ea55 100644 --- a/stm32/app/include/diagnostics.h +++ b/stm32/app/include/platform/diagnostics.h @@ -1,7 +1,7 @@ #ifndef DIAGNOSTICS_H #define DIAGNOSTICS_H -#include "diagnostic_record.h" +#include "platform/diagnostic_record.h" #include #include diff --git a/stm32/app/include/faults.h b/stm32/app/include/platform/faults.h similarity index 87% rename from stm32/app/include/faults.h rename to stm32/app/include/platform/faults.h index 549d390..06a66be 100644 --- a/stm32/app/include/faults.h +++ b/stm32/app/include/platform/faults.h @@ -1,7 +1,7 @@ #ifndef FAULTS_H #define FAULTS_H -#include "diagnostic_record.h" +#include "platform/diagnostic_record.h" void firmware_fail(firmware_fault_t fault) __attribute__((noreturn)); void hard_fault_handler(void) __attribute__((noreturn)); diff --git a/stm32/app/include/firmware_config.h b/stm32/app/include/platform/firmware_config.h similarity index 100% rename from stm32/app/include/firmware_config.h rename to stm32/app/include/platform/firmware_config.h diff --git a/stm32/app/include/spi_guard.h b/stm32/app/include/platform/spi_guard.h similarity index 100% rename from stm32/app/include/spi_guard.h rename to stm32/app/include/platform/spi_guard.h diff --git a/stm32/app/include/timebase.h b/stm32/app/include/platform/timebase.h similarity index 100% rename from stm32/app/include/timebase.h rename to stm32/app/include/platform/timebase.h diff --git a/stm32/app/include/watchdog.h b/stm32/app/include/platform/watchdog.h similarity index 100% rename from stm32/app/include/watchdog.h rename to stm32/app/include/platform/watchdog.h diff --git a/stm32/app/include/rf/commands.h b/stm32/app/include/rf/commands.h new file mode 100644 index 0000000..bc5f58c --- /dev/null +++ b/stm32/app/include/rf/commands.h @@ -0,0 +1,33 @@ +#ifndef RF_COMMANDS_H +#define RF_COMMANDS_H + +#include "rf/limits.h" +#include "rf/phase_states_2_4ghz.h" + +#include +#include + +#define PHASE_STATE_BITS 8u +#define PHASE_STATE_COUNT 256u +#define PHASE_FULL_TURN_MILLIDEGREES 360000u +#define PHASE_CONTROL_WORD_BITS 9u +#define PHASE_CONTROL_WORD_DATA_MASK 0x00ffu +#define PHASE_CONTROL_WORD_OPTION_MASK 0x0100u +#define PHASE_UNIT_ADDRESS_BITS 4u +#define PHASE_COMMAND_PHASE_SHIFT 5u +#define PHASE_COMMAND_OPTION_SHIFT 4u +#define PHASE_COMMAND_ADDRESS_MASK 0x000fu +#define PHASE_COMMAND_OPTION_MASK (1u << PHASE_COMMAND_OPTION_SHIFT) +#define PHASE_COMMAND_MAX 0x1fffu +#define VGA_COMMAND_ATTENUATION_SHIFT 2u + +uint16_t reverseBits(uint16_t word, uint8_t numBits); +optimizedPhaseState_e GetOptimizedPhaseState(uint8_t stateWordTableIndex); +uint16_t MakePSCommand(optimizedPhaseState_e phaseState, uint8_t unitAddressWord); +bool MakeVGACommand(uint8_t attenuationDb, uint8_t *command); + +bool phase_state_from_millidegrees( + uint32_t requested_shift_millidegrees, + uint8_t *phase_state); + +#endif diff --git a/stm32/app/include/rf/execute.h b/stm32/app/include/rf/execute.h new file mode 100644 index 0000000..ae7b80f --- /dev/null +++ b/stm32/app/include/rf/execute.h @@ -0,0 +1,21 @@ +#ifndef RF_EXECUTE_H +#define RF_EXECUTE_H + +#include "rf/plan.h" + +#include + +void rf_execute_operation( + const rf_operation_t *operation, + uint32_t timeout_millis, + uint16_t *last_phase_command, + uint8_t *last_vga_command); + +/* Execute a validated plan in order. Hardware failures enter firmware_fail(). */ +void rf_execute_plan( + const rf_plan_t *plan, + uint32_t timeout_millis, + uint16_t *last_phase_command, + uint8_t *last_vga_command); + +#endif diff --git a/stm32/app/include/rf/limits.h b/stm32/app/include/rf/limits.h new file mode 100644 index 0000000..f34c8a8 --- /dev/null +++ b/stm32/app/include/rf/limits.h @@ -0,0 +1,13 @@ +#ifndef RF_LIMITS_H +#define RF_LIMITS_H + +#define RF_CHANNEL_MIN 0u +#define RF_CHANNEL_MAX 3u +#define RF_CHANNEL_COUNT 4u + +#define PHASE_UNIT_ADDRESS_MIN 1u +#define PHASE_UNIT_ADDRESS_MAX 4u + +#define VGA_MAX_ATTENUATION_DB 23u + +#endif diff --git a/stm32/app/include/rf/phase_shifter.h b/stm32/app/include/rf/phase_shifter.h new file mode 100644 index 0000000..a460b2a --- /dev/null +++ b/stm32/app/include/rf/phase_shifter.h @@ -0,0 +1,11 @@ +#ifndef RF_PHASE_SHIFTER_H +#define RF_PHASE_SHIFTER_H + +#include "platform/spi_guard.h" + +#include + +void pe448spisetup(void); +spi_guard_status_t phase_shifter_write(uint16_t command, uint32_t timeout_millis); + +#endif diff --git a/stm32/app/include/rf/phase_states_2_4ghz.h b/stm32/app/include/rf/phase_states_2_4ghz.h new file mode 100644 index 0000000..0478f7b --- /dev/null +++ b/stm32/app/include/rf/phase_states_2_4ghz.h @@ -0,0 +1,278 @@ +#ifndef RF_PHASE_STATES_2_4_GHZ_H +#define RF_PHASE_STATES_2_4_GHZ_H + +#include + +/* + * Calibrated PE44820 phase-control words from the 2.4 GHz worksheet in + * PE44820_Lookup_3Feb2025.xlsx. + * + * The enum value is the complete 9-bit PE44820 control word: OPT in + * bit 8 and D7:D0 in bits 7:0. The enum suffix is the logical phase-state + * index sent over CAN; the suffix is not necessarily the enum value. + * + * A reviewable CSV export is stored at docs/PE44820_Lookup_2.4GHz.csv. + */ +#define PHASE_LOOKUP_2_4_GHZ_STATE_COUNT 256u +#define PHASE_LOOKUP_2_4_GHZ_FREQUENCY_MHZ 2400u + +typedef enum optimizedPhaseState { + OPTIMIZED_STATE_0 = 0b000000000, + OPTIMIZED_STATE_1 = 0b100000001, + OPTIMIZED_STATE_2 = 0b000000010, + OPTIMIZED_STATE_3 = 0b100000011, + OPTIMIZED_STATE_4 = 0b000000100, + OPTIMIZED_STATE_5 = 0b100000101, + OPTIMIZED_STATE_6 = 0b000000110, + OPTIMIZED_STATE_7 = 0b000000111, + OPTIMIZED_STATE_8 = 0b100000111, + OPTIMIZED_STATE_9 = 0b000001000, + OPTIMIZED_STATE_10 = 0b000001001, + OPTIMIZED_STATE_11 = 0b000001010, + OPTIMIZED_STATE_12 = 0b000001011, + OPTIMIZED_STATE_13 = 0b100001011, + OPTIMIZED_STATE_14 = 0b000001100, + OPTIMIZED_STATE_15 = 0b000001101, + OPTIMIZED_STATE_16 = 0b000010000, + OPTIMIZED_STATE_17 = 0b000010001, + OPTIMIZED_STATE_18 = 0b000010010, + OPTIMIZED_STATE_19 = 0b100010011, + OPTIMIZED_STATE_20 = 0b000010100, + OPTIMIZED_STATE_21 = 0b000010101, + OPTIMIZED_STATE_22 = 0b000010110, + OPTIMIZED_STATE_23 = 0b000010111, + OPTIMIZED_STATE_24 = 0b100010111, + OPTIMIZED_STATE_25 = 0b000011000, + OPTIMIZED_STATE_26 = 0b000011001, + OPTIMIZED_STATE_27 = 0b000011010, + OPTIMIZED_STATE_28 = 0b000011011, + OPTIMIZED_STATE_29 = 0b100011011, + OPTIMIZED_STATE_30 = 0b000011100, + OPTIMIZED_STATE_31 = 0b000011101, + OPTIMIZED_STATE_32 = 0b000100000, + OPTIMIZED_STATE_33 = 0b000100001, + OPTIMIZED_STATE_34 = 0b100011111, + OPTIMIZED_STATE_35 = 0b000100011, + OPTIMIZED_STATE_36 = 0b000100100, + OPTIMIZED_STATE_37 = 0b000100101, + OPTIMIZED_STATE_38 = 0b000100110, + OPTIMIZED_STATE_39 = 0b100100110, + OPTIMIZED_STATE_40 = 0b100100111, + OPTIMIZED_STATE_41 = 0b000101000, + OPTIMIZED_STATE_42 = 0b100101000, + OPTIMIZED_STATE_43 = 0b100101001, + OPTIMIZED_STATE_44 = 0b000101010, + OPTIMIZED_STATE_45 = 0b100101011, + OPTIMIZED_STATE_46 = 0b000101100, + OPTIMIZED_STATE_47 = 0b000101101, + OPTIMIZED_STATE_48 = 0b100101101, + OPTIMIZED_STATE_49 = 0b100110000, + OPTIMIZED_STATE_50 = 0b100101111, + OPTIMIZED_STATE_51 = 0b100110010, + OPTIMIZED_STATE_52 = 0b100110011, + OPTIMIZED_STATE_53 = 0b100110100, + OPTIMIZED_STATE_54 = 0b100110101, + OPTIMIZED_STATE_55 = 0b100110110, + OPTIMIZED_STATE_56 = 0b100110111, + OPTIMIZED_STATE_57 = 0b000111000, + OPTIMIZED_STATE_58 = 0b100111000, + OPTIMIZED_STATE_59 = 0b100111001, + OPTIMIZED_STATE_60 = 0b100111010, + OPTIMIZED_STATE_61 = 0b100111011, + OPTIMIZED_STATE_62 = 0b000111100, + OPTIMIZED_STATE_63 = 0b000111101, + OPTIMIZED_STATE_64 = 0b101000000, + OPTIMIZED_STATE_65 = 0b100111110, + OPTIMIZED_STATE_66 = 0b101000010, + OPTIMIZED_STATE_67 = 0b101000011, + OPTIMIZED_STATE_68 = 0b001000100, + OPTIMIZED_STATE_69 = 0b101000101, + OPTIMIZED_STATE_70 = 0b001000110, + OPTIMIZED_STATE_71 = 0b101000111, + OPTIMIZED_STATE_72 = 0b101000111, + OPTIMIZED_STATE_73 = 0b001001000, + OPTIMIZED_STATE_74 = 0b101001000, + OPTIMIZED_STATE_75 = 0b001001010, + OPTIMIZED_STATE_76 = 0b001001011, + OPTIMIZED_STATE_77 = 0b101001011, + OPTIMIZED_STATE_78 = 0b001001100, + OPTIMIZED_STATE_79 = 0b001001101, + OPTIMIZED_STATE_80 = 0b001010000, + OPTIMIZED_STATE_81 = 0b101010001, + OPTIMIZED_STATE_82 = 0b001010010, + OPTIMIZED_STATE_83 = 0b101010011, + OPTIMIZED_STATE_84 = 0b001010100, + OPTIMIZED_STATE_85 = 0b101010100, + OPTIMIZED_STATE_86 = 0b001010110, + OPTIMIZED_STATE_87 = 0b001010111, + OPTIMIZED_STATE_88 = 0b101010111, + OPTIMIZED_STATE_89 = 0b001011000, + OPTIMIZED_STATE_90 = 0b001011001, + OPTIMIZED_STATE_91 = 0b001011010, + OPTIMIZED_STATE_92 = 0b001011011, + OPTIMIZED_STATE_93 = 0b101011011, + OPTIMIZED_STATE_94 = 0b001011100, + OPTIMIZED_STATE_95 = 0b001011101, + OPTIMIZED_STATE_96 = 0b001100000, + OPTIMIZED_STATE_97 = 0b101100001, + OPTIMIZED_STATE_98 = 0b101011111, + OPTIMIZED_STATE_99 = 0b101100011, + OPTIMIZED_STATE_100 = 0b001100100, + OPTIMIZED_STATE_101 = 0b001100101, + OPTIMIZED_STATE_102 = 0b101100101, + OPTIMIZED_STATE_103 = 0b001100111, + OPTIMIZED_STATE_104 = 0b101100111, + OPTIMIZED_STATE_105 = 0b001101000, + OPTIMIZED_STATE_106 = 0b001101001, + OPTIMIZED_STATE_107 = 0b101101001, + OPTIMIZED_STATE_108 = 0b001101011, + OPTIMIZED_STATE_109 = 0b101101011, + OPTIMIZED_STATE_110 = 0b101101011, + OPTIMIZED_STATE_111 = 0b001101100, + OPTIMIZED_STATE_112 = 0b101101100, + OPTIMIZED_STATE_113 = 0b101110000, + OPTIMIZED_STATE_114 = 0b001110010, + OPTIMIZED_STATE_115 = 0b101101111, + OPTIMIZED_STATE_116 = 0b101110011, + OPTIMIZED_STATE_117 = 0b001110100, + OPTIMIZED_STATE_118 = 0b101110101, + OPTIMIZED_STATE_119 = 0b001110110, + OPTIMIZED_STATE_120 = 0b101110111, + OPTIMIZED_STATE_121 = 0b001111000, + OPTIMIZED_STATE_122 = 0b001111001, + OPTIMIZED_STATE_123 = 0b101111001, + OPTIMIZED_STATE_124 = 0b001111010, + OPTIMIZED_STATE_125 = 0b101111011, + OPTIMIZED_STATE_126 = 0b101111011, + OPTIMIZED_STATE_127 = 0b001111100, + OPTIMIZED_STATE_128 = 0b001111101, + OPTIMIZED_STATE_129 = 0b101111101, + OPTIMIZED_STATE_130 = 0b001111110, + OPTIMIZED_STATE_131 = 0b010000000, + OPTIMIZED_STATE_132 = 0b110000001, + OPTIMIZED_STATE_133 = 0b010000010, + OPTIMIZED_STATE_134 = 0b110000011, + OPTIMIZED_STATE_135 = 0b010000100, + OPTIMIZED_STATE_136 = 0b110000101, + OPTIMIZED_STATE_137 = 0b010000110, + OPTIMIZED_STATE_138 = 0b010000111, + OPTIMIZED_STATE_139 = 0b110000111, + OPTIMIZED_STATE_140 = 0b010001000, + OPTIMIZED_STATE_141 = 0b010001001, + OPTIMIZED_STATE_142 = 0b010001010, + OPTIMIZED_STATE_143 = 0b010001011, + OPTIMIZED_STATE_144 = 0b110001011, + OPTIMIZED_STATE_145 = 0b010001100, + OPTIMIZED_STATE_146 = 0b010001101, + OPTIMIZED_STATE_147 = 0b010010000, + OPTIMIZED_STATE_148 = 0b110001110, + OPTIMIZED_STATE_149 = 0b010010010, + OPTIMIZED_STATE_150 = 0b010010011, + OPTIMIZED_STATE_151 = 0b010010100, + OPTIMIZED_STATE_152 = 0b110010100, + OPTIMIZED_STATE_153 = 0b110010101, + OPTIMIZED_STATE_154 = 0b110010110, + OPTIMIZED_STATE_155 = 0b110010111, + OPTIMIZED_STATE_156 = 0b010011000, + OPTIMIZED_STATE_157 = 0b010011001, + OPTIMIZED_STATE_158 = 0b010011010, + OPTIMIZED_STATE_159 = 0b010011011, + OPTIMIZED_STATE_160 = 0b110011011, + OPTIMIZED_STATE_161 = 0b010011100, + OPTIMIZED_STATE_162 = 0b010011101, + OPTIMIZED_STATE_163 = 0b010100000, + OPTIMIZED_STATE_164 = 0b010011111, + OPTIMIZED_STATE_165 = 0b010100010, + OPTIMIZED_STATE_166 = 0b110100011, + OPTIMIZED_STATE_167 = 0b010100100, + OPTIMIZED_STATE_168 = 0b110100101, + OPTIMIZED_STATE_169 = 0b010100110, + OPTIMIZED_STATE_170 = 0b010100111, + OPTIMIZED_STATE_171 = 0b110100111, + OPTIMIZED_STATE_172 = 0b010101000, + OPTIMIZED_STATE_173 = 0b010101001, + OPTIMIZED_STATE_174 = 0b010101010, + OPTIMIZED_STATE_175 = 0b110101010, + OPTIMIZED_STATE_176 = 0b110101011, + OPTIMIZED_STATE_177 = 0b010101100, + OPTIMIZED_STATE_178 = 0b110101101, + OPTIMIZED_STATE_179 = 0b010101110, + OPTIMIZED_STATE_180 = 0b010101111, + OPTIMIZED_STATE_181 = 0b010110010, + OPTIMIZED_STATE_182 = 0b010110011, + OPTIMIZED_STATE_183 = 0b010110100, + OPTIMIZED_STATE_184 = 0b010110101, + OPTIMIZED_STATE_185 = 0b010110110, + OPTIMIZED_STATE_186 = 0b010110111, + OPTIMIZED_STATE_187 = 0b110110111, + OPTIMIZED_STATE_188 = 0b010111000, + OPTIMIZED_STATE_189 = 0b010111001, + OPTIMIZED_STATE_190 = 0b010111010, + OPTIMIZED_STATE_191 = 0b010111011, + OPTIMIZED_STATE_192 = 0b110111011, + OPTIMIZED_STATE_193 = 0b010111100, + OPTIMIZED_STATE_194 = 0b110111101, + OPTIMIZED_STATE_195 = 0b010111110, + OPTIMIZED_STATE_196 = 0b111000001, + OPTIMIZED_STATE_197 = 0b011000010, + OPTIMIZED_STATE_198 = 0b111000011, + OPTIMIZED_STATE_199 = 0b011000100, + OPTIMIZED_STATE_200 = 0b011000101, + OPTIMIZED_STATE_201 = 0b111000101, + OPTIMIZED_STATE_202 = 0b011000111, + OPTIMIZED_STATE_203 = 0b111000111, + OPTIMIZED_STATE_204 = 0b011001000, + OPTIMIZED_STATE_205 = 0b111001000, + OPTIMIZED_STATE_206 = 0b111001001, + OPTIMIZED_STATE_207 = 0b011001010, + OPTIMIZED_STATE_208 = 0b111001011, + OPTIMIZED_STATE_209 = 0b111001011, + OPTIMIZED_STATE_210 = 0b011001100, + OPTIMIZED_STATE_211 = 0b011001101, + OPTIMIZED_STATE_212 = 0b011010001, + OPTIMIZED_STATE_213 = 0b011010010, + OPTIMIZED_STATE_214 = 0b111001111, + OPTIMIZED_STATE_215 = 0b111010011, + OPTIMIZED_STATE_216 = 0b011010100, + OPTIMIZED_STATE_217 = 0b111010101, + OPTIMIZED_STATE_218 = 0b011010110, + OPTIMIZED_STATE_219 = 0b111010111, + OPTIMIZED_STATE_220 = 0b011011000, + OPTIMIZED_STATE_221 = 0b011011000, + OPTIMIZED_STATE_222 = 0b111011001, + OPTIMIZED_STATE_223 = 0b011011010, + OPTIMIZED_STATE_224 = 0b111011011, + OPTIMIZED_STATE_225 = 0b111011011, + OPTIMIZED_STATE_226 = 0b011011100, + OPTIMIZED_STATE_227 = 0b011011101, + OPTIMIZED_STATE_228 = 0b111100000, + OPTIMIZED_STATE_229 = 0b111011110, + OPTIMIZED_STATE_230 = 0b111100010, + OPTIMIZED_STATE_231 = 0b111100011, + OPTIMIZED_STATE_232 = 0b011100100, + OPTIMIZED_STATE_233 = 0b111100100, + OPTIMIZED_STATE_234 = 0b011100110, + OPTIMIZED_STATE_235 = 0b011100111, + OPTIMIZED_STATE_236 = 0b111100111, + OPTIMIZED_STATE_237 = 0b011101000, + OPTIMIZED_STATE_238 = 0b011101001, + OPTIMIZED_STATE_239 = 0b111101001, + OPTIMIZED_STATE_240 = 0b011101011, + OPTIMIZED_STATE_241 = 0b111101011, + OPTIMIZED_STATE_242 = 0b111101011, + OPTIMIZED_STATE_243 = 0b011101100, + OPTIMIZED_STATE_244 = 0b011101101, + OPTIMIZED_STATE_245 = 0b011110001, + OPTIMIZED_STATE_246 = 0b011110010, + OPTIMIZED_STATE_247 = 0b111110011, + OPTIMIZED_STATE_248 = 0b111101111, + OPTIMIZED_STATE_249 = 0b011110100, + OPTIMIZED_STATE_250 = 0b111110101, + OPTIMIZED_STATE_251 = 0b011110110, + OPTIMIZED_STATE_252 = 0b111110111, + OPTIMIZED_STATE_253 = 0b011111000, + OPTIMIZED_STATE_254 = 0b011111001, + OPTIMIZED_STATE_255 = 0b111111001, +} optimizedPhaseState_e; + +#endif diff --git a/stm32/app/include/rf/plan.h b/stm32/app/include/rf/plan.h new file mode 100644 index 0000000..845d5cb --- /dev/null +++ b/stm32/app/include/rf/plan.h @@ -0,0 +1,48 @@ +#ifndef RF_PLAN_H +#define RF_PLAN_H + +#include "can/protocol.h" + +#include +#include + +/* + * Worst case for a four-channel safe transition: + * 4 writes to apply 23 dB + 4 phase writes + 4 final VGA writes. + */ +#define RF_PLAN_MAX_OPERATIONS 12u + +typedef enum rf_operation_type { + RF_OPERATION_PHASE = 0, + RF_OPERATION_VGA = 1 +} rf_operation_type_t; + +typedef struct rf_operation { + rf_operation_type_t type; + uint8_t channel; /* zero-based RF channel 0..3 */ + uint16_t command; +} rf_operation_t; + +/* Array index 0..3 always means physical RF channel 1..4. */ +typedef struct rf_state { + uint8_t phase_states[RF_CHANNEL_COUNT]; + uint8_t attenuation_db[RF_CHANNEL_COUNT]; +} rf_state_t; + +typedef struct rf_plan { + rf_operation_t operations[RF_PLAN_MAX_OPERATIONS]; + uint8_t operation_count; + rf_state_t resulting_state; +} rf_plan_t; + +bool rf_plan_startup( + uint8_t phase_state, + uint8_t attenuation_db, + rf_plan_t *plan); + +can_command_result_t rf_plan_command( + const can_command_t *command, + const rf_state_t *current_state, + rf_plan_t *plan); + +#endif diff --git a/stm32/app/include/rf/vga.h b/stm32/app/include/rf/vga.h new file mode 100644 index 0000000..b1f4168 --- /dev/null +++ b/stm32/app/include/rf/vga.h @@ -0,0 +1,14 @@ +#ifndef RF_VGA_H +#define RF_VGA_H + +#include "platform/spi_guard.h" + +#include + +void f0480spisetup(void); +spi_guard_status_t vga_write( + uint8_t channel, + uint8_t command, + uint32_t timeout_millis); + +#endif diff --git a/stm32/app/src/UnitTests/RX1UnitTests.c b/stm32/app/src/UnitTests/RX1UnitTests.c deleted file mode 100644 index 89886e9..0000000 --- a/stm32/app/src/UnitTests/RX1UnitTests.c +++ /dev/null @@ -1,130 +0,0 @@ -/* -This file contains the following unit tests: -1. Pin Toggle / Sign of Life Unit Test -2. Phase Shifter SPI command transmission unit test. -3. VGA SPI command transmission unit Test. -4. VGA + Phase Shifter SPI command transmission at 'same time' (within clock cycle) -*/ - - -//Unit test 1: Pin Toggle / Sign of Life Unit Test -//Image -/* -int main(void) -{ - rcc_clock_setup_in_hse_8mhz_out_48mhz(); - rcc_periph_clock_enable(RCC_GPIOA); - gpio_mode_setup(gpioPort, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, gpioPin); // required - - while (1) - { - SignOfLifeSignalAtMaximumSpeed(SIGNOFLIFEPORT, SIGNOFLIFEPIN); //toggle at max speed by setting and resetting BSRR for the pin - } - - return 0; -} -*/ - -//Unit test 2: Phase Shifter SPI command transmission unit test. -//Images: -// - RX1_RecreatePE448SpiTiming.png & RX1_RecreatePE448SpiTiming(2).png ("dont care" after LE set) -// - RX1_PE448SpiCmd.png (proof of correct cmd format) -/* -int main(void) -{ - rcc_clock_setup_in_hse_8mhz_out_48mhz(); - pe448spisetup(); - spi_enable(SPI2); - - //Other commands to try - // uint16_t command2 = MakePSCommand(73.1f, 0, 0b0011); //73.1° = 45° + 22.5° + 5.6° - // uint16_t command3 = MakePSCommand(128.7f, 0, 0b0011); //128.7° = 90° + 22.5° + 11.2° + 5.6° - // uint16_t command4 = MakePSCommand(256.8f, 0, 0b0011); //256.8° = 180° + 45° + 22.5° + 5.6° + 2.8° + 1.4° - // uint16_t command5 = MakePSCommand(33.7f, 0, 0b0011); ////33.7° = 22.5° + 11.2° - // uint16_t SingleBitCommand= 0b0010000000 for seeing unit impulse like signal propogate - - # create command - float requestedShift_deg = 205.3f; - bool optBit = 0; - uint8_t unitAddressWord = 0b0011; - uint16_t command = MakePSCommand(requestedShift_deg, optBit, unitAddressWord); - - while (1) - { - gpio_clear(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); //set the cs low - spi_send(SPI2, command); - phaseShifterResponse =spi_read(SPI2); - gpio_set(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); - //then send another command. - spi_send(SPI2, 0b0011111100000); //LE indifference: "dont care" about this second command. whatever we send after LE goes high should be ignored. Tested by sending a command after LE goes high and ensuring that the response is not affected - break; - } - return 0; -} -*/ - - -//Unit test 3: VGA SPI command transmission unit Test. -/* -int main(void) -{ - rcc_clock_setup_in_hse_8mhz_out_48mhz(); - f0480spisetup(); - spi_enable(SPI1); - volatile SupportedAttenuationCommand_t command = ATTEN_23DB; //try the max attenuation. //ATTEN_23DB = 0b01100000 - - //other commands to try - // volatile SupportedAttenuationCommand_t command2 = ATTEN_16DB; - // volatile SupportedAttenuationCommand_t command3 = ATTEN_8DB; - // volatile SupportedAttenuationCommand_t command4 = ATTEN_4DB; - // volatile SupportedAttenuationCommand_t command5 = ATTEN_2DB; - - volatile uint16_t response = 0; - - while (1) - { - gpio_clear(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - spi_send(SPI1, command); - response =spi_read(SPI1); - gpio_set(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - break; - } - return 0; -} - - -//Unit test 4: sending a command to the phase shifter and the VGA at the same time. -int main(void) -{ - rcc_clock_setup_in_hse_8mhz_out_48mhz(); - f0480spisetup(); - pe448spisetup(); - spi_enable(SPI2); - spi_enable(SPI1); - - SupportedAttenuationCommand_t commandVGA = ATTEN_23DB; //try the max attenuation. //ATTEN_23DB = 0b01100000 - uint16_t commandPS = MakePSCommand(205.3f, 0, 0b0011); //requested shift of 205.3 degrees, opt bit 0, unit address 0b0011 - - //other commands to try - // SupportedAttenuationCommand_t command2 = ATTEN_16DB; - // SupportedAttenuationCommand_t command3 = ATTEN_8DB; - // SupportedAttenuationCommand_t command4 = ATTEN_4DB; - // SupportedAttenuationCommand_t command5 = ATTEN_2DB; - - - gpio_clear(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - gpio_clear(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); - - while (1) - { - spi_send(SPI2, commandPS); - spi_send(SPI1, commandVGA); - gpio_set(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - gpio_set(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); - break; - } - return 0; -} - -*/ - diff --git a/stm32/app/src/beamforming_protocol.c b/stm32/app/src/beamforming_protocol.c deleted file mode 100644 index 192e58a..0000000 --- a/stm32/app/src/beamforming_protocol.c +++ /dev/null @@ -1,69 +0,0 @@ -#include "beamforming_protocol.h" - -#include - -uint16_t beamforming_reverse_bits(uint16_t word, uint8_t bit_count) -{ - uint16_t reversed = 0u; - - for (uint8_t bit = 0u; bit < bit_count; ++bit) { - reversed = (uint16_t)((reversed << 1u) | (word & 1u)); - word >>= 1u; - } - - return reversed; -} - -bool phase_command_from_state(uint8_t phase_state, uint8_t unit_address, uint16_t *command) -{ - if ((command == NULL) || (unit_address > 0x0fu)) { - return false; - } - - const uint16_t phase_field = beamforming_reverse_bits(phase_state, 8u); - const uint16_t address_field = beamforming_reverse_bits(unit_address, 4u); - const uint16_t option_field = - ((phase_state & PHASE_OPTION_STATE_BIT) != 0u) ? PHASE_COMMAND_OPTION_MASK : 0u; - - *command = (uint16_t)((phase_field << 5u) | option_field | address_field); - return true; -} - -bool phase_state_from_millidegrees( - uint32_t requested_shift_millidegrees, - uint8_t *phase_state) -{ - if (phase_state == NULL - || requested_shift_millidegrees > PHASE_FULL_TURN_MILLIDEGREES) { - return false; - } - - const uint64_t scaled = - ((uint64_t)requested_shift_millidegrees * PHASE_STATE_COUNT) - + (PHASE_FULL_TURN_MILLIDEGREES / 2u); - *phase_state = (uint8_t)((scaled / PHASE_FULL_TURN_MILLIDEGREES) & 0xffu); - return true; -} - -bool phase_command_from_millidegrees( - uint32_t requested_shift_millidegrees, - uint8_t unit_address, - uint16_t *command) -{ - uint8_t phase_state = 0u; - if (!phase_state_from_millidegrees(requested_shift_millidegrees, &phase_state)) { - return false; - } - - return phase_command_from_state(phase_state, unit_address, command); -} - -bool vga_command_from_attenuation(uint8_t attenuation_db, uint8_t *command) -{ - if ((command == NULL) || (attenuation_db > VGA_MAX_ATTENUATION_DB)) { - return false; - } - - *command = (uint8_t)(attenuation_db << VGA_ATTENUATION_SHIFT); - return true; -} diff --git a/stm32/app/src/can_bus.c b/stm32/app/src/can/bus.c similarity index 92% rename from stm32/app/src/can_bus.c rename to stm32/app/src/can/bus.c index c9816f3..6cbd6a7 100644 --- a/stm32/app/src/can_bus.c +++ b/stm32/app/src/can/bus.c @@ -1,10 +1,12 @@ -#include "can_bus.h" +#include "can/bus.h" -#include "can_filter.h" -#include "can_queue.h" -#include "can_tx_queue.h" -#include "clock_control.h" -#include "firmware_config.h" +#include "platform/board.h" + +#include "filter.h" +#include "can/rx_queue.h" +#include "can/tx_queue.h" +#include "platform/clock.h" +#include "platform/firmware_config.h" #include #include @@ -16,13 +18,6 @@ #include #include -#define CAN_RX_PORT GPIOA -#define CAN_RX_PIN GPIO11 -#define CAN_TX_PORT GPIOA -#define CAN_TX_PIN GPIO12 -#define CAN_GPIO_ALTERNATE_FUNCTION GPIO_AF4 -#define CAN_FILTER_SELF_BANK 0u -#define CAN_FILTER_BROADCAST_BANK 1u #define CAN_RECEIVE_FIFO_INDEX 0u #define CAN_BITRATE_PRESCALER \ (FIRMWARE_CORE_CLOCK_HZ / (CAN_BUS_BITRATE * CAN_TIME_QUANTA_PER_BIT)) @@ -36,8 +31,8 @@ _Static_assert(CAN_BITRATE_PRESCALER == 6u, "unexpected 500 kbit/s CAN prescaler (CAN_TSR_RQCP0 | CAN_TSR_RQCP1 | CAN_TSR_RQCP2) /* - * The prioritized TX queue itself lives in can_tx_queue.c/h (host-testable, - * no libopencm3). can_bus.c owns the bxCAN register/ISR integration around it: + * The prioritized TX queue lives in can/tx_queue.c (host-testable, no + * libopencm3). This file owns the bxCAN register/ISR integration around it: * producer = main (interrupts masked), consumer = cec_can_isr(). */ static can_rx_queue_t receive_queue; @@ -160,19 +155,19 @@ bool can_bus_setup(uint8_t self_node) rcc_periph_clock_enable(RCC_CAN1); gpio_mode_setup( - CAN_RX_PORT, + BOARD_CAN_PORT, GPIO_MODE_AF, GPIO_PUPD_PULLUP, - CAN_RX_PIN | CAN_TX_PIN); + BOARD_CAN_RX_PIN | BOARD_CAN_TX_PIN); gpio_set_af( - CAN_RX_PORT, - CAN_GPIO_ALTERNATE_FUNCTION, - CAN_RX_PIN | CAN_TX_PIN); + BOARD_CAN_PORT, + BOARD_CAN_AF, + BOARD_CAN_RX_PIN | BOARD_CAN_TX_PIN); gpio_set_output_options( - CAN_TX_PORT, + BOARD_CAN_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_HIGH, - CAN_TX_PIN); + BOARD_CAN_TX_PIN); can_reset(CAN1); if (can_init( diff --git a/stm32/app/src/can_filter.c b/stm32/app/src/can/filter.c similarity index 98% rename from stm32/app/src/can_filter.c rename to stm32/app/src/can/filter.c index 048c9ac..76bea60 100644 --- a/stm32/app/src/can_filter.c +++ b/stm32/app/src/can/filter.c @@ -1,6 +1,6 @@ -#include "can_filter.h" +#include "filter.h" -#include "can_protocol.h" +#include "can/protocol.h" #include diff --git a/stm32/app/include/can_filter.h b/stm32/app/src/can/filter.h similarity index 100% rename from stm32/app/include/can_filter.h rename to stm32/app/src/can/filter.h diff --git a/stm32/app/src/can_protocol.c b/stm32/app/src/can/protocol.c similarity index 64% rename from stm32/app/src/can_protocol.c rename to stm32/app/src/can/protocol.c index bf1fbdb..3616a65 100644 --- a/stm32/app/src/can_protocol.c +++ b/stm32/app/src/can/protocol.c @@ -1,4 +1,4 @@ -#include "can_protocol.h" +#include "can/protocol.h" #include #include @@ -6,17 +6,9 @@ _Static_assert(CAN_MESSAGE_TYPE_COUNT == 8, "The CAN ID allocates exactly three bits to message type."); -_Static_assert(CAN_RF_CHANNEL_COUNT == 4, +_Static_assert(RF_CHANNEL_COUNT == 4, "BeamControl protocol requires exactly four RF channels."); -_Static_assert(CAN_PROTOCOL_FEATURE_FLAGS <= UINT16_MAX, - "Protocol feature flags must fit in 16 bits."); - -static bool can_protocol_node_valid(uint8_t node) -{ - return node <= CAN_NODE_BROADCAST; -} - static void can_protocol_clear_frame(can_frame_t *frame) { memset(frame, 0, sizeof(*frame)); @@ -24,45 +16,16 @@ static void can_protocol_clear_frame(can_frame_t *frame) frame->remote = false; } -static can_decode_result_t can_protocol_validate_used_length( - const can_frame_t *frame, - uint8_t used_length) -{ - uint8_t index; - - if (frame->length < used_length) { - return CAN_DECODE_INVALID_LENGTH; - } - - /* - * Bytes after the command's semantic payload are reserved. They must be - * zero when present. - */ - for (index = used_length; index < frame->length; ++index) { - if (frame->data[index] != 0u) { - return CAN_DECODE_RESERVED_BYTES; - } - } - - return CAN_DECODE_OK; -} - static can_decode_result_t can_protocol_decode_enter_safe( const can_frame_t *frame, can_command_t *command) { - can_decode_result_t result = - can_protocol_validate_used_length( - frame, - CAN_ENTER_SAFE_USED_LENGTH); - - if (result != CAN_DECODE_OK) { - return result; + if (frame->length != CAN_ENTER_SAFE_LENGTH) { + return CAN_DECODE_INVALID_LENGTH; } - command->phase_address = frame->data[0]; - - if (command->phase_address > CAN_RF_CHANNEL_MAX) { + command->channel = frame->data[0]; + if (command->channel > RF_CHANNEL_MAX) { return CAN_DECODE_INVALID_PAYLOAD; } @@ -73,27 +36,36 @@ static can_decode_result_t can_protocol_decode_set_combined( const can_frame_t *frame, can_command_t *command) { - can_decode_result_t result = - can_protocol_validate_used_length( - frame, - CAN_SET_COMBINED_USED_LENGTH); + if (frame->length == CAN_SET_COMBINED_INDIVIDUAL_LENGTH) { + command->channel = frame->data[1]; + if (command->channel > RF_CHANNEL_MAX + || frame->data[2] > VGA_MAX_ATTENUATION_DB) { + return CAN_DECODE_INVALID_PAYLOAD; + } - if (result != CAN_DECODE_OK) { - return result; + command->phase_states[command->channel] = frame->data[0]; + command->attenuation_db[command->channel] = frame->data[2]; + command->bulk_update = false; + return CAN_DECODE_OK; } - command->phase_state = frame->data[0]; - command->phase_address = frame->data[1]; - command->attenuation_db = frame->data[2]; - - if (command->phase_address > CAN_RF_CHANNEL_MAX) { - return CAN_DECODE_INVALID_PAYLOAD; + if (frame->length != CAN_SET_COMBINED_BULK_LENGTH) { + return CAN_DECODE_INVALID_LENGTH; } - if (command->attenuation_db > CAN_VGA_ATTENUATION_MAX_DB) { - return CAN_DECODE_INVALID_PAYLOAD; + memcpy(command->phase_states, frame->data, RF_CHANNEL_COUNT); + memcpy( + command->attenuation_db, + &frame->data[RF_CHANNEL_COUNT], + RF_CHANNEL_COUNT); + + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + if (command->attenuation_db[channel] > VGA_MAX_ATTENUATION_DB) { + return CAN_DECODE_INVALID_PAYLOAD; + } } + command->bulk_update = true; return CAN_DECODE_OK; } @@ -101,22 +73,23 @@ static can_decode_result_t can_protocol_decode_set_phase( const can_frame_t *frame, can_command_t *command) { - can_decode_result_t result = - can_protocol_validate_used_length( - frame, - CAN_SET_PHASE_USED_LENGTH); + if (frame->length == CAN_SET_PHASE_INDIVIDUAL_LENGTH) { + command->channel = frame->data[1]; + if (command->channel > RF_CHANNEL_MAX) { + return CAN_DECODE_INVALID_PAYLOAD; + } - if (result != CAN_DECODE_OK) { - return result; + command->phase_states[command->channel] = frame->data[0]; + command->bulk_update = false; + return CAN_DECODE_OK; } - command->phase_state = frame->data[0]; - command->phase_address = frame->data[1]; - - if (command->phase_address > CAN_RF_CHANNEL_MAX) { - return CAN_DECODE_INVALID_PAYLOAD; + if (frame->length != CAN_SET_PHASE_BULK_LENGTH) { + return CAN_DECODE_INVALID_LENGTH; } + memcpy(command->phase_states, frame->data, RF_CHANNEL_COUNT); + command->bulk_update = true; return CAN_DECODE_OK; } @@ -124,36 +97,39 @@ static can_decode_result_t can_protocol_decode_set_vga( const can_frame_t *frame, can_command_t *command) { - can_decode_result_t result = - can_protocol_validate_used_length( - frame, - CAN_SET_VGA_USED_LENGTH); + if (frame->length == CAN_SET_VGA_INDIVIDUAL_LENGTH) { + command->channel = frame->data[1]; + if (command->channel > RF_CHANNEL_MAX + || frame->data[0] > VGA_MAX_ATTENUATION_DB) { + return CAN_DECODE_INVALID_PAYLOAD; + } - if (result != CAN_DECODE_OK) { - return result; + command->attenuation_db[command->channel] = frame->data[0]; + command->bulk_update = false; + return CAN_DECODE_OK; } - command->attenuation_db = frame->data[0]; + if (frame->length != CAN_SET_VGA_BULK_LENGTH) { + return CAN_DECODE_INVALID_LENGTH; + } - if (command->attenuation_db > CAN_VGA_ATTENUATION_MAX_DB) { - return CAN_DECODE_INVALID_PAYLOAD; + memcpy(command->attenuation_db, frame->data, RF_CHANNEL_COUNT); + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + if (command->attenuation_db[channel] > VGA_MAX_ATTENUATION_DB) { + return CAN_DECODE_INVALID_PAYLOAD; + } } + command->bulk_update = true; return CAN_DECODE_OK; } static can_decode_result_t can_protocol_decode_ping( const can_frame_t *frame) { - /* - * A normal legacy PING uses DLC 0. - * - * Zero-padded PING frames with DLC 1-8 are also valid, but every byte must - * be zero. - */ - return can_protocol_validate_used_length( - frame, - CAN_PING_USED_LENGTH); + return frame->length == CAN_PING_LENGTH + ? CAN_DECODE_OK + : CAN_DECODE_INVALID_LENGTH; } bool can_protocol_make_id( @@ -173,8 +149,7 @@ bool can_protocol_make_id( return false; } - if (!can_protocol_node_valid(destination) || - !can_protocol_node_valid(source)) { + if (destination > CAN_NODE_BROADCAST || source > CAN_NODE_BROADCAST) { return false; } @@ -283,14 +258,14 @@ can_decode_result_t can_protocol_decode_command( /* * STATUS, ACK and ERROR are receiver-to-controller message types and are - * not valid commands received by an BeamControl node. + * not valid commands received by a BeamControl node. */ if (type > CAN_MESSAGE_PING) { return CAN_DECODE_UNSUPPORTED_TYPE; } /* - * Protocol 1.1 permits only ENTER_SAFE at the broadcast destination. + * Only ENTER_SAFE is permitted at the broadcast destination. * * No response will be emitted for this result because the runtime's error * helpers already suppress responses to broadcast destinations. @@ -423,59 +398,19 @@ bool can_protocol_make_status( return false; } - /* - * Preserve the protocol 1.0 legacy STATUS layout exactly. - */ frame->length = CAN_STATUS_LENGTH; - frame->data[0] = (uint8_t)CAN_PROTOCOL_VERSION; - frame->data[1] = status->node_id; - frame->data[2] = status->phase_state; - frame->data[3] = status->phase_address; - frame->data[4] = status->attenuation_db; - frame->data[5] = status->health_flags; - frame->data[6] = (uint8_t)(status->rx_dropped & 0xffu); + frame->data[0] = (uint8_t)CAN_PROTOCOL_VERSION_MAJOR; + frame->data[1] = (uint8_t)CAN_PROTOCOL_VERSION_MINOR; + frame->data[2] = (uint8_t)CAN_PROTOCOL_VERSION_PATCH; + frame->data[3] = status->node_id; + frame->data[4] = status->health_flags; + frame->data[5] = (uint8_t)(status->rx_dropped & 0xffu); + frame->data[6] = (uint8_t)(status->tx_dropped & 0xffu); frame->data[7] = (uint8_t)(status->invalid_commands & 0xffu); return true; } -bool can_protocol_make_protocol_info_status( - uint8_t source_node, - uint8_t destination_node, - uint16_t sequence, - can_frame_t *frame) -{ - uint16_t feature_flags = CAN_PROTOCOL_FEATURE_FLAGS; - - if (frame == NULL) { - return false; - } - - can_protocol_clear_frame(frame); - - if (!can_protocol_make_id( - CAN_MESSAGE_STATUS, - destination_node, - source_node, - sequence, - &frame->id)) { - return false; - } - - frame->length = CAN_STATUS_LENGTH; - - frame->data[0] = CAN_STATUS_SUBTYPE_PROTOCOL_INFO; - frame->data[1] = (uint8_t)CAN_PROTOCOL_VERSION_MAJOR; - frame->data[2] = (uint8_t)CAN_PROTOCOL_VERSION_MINOR; - frame->data[3] = (uint8_t)CAN_PROTOCOL_VERSION_PATCH; - frame->data[4] = (uint8_t)(feature_flags & 0xffu); - frame->data[5] = (uint8_t)((feature_flags >> 8) & 0xffu); - frame->data[6] = source_node; - frame->data[7] = 0u; - - return true; -} - bool can_protocol_is_state_changing_type(can_message_type_t type) { switch (type) { @@ -495,18 +430,6 @@ bool can_protocol_is_state_changing_type(can_message_type_t type) } } -bool can_protocol_is_discovery_ping(const can_command_t *command) -{ - if (command == NULL) { - return false; - } - - return - (command->type == CAN_MESSAGE_PING) && - (command->sequence == CAN_PROTOCOL_DISCOVERY_SEQUENCE) && - (command->destination != CAN_NODE_BROADCAST); -} - can_command_result_t can_protocol_result_from_decode( can_decode_result_t result) { @@ -517,9 +440,6 @@ can_command_result_t can_protocol_result_from_decode( case CAN_DECODE_INVALID_LENGTH: return CAN_COMMAND_RESULT_INVALID_LENGTH; - case CAN_DECODE_RESERVED_BYTES: - return CAN_COMMAND_RESULT_RESERVED_BYTES; - case CAN_DECODE_BROADCAST_NOT_ALLOWED: return CAN_COMMAND_RESULT_BROADCAST_NOT_ALLOWED; diff --git a/stm32/app/src/can_runtime.c b/stm32/app/src/can/runtime.c similarity index 65% rename from stm32/app/src/can_runtime.c rename to stm32/app/src/can/runtime.c index cc4b9d2..b4d2561 100644 --- a/stm32/app/src/can_runtime.c +++ b/stm32/app/src/can/runtime.c @@ -1,11 +1,9 @@ -#include "can_runtime.h" +#include "can/runtime.h" -#include "PhaseShifter.h" -#include "Vga.h" -#include "can_bus.h" -#include "can_protocol.h" -#include "diagnostics.h" -#include "faults.h" +#include "can/bus.h" +#include "can/protocol.h" +#include "platform/diagnostics.h" +#include "rf/execute.h" #include #include @@ -13,15 +11,7 @@ #define CAN_SPI_TIMEOUT_MILLIS 2u -/* ------------------------------------------------------------------ */ -/* Protocol 1.1 replay cache (module scope). */ -/* */ -/* Stores the most recent successfully applied state-changing command */ -/* and its ACK so a controller retry (identical wire request) replays */ -/* the ACK without repeating the RF transition. The cache stores the */ -/* original request frame (length + bytes) so matching is byte-for- */ -/* byte, exactly as the v1.1 duplicate rule requires. */ -/* ------------------------------------------------------------------ */ +/* Replays the last successful unicast command without repeating RF writes. */ typedef struct can_replay_cache { bool valid; can_command_t command; @@ -52,32 +42,6 @@ static void increment_invalid_commands(can_runtime_t *runtime) } } -static firmware_fault_t phase_fault_from_status(spi_guard_status_t status) -{ - return (status == SPI_GUARD_TIMEOUT) - ? FIRMWARE_FAULT_PHASE_SPI_TIMEOUT - : FIRMWARE_FAULT_PHASE_SPI_ERROR; -} - -static firmware_fault_t vga_fault_from_status(spi_guard_status_t status) -{ - return (status == SPI_GUARD_TIMEOUT) - ? FIRMWARE_FAULT_VGA_SPI_TIMEOUT - : FIRMWARE_FAULT_VGA_SPI_ERROR; -} - -static void send_frame_if_possible( - const can_frame_t *frame, - can_tx_priority_t priority) -{ - (void)can_bus_send(frame, priority); -} - -static bool command_is_unicast(const can_command_t *command) -{ - return command->destination != CAN_NODE_BROADCAST; -} - static bool replay_command_key_equal( const can_command_t *left, const can_command_t *right) @@ -164,7 +128,7 @@ static void send_ack( const can_command_t *command, const can_frame_t *request) { - if (!command_is_unicast(command)) { + if (command->destination == CAN_NODE_BROADCAST) { return; } @@ -177,7 +141,7 @@ static void send_ack( &response)) { /* Cache before transmit: a retry must replay, not re-run the RF step. */ replay_cache_store_ack(command, request, &response); - send_frame_if_possible(&response, CAN_TX_PRIORITY_ACK); + (void)can_bus_send(&response, CAN_TX_PRIORITY_ACK); } } @@ -186,7 +150,7 @@ static void send_error_for_command( const can_command_t *command, can_command_result_t result) { - if (!command_is_unicast(command)) { + if (command->destination == CAN_NODE_BROADCAST) { return; } @@ -198,7 +162,7 @@ static void send_error_for_command( command->type, result, &response)) { - send_frame_if_possible(&response, CAN_TX_PRIORITY_ERROR); + (void)can_bus_send(&response, CAN_TX_PRIORITY_ERROR); } } @@ -235,7 +199,7 @@ static void send_error_for_frame( type, can_protocol_result_from_decode(decode_result), &response)) { - send_frame_if_possible(&response, CAN_TX_PRIORITY_ERROR); + (void)can_bus_send(&response, CAN_TX_PRIORITY_ERROR); } } @@ -267,17 +231,15 @@ static uint8_t health_flags(const can_runtime_t *runtime) static void send_status(const can_runtime_t *runtime, const can_command_t *command) { - if (!command_is_unicast(command)) { + if (command->destination == CAN_NODE_BROADCAST) { return; } const can_status_payload_t status = { .node_id = runtime->node_id, - .phase_state = runtime->state.phase_state, - .phase_address = runtime->state.phase_address, - .attenuation_db = runtime->state.attenuation_db, .health_flags = health_flags(runtime), .rx_dropped = can_bus_rx_dropped(), + .tx_dropped = can_bus_tx_dropped(), .invalid_commands = runtime->invalid_commands, }; @@ -288,89 +250,37 @@ static void send_status(const can_runtime_t *runtime, const can_command_t *comma command->sequence, &status, &response)) { - send_frame_if_possible(&response, CAN_TX_PRIORITY_REQUESTED_STATUS); + (void)can_bus_send(&response, CAN_TX_PRIORITY_REQUESTED_STATUS); } } -static void send_protocol_info_status( - const can_runtime_t *runtime, - const can_command_t *command) -{ - if (!command_is_unicast(command)) { - return; - } - - can_frame_t response = {0}; - if (can_protocol_make_protocol_info_status( - runtime->node_id, - command->source, - command->sequence, - &response)) { - send_frame_if_possible(&response, CAN_TX_PRIORITY_REQUESTED_STATUS); - } -} - -static void send_cached_response(const can_frame_t *frame) -{ - if (frame == NULL) { - return; - } - send_frame_if_possible(frame, CAN_TX_PRIORITY_ACK); -} - -static void apply_operation(can_runtime_t *runtime, const can_control_operation_t *operation) -{ - if (operation->type == CAN_CONTROL_OPERATION_VGA) { - const uint8_t command = (uint8_t)operation->command; - const spi_guard_status_t status = vga_write(command, CAN_SPI_TIMEOUT_MILLIS); - if (status != SPI_GUARD_OK) { - firmware_fail(vga_fault_from_status(status)); - } - runtime->vga_command = command; - return; - } - - const spi_guard_status_t status = - phase_shifter_write(operation->command, CAN_SPI_TIMEOUT_MILLIS); - if (status != SPI_GUARD_OK) { - firmware_fail(phase_fault_from_status(status)); - } - runtime->phase_command = operation->command; -} - -static void apply_plan(can_runtime_t *runtime, const can_control_plan_t *plan) -{ - for (uint8_t index = 0u; index < plan->operation_count; ++index) { - apply_operation(runtime, &plan->operations[index]); - } - - runtime->state = plan->resulting_state; - diagnostics_set_commands(runtime->phase_command, runtime->vga_command); -} - bool can_runtime_start( can_runtime_t *runtime, uint8_t node_id, firmware_clock_t clock_source, bool safe_lockout, - const can_control_state_t *initial_state, + const rf_state_t *initial_state, uint16_t initial_phase_command, uint8_t initial_vga_command) { if (runtime == NULL || initial_state == NULL || node_id < CAN_NODE_MIN - || node_id > CAN_NODE_MAX - || initial_state->phase_address > CAN_PHASE_ADDRESS_MAX - || initial_state->attenuation_db > CAN_VGA_ATTENUATION_MAX_DB) { + || node_id > CAN_NODE_MAX) { return false; } + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + if (initial_state->attenuation_db[channel] > VGA_MAX_ATTENUATION_DB) { + return false; + } + } + memset(runtime, 0, sizeof(*runtime)); replay_cache_invalidate(); runtime->state = *initial_state; - runtime->phase_command = initial_phase_command; - runtime->vga_command = initial_vga_command; + runtime->last_phase_command = initial_phase_command; + runtime->last_vga_command = initial_vga_command; runtime->node_id = node_id; runtime->clock_source = clock_source; runtime->safe_lockout = safe_lockout; @@ -408,7 +318,7 @@ bool can_runtime_service_next(can_runtime_t *runtime) replay_cache_lookup(&command, &frame, &cached_response); if (replay_result == CAN_REPLAY_HIT) { - send_cached_response(&cached_response); + (void)can_bus_send(&cached_response, CAN_TX_PRIORITY_ACK); return true; } if (replay_result == CAN_REPLAY_SEQUENCE_REUSE) { @@ -418,11 +328,7 @@ bool can_runtime_service_next(can_runtime_t *runtime) } if (command.type == CAN_MESSAGE_PING) { - /* Send the legacy STATUS first so v1.0 controllers see what they expect. */ send_status(runtime, &command); - if (can_protocol_is_discovery_ping(&command)) { - send_protocol_info_status(runtime, &command); - } return true; } @@ -431,22 +337,30 @@ bool can_runtime_service_next(can_runtime_t *runtime) return true; } - can_control_plan_t plan = {0}; + rf_plan_t plan = {0}; const can_command_result_t plan_result = - can_control_plan_command(&command, &runtime->state, &plan); + rf_plan_command(&command, &runtime->state, &plan); if (plan_result != CAN_COMMAND_RESULT_OK) { increment_invalid_commands(runtime); send_error_for_command(runtime, &command, plan_result); return true; } - apply_plan(runtime, &plan); + rf_execute_plan( + &plan, + CAN_SPI_TIMEOUT_MILLIS, + &runtime->last_phase_command, + &runtime->last_vga_command); + runtime->state = plan.resulting_state; + diagnostics_set_commands( + runtime->last_phase_command, + runtime->last_vga_command); /* * A successful broadcast ENTER_SAFE changes hardware state but has no * ACK to cache. Any older cached ACK is no longer safe to replay. */ - if (!command_is_unicast(&command)) { + if (command.destination == CAN_NODE_BROADCAST) { replay_cache_invalidate(); return true; } diff --git a/stm32/app/src/can_queue.c b/stm32/app/src/can/rx_queue.c similarity index 98% rename from stm32/app/src/can_queue.c rename to stm32/app/src/can/rx_queue.c index a048a51..5429b9e 100644 --- a/stm32/app/src/can_queue.c +++ b/stm32/app/src/can/rx_queue.c @@ -1,4 +1,4 @@ -#include "can_queue.h" +#include "can/rx_queue.h" #include #include diff --git a/stm32/app/src/can_tx_queue.c b/stm32/app/src/can/tx_queue.c similarity index 99% rename from stm32/app/src/can_tx_queue.c rename to stm32/app/src/can/tx_queue.c index 29ee5b5..0b5fd02 100644 --- a/stm32/app/src/can_tx_queue.c +++ b/stm32/app/src/can/tx_queue.c @@ -1,4 +1,4 @@ -#include "can_tx_queue.h" +#include "can/tx_queue.h" #include #include diff --git a/stm32/app/src/can_control.c b/stm32/app/src/can_control.c deleted file mode 100644 index ef43871..0000000 --- a/stm32/app/src/can_control.c +++ /dev/null @@ -1,149 +0,0 @@ -#include "can_control.h" - -#include "beamforming_protocol.h" - -#include -#include - -static can_command_result_t append_operation( - can_control_plan_t *plan, - can_control_operation_type_t type, - uint16_t command) -{ - if (plan->operation_count >= CAN_CONTROL_MAX_OPERATIONS) { - return CAN_COMMAND_RESULT_BUSY; - } - - plan->operations[plan->operation_count].type = type; - plan->operations[plan->operation_count].command = command; - ++plan->operation_count; - return CAN_COMMAND_RESULT_OK; -} - -static can_command_result_t append_phase( - uint8_t phase_state, - uint8_t phase_address, - can_control_plan_t *plan) -{ - uint16_t command = 0u; - if (!phase_command_from_state(phase_state, phase_address, &command)) { - return CAN_COMMAND_RESULT_INVALID_PAYLOAD; - } - - const can_command_result_t result = - append_operation(plan, CAN_CONTROL_OPERATION_PHASE, command); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - - plan->resulting_state.phase_state = phase_state; - plan->resulting_state.phase_address = phase_address; - return CAN_COMMAND_RESULT_OK; -} - -static can_command_result_t append_vga(uint8_t attenuation_db, can_control_plan_t *plan) -{ - uint8_t command = 0u; - if (!vga_command_from_attenuation(attenuation_db, &command)) { - return CAN_COMMAND_RESULT_INVALID_PAYLOAD; - } - - const can_command_result_t result = - append_operation(plan, CAN_CONTROL_OPERATION_VGA, command); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - - plan->resulting_state.attenuation_db = attenuation_db; - return CAN_COMMAND_RESULT_OK; -} - -static can_command_result_t plan_combined( - const can_command_t *command, - can_control_plan_t *plan) -{ - can_command_result_t result = append_vga(VGA_MAX_ATTENUATION_DB, plan); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - - result = append_phase(command->phase_state, command->phase_address, plan); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - - if (command->attenuation_db != VGA_MAX_ATTENUATION_DB) { - result = append_vga(command->attenuation_db, plan); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - } - - plan->resulting_state.attenuation_db = command->attenuation_db; - return CAN_COMMAND_RESULT_OK; -} - -static can_command_result_t plan_phase_safely( - const can_command_t *command, - const can_control_state_t *current_state, - can_control_plan_t *plan) -{ - if (current_state->attenuation_db == VGA_MAX_ATTENUATION_DB) { - return append_phase(command->phase_state, command->phase_address, plan); - } - - can_command_result_t result = append_vga(VGA_MAX_ATTENUATION_DB, plan); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - - result = append_phase(command->phase_state, command->phase_address, plan); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - - return append_vga(current_state->attenuation_db, plan); -} - -can_command_result_t can_control_plan_command( - const can_command_t *command, - const can_control_state_t *current_state, - can_control_plan_t *plan) -{ - if (command == NULL || current_state == NULL || plan == NULL) { - return CAN_COMMAND_RESULT_INVALID_PAYLOAD; - } - - memset(plan, 0, sizeof(*plan)); - plan->resulting_state = *current_state; - - switch (command->type) { - case CAN_MESSAGE_SET_PHASE: - return plan_phase_safely(command, current_state, plan); - - case CAN_MESSAGE_SET_VGA: - return append_vga(command->attenuation_db, plan); - - case CAN_MESSAGE_SET_COMBINED: - return plan_combined(command, plan); - - case CAN_MESSAGE_ENTER_SAFE: { - can_command_result_t result = append_vga(VGA_MAX_ATTENUATION_DB, plan); - if (result != CAN_COMMAND_RESULT_OK) { - return result; - } - return append_phase(0u, command->phase_address, plan); - } - - case CAN_MESSAGE_PING: - return CAN_COMMAND_RESULT_OK; - - case CAN_MESSAGE_STATUS: - case CAN_MESSAGE_ACK: - case CAN_MESSAGE_ERROR: - case CAN_MESSAGE_TYPE_COUNT: - return CAN_COMMAND_RESULT_UNSUPPORTED; - } - - return CAN_COMMAND_RESULT_UNSUPPORTED; -} diff --git a/stm32/app/src/main.c b/stm32/app/src/main.c index 1301c37..01d8799 100644 --- a/stm32/app/src/main.c +++ b/stm32/app/src/main.c @@ -1,14 +1,16 @@ -#include "PhaseShifter.h" -#include "Vga.h" -#include "beamforming_protocol.h" -#include "build_info.h" -#include "can_runtime.h" -#include "clock_control.h" -#include "diagnostics.h" -#include "faults.h" -#include "firmware_config.h" -#include "timebase.h" -#include "watchdog.h" +#include "can/runtime.h" +#include "platform/build_info.h" +#include "platform/clock.h" +#include "platform/diagnostics.h" +#include "platform/faults.h" +#include "platform/firmware_config.h" +#include "platform/timebase.h" +#include "platform/watchdog.h" +#include "rf/commands.h" +#include "rf/execute.h" +#include "rf/phase_shifter.h" +#include "rf/plan.h" +#include "rf/vga.h" #include @@ -19,24 +21,9 @@ #define SPI_TIMEOUT_MILLIS 2u #define OPERATIONAL_PHASE_MILLIDEGREES 205300u #define SAFE_PHASE_MILLIDEGREES 0u -#define PHASE_SHIFTER_ADDRESS 3u #define SAFE_VGA_ATTENUATION_DB 23u #define FIRMWARE_RESET_FLAG_MASK 0xfe000000u -static firmware_fault_t vga_fault_from_status(spi_guard_status_t status) -{ - return (status == SPI_GUARD_TIMEOUT) - ? FIRMWARE_FAULT_VGA_SPI_TIMEOUT - : FIRMWARE_FAULT_VGA_SPI_ERROR; -} - -static firmware_fault_t phase_fault_from_status(spi_guard_status_t status) -{ - return (status == SPI_GUARD_TIMEOUT) - ? FIRMWARE_FAULT_PHASE_SPI_TIMEOUT - : FIRMWARE_FAULT_PHASE_SPI_ERROR; -} - static void service_without_can(void) __attribute__((noreturn)); static void service_without_can(void) @@ -75,7 +62,7 @@ int main(void) const bool safe_lockout = diagnostics_lockout_required(); firmware_clock_t clock_source = FIRMWARE_CLOCK_UNKNOWN; - if (!clock_control_setup(&clock_source)) { + if (!clock_setup(&clock_source)) { firmware_fail(FIRMWARE_FAULT_CLOCK_STARTUP); } @@ -83,52 +70,56 @@ int main(void) diagnostics_set_clock(clock_source); diagnostics_set_state(FIRMWARE_STATE_CLOCK_READY); - vga_setup(); - phase_shifter_setup(); - - uint8_t vga_command = 0u; - if (!vga_command_from_attenuation(SAFE_VGA_ATTENUATION_DB, &vga_command)) { - firmware_fail(FIRMWARE_FAULT_VGA_COMMAND); - } + f0480spisetup(); + pe448spisetup(); const uint32_t requested_phase = safe_lockout ? SAFE_PHASE_MILLIDEGREES : OPERATIONAL_PHASE_MILLIDEGREES; uint8_t phase_state = 0u; - uint16_t phase_command = 0u; - if (!phase_state_from_millidegrees(requested_phase, &phase_state) - || !phase_command_from_state( + if (!phase_state_from_millidegrees(requested_phase, &phase_state)) { + firmware_fail(FIRMWARE_FAULT_PHASE_COMMAND); + } + + rf_plan_t startup_plan = {0}; + if (!rf_plan_startup( phase_state, - PHASE_SHIFTER_ADDRESS, - &phase_command)) { + SAFE_VGA_ATTENUATION_DB, + &startup_plan)) { firmware_fail(FIRMWARE_FAULT_PHASE_COMMAND); } - diagnostics_set_commands(phase_command, vga_command); + uint16_t phase_command = 0u; + uint8_t vga_command = 0u; - spi_guard_status_t status = vga_write(vga_command, SPI_TIMEOUT_MILLIS); - if (status != SPI_GUARD_OK) { - firmware_fail(vga_fault_from_status(status)); + /* The startup plan begins with one maximum-attenuation write per channel. */ + for (uint8_t index = 0u; index < RF_CHANNEL_COUNT; ++index) { + rf_execute_operation( + &startup_plan.operations[index], + SPI_TIMEOUT_MILLIS, + &phase_command, + &vga_command); } diagnostics_set_state(FIRMWARE_STATE_SAFE_OUTPUTS); - status = phase_shifter_write(phase_command, SPI_TIMEOUT_MILLIS); - if (status != SPI_GUARD_OK) { - firmware_fail(phase_fault_from_status(status)); + for (uint8_t index = RF_CHANNEL_COUNT; + index < startup_plan.operation_count; + ++index) { + rf_execute_operation( + &startup_plan.operations[index], + SPI_TIMEOUT_MILLIS, + &phase_command, + &vga_command); } + diagnostics_set_commands(phase_command, vga_command); - const can_control_state_t initial_state = { - .phase_state = phase_state, - .phase_address = PHASE_SHIFTER_ADDRESS, - .attenuation_db = SAFE_VGA_ATTENUATION_DB, - }; can_runtime_t runtime = {0}; if (!can_runtime_start( &runtime, BEAMFORMER_NODE_ID, clock_source, safe_lockout, - &initial_state, + &startup_plan.resulting_state, phase_command, vga_command)) { if (safe_lockout) { diff --git a/stm32/app/src/build_info.c b/stm32/app/src/platform/build_info.c similarity index 87% rename from stm32/app/src/build_info.c rename to stm32/app/src/platform/build_info.c index f867344..410cccd 100644 --- a/stm32/app/src/build_info.c +++ b/stm32/app/src/platform/build_info.c @@ -1,5 +1,5 @@ -#include "build_info.h" -#include "firmware_config.h" +#include "platform/build_info.h" +#include "platform/firmware_config.h" #ifndef FIRMWARE_REVISION #define FIRMWARE_REVISION "unknown" diff --git a/stm32/app/src/clock_control.c b/stm32/app/src/platform/clock.c similarity index 96% rename from stm32/app/src/clock_control.c rename to stm32/app/src/platform/clock.c index d3e7ed6..3681da1 100644 --- a/stm32/app/src/clock_control.c +++ b/stm32/app/src/platform/clock.c @@ -1,4 +1,4 @@ -#include "clock_control.h" +#include "platform/clock.h" #include #include @@ -82,7 +82,7 @@ static bool try_hsi48(void) return true; } -bool clock_control_setup(firmware_clock_t *clock_source) +bool clock_setup(firmware_clock_t *clock_source) { if (clock_source == NULL) { return false; diff --git a/stm32/app/src/diagnostic_record.c b/stm32/app/src/platform/diagnostic_record.c similarity index 99% rename from stm32/app/src/diagnostic_record.c rename to stm32/app/src/platform/diagnostic_record.c index f13ec5e..e01e38d 100644 --- a/stm32/app/src/diagnostic_record.c +++ b/stm32/app/src/platform/diagnostic_record.c @@ -1,4 +1,4 @@ -#include "diagnostic_record.h" +#include "platform/diagnostic_record.h" #include diff --git a/stm32/app/src/diagnostics.c b/stm32/app/src/platform/diagnostics.c similarity index 98% rename from stm32/app/src/diagnostics.c rename to stm32/app/src/platform/diagnostics.c index c5c90bc..242ad8f 100644 --- a/stm32/app/src/diagnostics.c +++ b/stm32/app/src/platform/diagnostics.c @@ -1,4 +1,4 @@ -#include "diagnostics.h" +#include "platform/diagnostics.h" volatile diagnostic_record_t FIRMWARE_DIAGNOSTICS __attribute__((section(".noinit.diagnostics"), used)); diff --git a/stm32/app/src/faults.c b/stm32/app/src/platform/faults.c similarity index 96% rename from stm32/app/src/faults.c rename to stm32/app/src/platform/faults.c index 5859792..c65fffa 100644 --- a/stm32/app/src/faults.c +++ b/stm32/app/src/platform/faults.c @@ -1,6 +1,6 @@ -#include "faults.h" +#include "platform/faults.h" -#include "diagnostics.h" +#include "platform/diagnostics.h" #include #include diff --git a/stm32/app/src/spi_guard.c b/stm32/app/src/platform/spi_guard.c similarity index 95% rename from stm32/app/src/spi_guard.c rename to stm32/app/src/platform/spi_guard.c index 3b448e8..98e2716 100644 --- a/stm32/app/src/spi_guard.c +++ b/stm32/app/src/platform/spi_guard.c @@ -1,6 +1,6 @@ -#include "spi_guard.h" +#include "platform/spi_guard.h" -#include "timebase.h" +#include "platform/timebase.h" #include diff --git a/stm32/app/src/timebase.c b/stm32/app/src/platform/timebase.c similarity index 95% rename from stm32/app/src/timebase.c rename to stm32/app/src/platform/timebase.c index 23a3e95..3773329 100644 --- a/stm32/app/src/timebase.c +++ b/stm32/app/src/platform/timebase.c @@ -1,4 +1,4 @@ -#include "timebase.h" +#include "platform/timebase.h" #include diff --git a/stm32/app/src/watchdog.c b/stm32/app/src/platform/watchdog.c similarity index 85% rename from stm32/app/src/watchdog.c rename to stm32/app/src/platform/watchdog.c index 54cd704..e0d2e6e 100644 --- a/stm32/app/src/watchdog.c +++ b/stm32/app/src/platform/watchdog.c @@ -1,4 +1,4 @@ -#include "watchdog.h" +#include "platform/watchdog.h" #include diff --git a/stm32/app/src/rf/commands.c b/stm32/app/src/rf/commands.c new file mode 100644 index 0000000..bfc0d17 --- /dev/null +++ b/stm32/app/src/rf/commands.c @@ -0,0 +1,139 @@ +#include "rf/commands.h" + +#include + +_Static_assert( + PHASE_LOOKUP_2_4_GHZ_STATE_COUNT == PHASE_STATE_COUNT, + "phase enum mapping must contain every CAN phase state"); + +/* + * Convert the sequential CAN stateWordTableIndex to its nonsequential enum value. + * A direct cast from the index to optimizedPhaseState_e is incorrect. + */ +static const optimizedPhaseState_e optimizedPhaseStateByIndex[ + PHASE_LOOKUP_2_4_GHZ_STATE_COUNT] = { + OPTIMIZED_STATE_0, OPTIMIZED_STATE_1, OPTIMIZED_STATE_2, OPTIMIZED_STATE_3, + OPTIMIZED_STATE_4, OPTIMIZED_STATE_5, OPTIMIZED_STATE_6, OPTIMIZED_STATE_7, + OPTIMIZED_STATE_8, OPTIMIZED_STATE_9, OPTIMIZED_STATE_10, OPTIMIZED_STATE_11, + OPTIMIZED_STATE_12, OPTIMIZED_STATE_13, OPTIMIZED_STATE_14, OPTIMIZED_STATE_15, + OPTIMIZED_STATE_16, OPTIMIZED_STATE_17, OPTIMIZED_STATE_18, OPTIMIZED_STATE_19, + OPTIMIZED_STATE_20, OPTIMIZED_STATE_21, OPTIMIZED_STATE_22, OPTIMIZED_STATE_23, + OPTIMIZED_STATE_24, OPTIMIZED_STATE_25, OPTIMIZED_STATE_26, OPTIMIZED_STATE_27, + OPTIMIZED_STATE_28, OPTIMIZED_STATE_29, OPTIMIZED_STATE_30, OPTIMIZED_STATE_31, + OPTIMIZED_STATE_32, OPTIMIZED_STATE_33, OPTIMIZED_STATE_34, OPTIMIZED_STATE_35, + OPTIMIZED_STATE_36, OPTIMIZED_STATE_37, OPTIMIZED_STATE_38, OPTIMIZED_STATE_39, + OPTIMIZED_STATE_40, OPTIMIZED_STATE_41, OPTIMIZED_STATE_42, OPTIMIZED_STATE_43, + OPTIMIZED_STATE_44, OPTIMIZED_STATE_45, OPTIMIZED_STATE_46, OPTIMIZED_STATE_47, + OPTIMIZED_STATE_48, OPTIMIZED_STATE_49, OPTIMIZED_STATE_50, OPTIMIZED_STATE_51, + OPTIMIZED_STATE_52, OPTIMIZED_STATE_53, OPTIMIZED_STATE_54, OPTIMIZED_STATE_55, + OPTIMIZED_STATE_56, OPTIMIZED_STATE_57, OPTIMIZED_STATE_58, OPTIMIZED_STATE_59, + OPTIMIZED_STATE_60, OPTIMIZED_STATE_61, OPTIMIZED_STATE_62, OPTIMIZED_STATE_63, + OPTIMIZED_STATE_64, OPTIMIZED_STATE_65, OPTIMIZED_STATE_66, OPTIMIZED_STATE_67, + OPTIMIZED_STATE_68, OPTIMIZED_STATE_69, OPTIMIZED_STATE_70, OPTIMIZED_STATE_71, + OPTIMIZED_STATE_72, OPTIMIZED_STATE_73, OPTIMIZED_STATE_74, OPTIMIZED_STATE_75, + OPTIMIZED_STATE_76, OPTIMIZED_STATE_77, OPTIMIZED_STATE_78, OPTIMIZED_STATE_79, + OPTIMIZED_STATE_80, OPTIMIZED_STATE_81, OPTIMIZED_STATE_82, OPTIMIZED_STATE_83, + OPTIMIZED_STATE_84, OPTIMIZED_STATE_85, OPTIMIZED_STATE_86, OPTIMIZED_STATE_87, + OPTIMIZED_STATE_88, OPTIMIZED_STATE_89, OPTIMIZED_STATE_90, OPTIMIZED_STATE_91, + OPTIMIZED_STATE_92, OPTIMIZED_STATE_93, OPTIMIZED_STATE_94, OPTIMIZED_STATE_95, + OPTIMIZED_STATE_96, OPTIMIZED_STATE_97, OPTIMIZED_STATE_98, OPTIMIZED_STATE_99, + OPTIMIZED_STATE_100, OPTIMIZED_STATE_101, OPTIMIZED_STATE_102, OPTIMIZED_STATE_103, + OPTIMIZED_STATE_104, OPTIMIZED_STATE_105, OPTIMIZED_STATE_106, OPTIMIZED_STATE_107, + OPTIMIZED_STATE_108, OPTIMIZED_STATE_109, OPTIMIZED_STATE_110, OPTIMIZED_STATE_111, + OPTIMIZED_STATE_112, OPTIMIZED_STATE_113, OPTIMIZED_STATE_114, OPTIMIZED_STATE_115, + OPTIMIZED_STATE_116, OPTIMIZED_STATE_117, OPTIMIZED_STATE_118, OPTIMIZED_STATE_119, + OPTIMIZED_STATE_120, OPTIMIZED_STATE_121, OPTIMIZED_STATE_122, OPTIMIZED_STATE_123, + OPTIMIZED_STATE_124, OPTIMIZED_STATE_125, OPTIMIZED_STATE_126, OPTIMIZED_STATE_127, + OPTIMIZED_STATE_128, OPTIMIZED_STATE_129, OPTIMIZED_STATE_130, OPTIMIZED_STATE_131, + OPTIMIZED_STATE_132, OPTIMIZED_STATE_133, OPTIMIZED_STATE_134, OPTIMIZED_STATE_135, + OPTIMIZED_STATE_136, OPTIMIZED_STATE_137, OPTIMIZED_STATE_138, OPTIMIZED_STATE_139, + OPTIMIZED_STATE_140, OPTIMIZED_STATE_141, OPTIMIZED_STATE_142, OPTIMIZED_STATE_143, + OPTIMIZED_STATE_144, OPTIMIZED_STATE_145, OPTIMIZED_STATE_146, OPTIMIZED_STATE_147, + OPTIMIZED_STATE_148, OPTIMIZED_STATE_149, OPTIMIZED_STATE_150, OPTIMIZED_STATE_151, + OPTIMIZED_STATE_152, OPTIMIZED_STATE_153, OPTIMIZED_STATE_154, OPTIMIZED_STATE_155, + OPTIMIZED_STATE_156, OPTIMIZED_STATE_157, OPTIMIZED_STATE_158, OPTIMIZED_STATE_159, + OPTIMIZED_STATE_160, OPTIMIZED_STATE_161, OPTIMIZED_STATE_162, OPTIMIZED_STATE_163, + OPTIMIZED_STATE_164, OPTIMIZED_STATE_165, OPTIMIZED_STATE_166, OPTIMIZED_STATE_167, + OPTIMIZED_STATE_168, OPTIMIZED_STATE_169, OPTIMIZED_STATE_170, OPTIMIZED_STATE_171, + OPTIMIZED_STATE_172, OPTIMIZED_STATE_173, OPTIMIZED_STATE_174, OPTIMIZED_STATE_175, + OPTIMIZED_STATE_176, OPTIMIZED_STATE_177, OPTIMIZED_STATE_178, OPTIMIZED_STATE_179, + OPTIMIZED_STATE_180, OPTIMIZED_STATE_181, OPTIMIZED_STATE_182, OPTIMIZED_STATE_183, + OPTIMIZED_STATE_184, OPTIMIZED_STATE_185, OPTIMIZED_STATE_186, OPTIMIZED_STATE_187, + OPTIMIZED_STATE_188, OPTIMIZED_STATE_189, OPTIMIZED_STATE_190, OPTIMIZED_STATE_191, + OPTIMIZED_STATE_192, OPTIMIZED_STATE_193, OPTIMIZED_STATE_194, OPTIMIZED_STATE_195, + OPTIMIZED_STATE_196, OPTIMIZED_STATE_197, OPTIMIZED_STATE_198, OPTIMIZED_STATE_199, + OPTIMIZED_STATE_200, OPTIMIZED_STATE_201, OPTIMIZED_STATE_202, OPTIMIZED_STATE_203, + OPTIMIZED_STATE_204, OPTIMIZED_STATE_205, OPTIMIZED_STATE_206, OPTIMIZED_STATE_207, + OPTIMIZED_STATE_208, OPTIMIZED_STATE_209, OPTIMIZED_STATE_210, OPTIMIZED_STATE_211, + OPTIMIZED_STATE_212, OPTIMIZED_STATE_213, OPTIMIZED_STATE_214, OPTIMIZED_STATE_215, + OPTIMIZED_STATE_216, OPTIMIZED_STATE_217, OPTIMIZED_STATE_218, OPTIMIZED_STATE_219, + OPTIMIZED_STATE_220, OPTIMIZED_STATE_221, OPTIMIZED_STATE_222, OPTIMIZED_STATE_223, + OPTIMIZED_STATE_224, OPTIMIZED_STATE_225, OPTIMIZED_STATE_226, OPTIMIZED_STATE_227, + OPTIMIZED_STATE_228, OPTIMIZED_STATE_229, OPTIMIZED_STATE_230, OPTIMIZED_STATE_231, + OPTIMIZED_STATE_232, OPTIMIZED_STATE_233, OPTIMIZED_STATE_234, OPTIMIZED_STATE_235, + OPTIMIZED_STATE_236, OPTIMIZED_STATE_237, OPTIMIZED_STATE_238, OPTIMIZED_STATE_239, + OPTIMIZED_STATE_240, OPTIMIZED_STATE_241, OPTIMIZED_STATE_242, OPTIMIZED_STATE_243, + OPTIMIZED_STATE_244, OPTIMIZED_STATE_245, OPTIMIZED_STATE_246, OPTIMIZED_STATE_247, + OPTIMIZED_STATE_248, OPTIMIZED_STATE_249, OPTIMIZED_STATE_250, OPTIMIZED_STATE_251, + OPTIMIZED_STATE_252, OPTIMIZED_STATE_253, OPTIMIZED_STATE_254, OPTIMIZED_STATE_255, +}; + +// The phase word and address need their bit order reversed before packing. +uint16_t reverseBits(uint16_t word, uint8_t numBits) +{ + uint16_t reversed = 0u; + + for (uint8_t i = 0u; i < numBits; ++i) { + reversed = (uint16_t)((reversed << 1u) | (word & 0x1u)); + word >>= 1u; + } + + return reversed; +} + +optimizedPhaseState_e GetOptimizedPhaseState(uint8_t stateWordTableIndex) +{ + return optimizedPhaseStateByIndex[stateWordTableIndex]; +} + +uint16_t MakePSCommand(optimizedPhaseState_e phaseState, uint8_t unitAddressWord) +{ + // [reversedStateWord][optBit][reversedAddress] + const uint16_t stateWord = (uint16_t)phaseState; + const bool optBit = (stateWord & PHASE_CONTROL_WORD_OPTION_MASK) != 0u; + uint16_t stateWordMasked = stateWord & PHASE_CONTROL_WORD_DATA_MASK; + + stateWordMasked = reverseBits(stateWordMasked, PHASE_STATE_BITS); + unitAddressWord = (uint8_t)reverseBits(unitAddressWord, PHASE_UNIT_ADDRESS_BITS); + + return (uint16_t)( + ((stateWordMasked & 0xffu) << PHASE_COMMAND_PHASE_SHIFT) + | ((uint16_t)optBit << PHASE_COMMAND_OPTION_SHIFT) + | (unitAddressWord & PHASE_COMMAND_ADDRESS_MASK)); +} + +bool phase_state_from_millidegrees( + uint32_t requested_shift_millidegrees, + uint8_t *phase_state) +{ + if (phase_state == NULL + || requested_shift_millidegrees > PHASE_FULL_TURN_MILLIDEGREES) { + return false; + } + + const uint64_t scaled = + ((uint64_t)requested_shift_millidegrees * PHASE_STATE_COUNT) + + (PHASE_FULL_TURN_MILLIDEGREES / 2u); + *phase_state = (uint8_t)((scaled / PHASE_FULL_TURN_MILLIDEGREES) & 0xffu); + return true; +} + +bool MakeVGACommand(uint8_t attenuationDb, uint8_t *command) +{ + if ((command == NULL) || (attenuationDb > VGA_MAX_ATTENUATION_DB)) { + return false; + } + + *command = (uint8_t)(attenuationDb << VGA_COMMAND_ATTENUATION_SHIFT); + return true; +} diff --git a/stm32/app/src/rf/execute.c b/stm32/app/src/rf/execute.c new file mode 100644 index 0000000..6eab7e1 --- /dev/null +++ b/stm32/app/src/rf/execute.c @@ -0,0 +1,73 @@ +#include "rf/execute.h" + +#include "platform/faults.h" +#include "rf/phase_shifter.h" +#include "rf/vga.h" + +#include + +static firmware_fault_t phase_fault(spi_guard_status_t status) +{ + return (status == SPI_GUARD_TIMEOUT) + ? FIRMWARE_FAULT_PHASE_SPI_TIMEOUT + : FIRMWARE_FAULT_PHASE_SPI_ERROR; +} + +static firmware_fault_t vga_fault(spi_guard_status_t status) +{ + return (status == SPI_GUARD_TIMEOUT) + ? FIRMWARE_FAULT_VGA_SPI_TIMEOUT + : FIRMWARE_FAULT_VGA_SPI_ERROR; +} + +void rf_execute_operation( + const rf_operation_t *operation, + uint32_t timeout_millis, + uint16_t *last_phase_command, + uint8_t *last_vga_command) +{ + if (operation == NULL + || last_phase_command == NULL + || last_vga_command == NULL) { + firmware_fail(FIRMWARE_FAULT_PHASE_COMMAND); + } + + if (operation->type == RF_OPERATION_VGA) { + const uint8_t command = (uint8_t)operation->command; + const spi_guard_status_t status = vga_write( + operation->channel, + command, + timeout_millis); + if (status != SPI_GUARD_OK) { + firmware_fail(vga_fault(status)); + } + *last_vga_command = command; + return; + } + + const spi_guard_status_t status = + phase_shifter_write(operation->command, timeout_millis); + if (status != SPI_GUARD_OK) { + firmware_fail(phase_fault(status)); + } + *last_phase_command = operation->command; +} + +void rf_execute_plan( + const rf_plan_t *plan, + uint32_t timeout_millis, + uint16_t *last_phase_command, + uint8_t *last_vga_command) +{ + if (plan == NULL || last_phase_command == NULL || last_vga_command == NULL) { + firmware_fail(FIRMWARE_FAULT_PHASE_COMMAND); + } + + for (uint8_t index = 0u; index < plan->operation_count; ++index) { + rf_execute_operation( + &plan->operations[index], + timeout_millis, + last_phase_command, + last_vga_command); + } +} diff --git a/stm32/app/src/phaseShifter.c b/stm32/app/src/rf/phase_shifter.c similarity index 52% rename from stm32/app/src/phaseShifter.c rename to stm32/app/src/rf/phase_shifter.c index 4ad6d97..8d321aa 100644 --- a/stm32/app/src/phaseShifter.c +++ b/stm32/app/src/rf/phase_shifter.c @@ -1,48 +1,44 @@ -#include "PhaseShifter.h" +#include "rf/phase_shifter.h" -#include "beamforming_protocol.h" +#include "platform/board.h" + +#include "rf/commands.h" #include #include #include -static void control_line_timing_margin(void) -{ - __asm__ volatile("nop"); - __asm__ volatile("nop"); -} - -void phase_shifter_setup(void) +void pe448spisetup(void) { rcc_periph_clock_enable(RCC_SPI2); rcc_periph_clock_enable(RCC_GPIOB); rcc_periph_clock_enable(RCC_GPIOC); - gpio_set(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); - gpio_mode_setup(SPI2_PS_LE_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, SPI2_PS_LE_PIN); + gpio_set(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); + gpio_mode_setup(BOARD_PHASE_LE_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, BOARD_PHASE_LE_PIN); gpio_set_output_options( - SPI2_PS_LE_PORT, + BOARD_PHASE_LE_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_HIGH, - SPI2_PS_LE_PIN); + BOARD_PHASE_LE_PIN); - gpio_set(SPI2_PS_SP_PORT, SPI2_PS_SP_PIN); - gpio_mode_setup(SPI2_PS_SP_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, SPI2_PS_SP_PIN); + gpio_set(BOARD_PHASE_SP_PORT, BOARD_PHASE_SP_PIN); + gpio_mode_setup(BOARD_PHASE_SP_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, BOARD_PHASE_SP_PIN); gpio_set_output_options( - SPI2_PS_SP_PORT, + BOARD_PHASE_SP_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_HIGH, - SPI2_PS_SP_PIN); + BOARD_PHASE_SP_PIN); - gpio_mode_setup(SPI2_PS_CLK_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, SPI2_PS_CLK_PIN); - gpio_mode_setup(SPI2_PS_MOSI_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, SPI2_PS_MOSI_PIN); - gpio_set_af(SPI2_PS_CLK_PORT, GPIO_AF0, SPI2_PS_CLK_PIN); - gpio_set_af(SPI2_PS_MOSI_PORT, GPIO_AF0, SPI2_PS_MOSI_PIN); + gpio_mode_setup(BOARD_PHASE_CLK_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, BOARD_PHASE_CLK_PIN); + gpio_mode_setup(BOARD_PHASE_MOSI_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, BOARD_PHASE_MOSI_PIN); + gpio_set_af(BOARD_PHASE_CLK_PORT, BOARD_PHASE_AF, BOARD_PHASE_CLK_PIN); + gpio_set_af(BOARD_PHASE_MOSI_PORT, BOARD_PHASE_AF, BOARD_PHASE_MOSI_PIN); gpio_set_output_options( - SPI2_PS_CLK_PORT, + BOARD_PHASE_CLK_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_HIGH, - SPI2_PS_CLK_PIN | SPI2_PS_MOSI_PIN); + BOARD_PHASE_CLK_PIN | BOARD_PHASE_MOSI_PIN); spi_disable(SPI2); spi_init_master( @@ -53,6 +49,7 @@ void phase_shifter_setup(void) SPI_CR1_MSBFIRST); spi_set_data_size(SPI2, SPI_CR2_DS_13BIT); spi_fifo_reception_threshold_16bit(SPI2); + /* The receiver PCB does not route PE44820 SDO readback to the STM32. */ spi_set_bidirectional_transmit_only_mode(SPI2); spi_enable_software_slave_management(SPI2); spi_set_nss_high(SPI2); @@ -70,7 +67,7 @@ spi_guard_status_t phase_shifter_write(uint16_t command, uint32_t timeout_millis return status; } - gpio_clear(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); + gpio_clear(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); SPI_DR(SPI2) = command; __asm__ volatile("dsb" ::: "memory"); @@ -79,8 +76,9 @@ spi_guard_status_t phase_shifter_write(uint16_t command, uint32_t timeout_millis return status; } - control_line_timing_margin(); - gpio_set(SPI2_PS_LE_PORT, SPI2_PS_LE_PIN); - control_line_timing_margin(); + board_control_line_margin(); + gpio_set(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); + board_control_line_margin(); + /* This confirms only MCU-side transfer completion; there is no device ACK. */ return SPI_GUARD_OK; } diff --git a/stm32/app/src/rf/plan.c b/stm32/app/src/rf/plan.c new file mode 100644 index 0000000..b399732 --- /dev/null +++ b/stm32/app/src/rf/plan.c @@ -0,0 +1,293 @@ +#include "rf/plan.h" + +#include "rf/commands.h" + +#include +#include + +_Static_assert( + PHASE_UNIT_ADDRESS_MIN + RF_CHANNEL_MAX <= PHASE_COMMAND_ADDRESS_MASK, + "Every RF channel must map to a valid PE44820 address."); + +/* + * Add one already-formatted SPI write to the execution plan. + * + * Planning every write before touching hardware is intentional. It lets the + * firmware reject an invalid eight-byte command without programming only some + * of the RF channels, and it keeps the exact write order visible in unit tests. + */ +static can_command_result_t append_operation( + rf_plan_t *plan, + rf_operation_type_t type, + uint8_t channel, + uint16_t command) +{ + if (plan->operation_count >= RF_PLAN_MAX_OPERATIONS) { + return CAN_COMMAND_RESULT_BUSY; + } + + rf_operation_t *operation = + &plan->operations[plan->operation_count++]; + operation->type = type; + operation->channel = channel; + operation->command = command; + return CAN_COMMAND_RESULT_OK; +} + +/* + * Convert a CAN phase-state index into the calibrated 9-bit PE44820 enum word, + * then build the 13-bit serial command with the channel's hardware address. + * + * CAN channels are zero based: 0, 1, 2, 3 + * PE44820 unit addresses are: 1, 2, 3, 4 + */ +static can_command_result_t append_phase( + uint8_t channel, + uint8_t phaseStateIndex, + rf_plan_t *plan) +{ + if (channel >= RF_CHANNEL_COUNT) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + const optimizedPhaseState_e phaseState = + GetOptimizedPhaseState(phaseStateIndex); + const uint8_t phaseAddress = + (uint8_t)(PHASE_UNIT_ADDRESS_MIN + channel); + const uint16_t command = MakePSCommand(phaseState, phaseAddress); + + const can_command_result_t result = + append_operation(plan, RF_OPERATION_PHASE, channel, command); + if (result == CAN_COMMAND_RESULT_OK) { + plan->resulting_state.phase_states[channel] = phaseStateIndex; + } + return result; +} + +/* + * Validate one requested attenuation and add its eight-bit F0480 command to + * the plan. The operation keeps the channel number because the final hardware + * driver must select the corresponding VGA before transmitting the byte. + */ +static can_command_result_t append_vga( + uint8_t channel, + uint8_t attenuationDb, + rf_plan_t *plan) +{ + if (channel >= RF_CHANNEL_COUNT) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + uint8_t command = 0u; + if (!MakeVGACommand(attenuationDb, &command)) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + const can_command_result_t result = + append_operation(plan, RF_OPERATION_VGA, channel, command); + if (result == CAN_COMMAND_RESULT_OK) { + plan->resulting_state.attenuation_db[channel] = attenuationDb; + } + return result; +} + +/* + * SET_PHASE changes one or four phase shifters while preserving each channel's + * current attenuation. + * + * Stage 1: move every active channel to 23 dB before changing phase. + * Stage 2: program the selected PE44820 address(es) in channel order. + * Stage 3: restore the attenuation that each channel had before the command. + * + * Channels already at 23 dB do not need an unnecessary stage-1 or stage-3 + * VGA write. The resulting state records each updated phase. + */ +static can_command_result_t plan_phase_safely( + const can_command_t *command, + const rf_state_t *currentState, + rf_plan_t *plan) +{ + if (!command->bulk_update && command->channel >= RF_CHANNEL_COUNT) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + const uint8_t firstChannel = command->bulk_update ? 0u : command->channel; + const uint8_t endChannel = command->bulk_update + ? RF_CHANNEL_COUNT + : (uint8_t)(command->channel + 1u); + + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + if (currentState->attenuation_db[channel] != VGA_MAX_ATTENUATION_DB) { + const can_command_result_t result = + append_vga(channel, VGA_MAX_ATTENUATION_DB, plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + } + + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + const can_command_result_t result = + append_phase(channel, command->phase_states[channel], plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + if (currentState->attenuation_db[channel] != VGA_MAX_ATTENUATION_DB) { + const can_command_result_t result = append_vga( + channel, + currentState->attenuation_db[channel], + plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + } + + return CAN_COMMAND_RESULT_OK; +} + +/* + * SET_COMBINED updates one channel (DLC 3) or all channels (DLC 8): + * + * Each affected output moves to maximum attenuation, receives its phase word, + * then receives the requested attenuation. A requested 23 dB is not sent twice. + */ +static can_command_result_t plan_combined( + const can_command_t *command, + rf_plan_t *plan) +{ + if (!command->bulk_update && command->channel >= RF_CHANNEL_COUNT) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + const uint8_t firstChannel = command->bulk_update ? 0u : command->channel; + const uint8_t endChannel = command->bulk_update + ? RF_CHANNEL_COUNT + : (uint8_t)(command->channel + 1u); + + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + const can_command_result_t result = + append_vga(channel, VGA_MAX_ATTENUATION_DB, plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + const can_command_result_t result = + append_phase(channel, command->phase_states[channel], plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + if (command->attenuation_db[channel] != VGA_MAX_ATTENUATION_DB) { + const can_command_result_t result = append_vga( + channel, + command->attenuation_db[channel], + plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + } + + return CAN_COMMAND_RESULT_OK; +} + +bool rf_plan_startup( + uint8_t phase_state, + uint8_t attenuation_db, + rf_plan_t *plan) +{ + if (plan == NULL || attenuation_db > VGA_MAX_ATTENUATION_DB) { + return false; + } + + memset(plan, 0, sizeof(*plan)); + + /* Put every output at the requested attenuation before changing phase. */ + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + if (append_vga(channel, attenuation_db, plan) != CAN_COMMAND_RESULT_OK) { + return false; + } + } + + /* PE44820 channel indexes 0..3 map to hardware addresses 1..4. */ + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + if (append_phase(channel, phase_state, plan) != CAN_COMMAND_RESULT_OK) { + return false; + } + } + + return true; +} + +can_command_result_t rf_plan_command( + const can_command_t *command, + const rf_state_t *currentState, + rf_plan_t *plan) +{ + if (command == NULL || currentState == NULL || plan == NULL) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + memset(plan, 0, sizeof(*plan)); + plan->resulting_state = *currentState; + + switch (command->type) { + case CAN_MESSAGE_SET_PHASE: + return plan_phase_safely(command, currentState, plan); + + case CAN_MESSAGE_SET_VGA: { + if (!command->bulk_update && command->channel >= RF_CHANNEL_COUNT) { + return CAN_COMMAND_RESULT_INVALID_PAYLOAD; + } + + const uint8_t firstChannel = command->bulk_update ? 0u : command->channel; + const uint8_t endChannel = command->bulk_update + ? RF_CHANNEL_COUNT + : (uint8_t)(command->channel + 1u); + for (uint8_t channel = firstChannel; channel < endChannel; ++channel) { + const can_command_result_t result = append_vga( + channel, + command->attenuation_db[channel], + plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + } + return CAN_COMMAND_RESULT_OK; + } + + case CAN_MESSAGE_SET_COMBINED: + return plan_combined(command, plan); + + case CAN_MESSAGE_ENTER_SAFE: { + /* ENTER_SAFE intentionally remains a one-channel emergency command. */ + can_command_result_t result = append_vga( + command->channel, + VGA_MAX_ATTENUATION_DB, + plan); + if (result != CAN_COMMAND_RESULT_OK) { + return result; + } + return append_phase(command->channel, 0u, plan); + } + + case CAN_MESSAGE_PING: + return CAN_COMMAND_RESULT_OK; + + case CAN_MESSAGE_STATUS: + case CAN_MESSAGE_ACK: + case CAN_MESSAGE_ERROR: + case CAN_MESSAGE_TYPE_COUNT: + return CAN_COMMAND_RESULT_UNSUPPORTED; + } + + return CAN_COMMAND_RESULT_UNSUPPORTED; +} diff --git a/stm32/app/src/rf/vga.c b/stm32/app/src/rf/vga.c new file mode 100644 index 0000000..d40a51a --- /dev/null +++ b/stm32/app/src/rf/vga.c @@ -0,0 +1,89 @@ +#include "rf/vga.h" + +#include "platform/board.h" +#include "rf/commands.h" + +#include +#include +#include + +void f0480spisetup(void) +{ + rcc_periph_clock_enable(RCC_SPI1); + rcc_periph_clock_enable(RCC_GPIOA); + + gpio_set(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + gpio_mode_setup(BOARD_VGA_CS_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, BOARD_VGA_CS_PIN); + gpio_set_output_options( + BOARD_VGA_CS_PORT, + GPIO_OTYPE_PP, + GPIO_OSPEED_HIGH, + BOARD_VGA_CS_PIN); + + gpio_mode_setup(BOARD_VGA_CLK_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, BOARD_VGA_CLK_PIN); + gpio_mode_setup(BOARD_VGA_MOSI_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, BOARD_VGA_MOSI_PIN); + gpio_set_af(BOARD_VGA_CLK_PORT, BOARD_VGA_AF, BOARD_VGA_CLK_PIN); + gpio_set_af(BOARD_VGA_MOSI_PORT, BOARD_VGA_AF, BOARD_VGA_MOSI_PIN); + gpio_set_output_options( + BOARD_VGA_CLK_PORT, + GPIO_OTYPE_PP, + GPIO_OSPEED_HIGH, + BOARD_VGA_CLK_PIN | BOARD_VGA_MOSI_PIN); + + spi_disable(SPI1); + spi_init_master( + SPI1, + SPI_CR1_BAUDRATE_FPCLK_DIV_16, + SPI_CR1_CPOL_CLK_TO_0_WHEN_IDLE, + SPI_CR1_CPHA_CLK_TRANSITION_1, + SPI_CR1_LSBFIRST); + spi_set_data_size(SPI1, SPI_CR2_DS_8BIT); + spi_fifo_reception_threshold_8bit(SPI1); + /* The F0480 three-wire control bus has no return data line on this PCB. */ + spi_set_bidirectional_transmit_only_mode(SPI1); + spi_enable_software_slave_management(SPI1); + spi_set_nss_high(SPI1); + spi_enable(SPI1); +} + +spi_guard_status_t vga_write( + uint8_t channel, + uint8_t command, + uint32_t timeout_millis) +{ + if (channel >= RF_CHANNEL_COUNT) { + return SPI_GUARD_INVALID_ARGUMENT; + } + + /* + * The protocol carries four independent VGA values and the RF planner + * preserves the channel number for every write. The checked-in C board pin + * map, however, names only one F0480 chip-select line (PA4) and does not + * identify the selector pins needed to route that CS to one of four chips. + * + * Keep the channel in this API so the final PCB selector can be added here + * without changing the CAN/runtime layers. Until that net mapping is added, + * the physical transfer below still uses the documented PA4 line. + */ + (void)channel; + + spi_guard_status_t status = spi_guard_wait_txe(SPI1, timeout_millis); + if (status != SPI_GUARD_OK) { + return status; + } + + gpio_clear(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + SPI_DR8(SPI1) = command; + __asm__ volatile("dsb" ::: "memory"); + + status = spi_guard_wait_complete(SPI1, timeout_millis); + if (status != SPI_GUARD_OK) { + return status; + } + + board_control_line_margin(); + gpio_set(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + board_control_line_margin(); + /* This confirms only MCU-side transfer completion; there is no device ACK. */ + return SPI_GUARD_OK; +} diff --git a/stm32/app/src/vga.c b/stm32/app/src/vga.c deleted file mode 100644 index d9b03a7..0000000 --- a/stm32/app/src/vga.c +++ /dev/null @@ -1,71 +0,0 @@ -#include "Vga.h" - -#include -#include -#include - -static void control_line_timing_margin(void) -{ - __asm__ volatile("nop"); - __asm__ volatile("nop"); -} - -void vga_setup(void) -{ - rcc_periph_clock_enable(RCC_SPI1); - rcc_periph_clock_enable(RCC_GPIOA); - - gpio_set(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - gpio_mode_setup(SPI1_VGA_CSB_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, SPI1_VGA_CSB_PIN); - gpio_set_output_options( - SPI1_VGA_CSB_PORT, - GPIO_OTYPE_PP, - GPIO_OSPEED_HIGH, - SPI1_VGA_CSB_PIN); - - gpio_mode_setup(SPI1_VGA_CLK_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, SPI1_VGA_CLK_PIN); - gpio_mode_setup(SPI1_VGA_MOSI_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, SPI1_VGA_MOSI_PIN); - gpio_set_af(SPI1_VGA_CLK_PORT, GPIO_AF0, SPI1_VGA_CLK_PIN); - gpio_set_af(SPI1_VGA_MOSI_PORT, GPIO_AF0, SPI1_VGA_MOSI_PIN); - gpio_set_output_options( - SPI1_VGA_CLK_PORT, - GPIO_OTYPE_PP, - GPIO_OSPEED_HIGH, - SPI1_VGA_CLK_PIN | SPI1_VGA_MOSI_PIN); - - spi_disable(SPI1); - spi_init_master( - SPI1, - SPI_CR1_BAUDRATE_FPCLK_DIV_16, - SPI_CR1_CPOL_CLK_TO_0_WHEN_IDLE, - SPI_CR1_CPHA_CLK_TRANSITION_1, - SPI_CR1_LSBFIRST); - spi_set_data_size(SPI1, SPI_CR2_DS_8BIT); - spi_fifo_reception_threshold_8bit(SPI1); - spi_set_bidirectional_transmit_only_mode(SPI1); - spi_enable_software_slave_management(SPI1); - spi_set_nss_high(SPI1); - spi_enable(SPI1); -} - -spi_guard_status_t vga_write(uint8_t command, uint32_t timeout_millis) -{ - spi_guard_status_t status = spi_guard_wait_txe(SPI1, timeout_millis); - if (status != SPI_GUARD_OK) { - return status; - } - - gpio_clear(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - SPI_DR8(SPI1) = command; - __asm__ volatile("dsb" ::: "memory"); - - status = spi_guard_wait_complete(SPI1, timeout_millis); - if (status != SPI_GUARD_OK) { - return status; - } - - control_line_timing_margin(); - gpio_set(SPI1_VGA_CSB_PORT, SPI1_VGA_CSB_PIN); - control_line_timing_margin(); - return SPI_GUARD_OK; -} diff --git a/stm32/app/src/PinToggle.c b/stm32/bringup/PinToggle.c similarity index 100% rename from stm32/app/src/PinToggle.c rename to stm32/bringup/PinToggle.c diff --git a/stm32/bringup/README.md b/stm32/bringup/README.md new file mode 100644 index 0000000..3759393 --- /dev/null +++ b/stm32/bringup/README.md @@ -0,0 +1,7 @@ +# STM32 bring-up archive + +Bench-only code and captured SPI/GPIO evidence from early receiver-board work. + +- Not compiled into firmware. +- Production tests are under `stm32/tests/`. +- Current hardware validation is in `stm32/docs/HARDWARE_VALIDATION.md`. diff --git a/stm32/bringup/RX1UnitTests.c b/stm32/bringup/RX1UnitTests.c new file mode 100644 index 0000000..df56bc2 --- /dev/null +++ b/stm32/bringup/RX1UnitTests.c @@ -0,0 +1,111 @@ +/* +This file contains the following unit tests: +1. Pin Toggle / Sign of Life Unit Test +2. Phase Shifter SPI command transmission unit test. +3. VGA SPI command transmission unit Test. +4. VGA + Phase Shifter SPI command transmission at 'same time' (within clock cycle) +*/ + + +//Unit test 1: Pin Toggle / Sign of Life Unit Test +//Image +/* +int main(void) +{ + rcc_clock_setup_in_hse_8mhz_out_48mhz(); + rcc_periph_clock_enable(RCC_GPIOA); + gpio_mode_setup(gpioPort, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, gpioPin); // required + + while (1) + { + SignOfLifeSignalAtMaximumSpeed(SIGNOFLIFEPORT, SIGNOFLIFEPIN); //toggle at max speed by setting and resetting BSRR for the pin + } + + return 0; +} +*/ + +//Unit test 2: Phase Shifter SPI command transmission unit test. +//Images: +// - RX1_RecreatePE448SpiTiming.png & RX1_RecreatePE448SpiTiming(2).png ("dont care" after LE set) +// - RX1_PE448SpiCmd.png (proof of correct cmd format) +/* +int main(void) +{ + rcc_clock_setup_in_hse_8mhz_out_48mhz(); + pe448spisetup(); + spi_enable(SPI2); + + // Other calibrated states can be selected by their CAN table index. + uint8_t stateWordTableIndex = 146u; // approximately 205.3 degrees at 2.4 GHz + optimizedPhaseState_e phaseState = GetOptimizedPhaseState(stateWordTableIndex); + uint8_t unitAddressWord = 0b0011; + uint16_t command = MakePSCommand(phaseState, unitAddressWord); + + while (1) + { + gpio_clear(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); //set the cs low + spi_send(SPI2, command); + gpio_set(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); + //then send another command. + spi_send(SPI2, 0b0011111100000); //LE indifference: "dont care" about this second command. whatever we send after LE goes high should be ignored. Tested by sending a command after LE goes high and ensuring that the response is not affected + break; + } + return 0; +} +*/ + + +//Unit test 3: VGA SPI command transmission unit Test. +/* +int main(void) +{ + rcc_clock_setup_in_hse_8mhz_out_48mhz(); + f0480spisetup(); + spi_enable(SPI1); + uint8_t command = 0u; + MakeVGACommand(23u, &command); // maximum attenuation, command 0b01011100 + + while (1) + { + gpio_clear(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + spi_send(SPI1, command); + gpio_set(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + break; + } + return 0; +} + + +//Unit test 4: sending a command to the phase shifter and the VGA at the same time. +int main(void) +{ + rcc_clock_setup_in_hse_8mhz_out_48mhz(); + f0480spisetup(); + pe448spisetup(); + spi_enable(SPI2); + spi_enable(SPI1); + + uint8_t commandVGA = 0u; + MakeVGACommand(23u, &commandVGA); + uint16_t commandPS = MakePSCommand(GetOptimizedPhaseState(146u), 0b0011); + + // Other attenuation values can be formed with MakeVGACommand(0..23, &commandVGA). + + + gpio_clear(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + gpio_clear(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); + + while (1) + { + spi_send(SPI2, commandPS); + spi_send(SPI1, commandVGA); + gpio_set(BOARD_VGA_CS_PORT, BOARD_VGA_CS_PIN); + gpio_set(BOARD_PHASE_LE_PORT, BOARD_PHASE_LE_PIN); + break; + } + return 0; +} + +*/ + diff --git a/stm32/app/src/UnitTests/RX1_PE448SpiCmdFormat.png b/stm32/bringup/evidence/RX1_PE448SpiCmdFormat.png similarity index 100% rename from stm32/app/src/UnitTests/RX1_PE448SpiCmdFormat.png rename to stm32/bringup/evidence/RX1_PE448SpiCmdFormat.png diff --git a/stm32/app/src/UnitTests/RX1_RecreatePE448SpiTiming(2).png b/stm32/bringup/evidence/RX1_RecreatePE448SpiTiming(2).png similarity index 100% rename from stm32/app/src/UnitTests/RX1_RecreatePE448SpiTiming(2).png rename to stm32/bringup/evidence/RX1_RecreatePE448SpiTiming(2).png diff --git a/stm32/app/src/UnitTests/RX1_RecreatePE448SpiTiming.png b/stm32/bringup/evidence/RX1_RecreatePE448SpiTiming.png similarity index 100% rename from stm32/app/src/UnitTests/RX1_RecreatePE448SpiTiming.png rename to stm32/bringup/evidence/RX1_RecreatePE448SpiTiming.png diff --git a/stm32/app/src/UnitTests/RX1_SignOfLife.mp4 b/stm32/bringup/evidence/RX1_SignOfLife.mp4 similarity index 100% rename from stm32/app/src/UnitTests/RX1_SignOfLife.mp4 rename to stm32/bringup/evidence/RX1_SignOfLife.mp4 diff --git a/stm32/docs/CAN_PROTOCOL.md b/stm32/docs/CAN_PROTOCOL.md index 7c0086e..4ffc5e9 100644 --- a/stm32/docs/CAN_PROTOCOL.md +++ b/stm32/docs/CAN_PROTOCOL.md @@ -1,196 +1,18 @@ -# Receiver-board CAN protocol +# STM32 CAN protocol -This document defines protocol version 1 for communication between one controller and multiple STM32F072 receiver-chain boards. +Canonical specification: [`../../docs/can-protocol.md`](../../docs/can-protocol.md). -The controller algorithm belongs in a separate host-side project. This repository implements only the per-board firmware, device drivers, command validation, safe output sequencing, and CAN transport. - -## Physical and link layer - -- Classic CAN 2.0B extended frames -- 500 kbit/s -- Eight data bytes maximum -- Controller node ID: `0` -- Receiver node IDs: `1` through `30` -- Broadcast destination: `31` -- Remote frames and standard-ID frames are ignored -- Each receiver configures hardware acceptance filters for its own destination and broadcast - -The current board code uses CAN1 on PA11/PA12 with an external SN65HVD230-class 3.3 V transceiver. Connector choice does not affect the protocol. Verify the actual RJ11 or RJ45 pinout from the board schematic before connecting hardware. - -## Extended identifier - -The 29-bit identifier is partitioned as follows: - -| Bits | Width | Field | -|---:|---:|---| -| 28:26 | 3 | Message type | -| 25:21 | 5 | Destination node | -| 20:16 | 5 | Source node | -| 15:0 | 16 | Sequence number | - -Equivalent construction: +Protocol 2.1 supports exact-length individual and bulk payloads: ```text -id = (type << 26) | (destination << 21) | (source << 16) | sequence -``` - -Receiver boards accept control commands only from controller node `0`. A receiver never treats another receiver as a valid command source. - -The sequence number is echoed in responses. The controller should increment it for each logical request and match responses by source and sequence. - -Sequence numbers provide request/response correlation only. Commands are absolute and idempotent, so a controller may safely retry after a timeout, but receivers do not currently maintain a replay cache. - -## Message types - -| Type | Name | Direction | Payload | -|---:|---|---|---| -| 0 | `ENTER_SAFE` | Controller → receiver | `[phase_address]` | -| 1 | `SET_COMBINED` | Controller → receiver | `[phase_state, phase_address, attenuation_db]` | -| 2 | `SET_PHASE` | Controller → receiver | `[phase_state, phase_address]` | -| 3 | `SET_VGA` | Controller → receiver | `[attenuation_db]` | -| 4 | `PING` | Controller → receiver | Empty | -| 5 | `STATUS` | Receiver → controller | Eight-byte status payload | -| 6 | `ACK` | Receiver → controller | `[command_type, result]` | -| 7 | `ERROR` | Receiver → controller | `[command_type, result]` | - -### Field ranges - -- `phase_state`: `0..255` -- `phase_address`: `0..15` -- `attenuation_db`: `0..23` - -The PE44820 OPT bit is not sent over CAN. Firmware derives it from phase-state bit D6. - -The safety command has the numerically lowest type field, so it wins CAN arbitration against every other protocol message type. - -Protocol version 1 changes one phase-shifter address per command. A controller can sequence commands for multiple addresses, but an atomic multi-address batch is intentionally not defined until the board channel count and update requirements are confirmed. - -## Command behavior - -### SET_PHASE - -Programs one phase state and address. When the current attenuation is below 23 dB, firmware first applies 23 dB, changes phase, and then restores the prior attenuation. When the VGA is already at 23 dB, only the phase write is needed. - -### SET_VGA - -Programs one attenuation value from 0 through 23 dB. The current phase setting is left unchanged. - -### SET_COMBINED - -Uses a safe three-step transition when the final attenuation is below 23 dB: - -1. Apply 23 dB maximum attenuation. -2. Program the phase shifter. -3. Apply the requested attenuation. - -When the requested final attenuation is already 23 dB, the duplicate final VGA write is omitted. - -### ENTER_SAFE - -Applies 23 dB attenuation, then phase state zero at the supplied phase address. - -### PING - -Does not touch hardware. A unicast ping returns `STATUS`. - -## Responses - -Unicast state-changing commands receive `ACK` after all requested hardware operations complete successfully. Invalid unicast commands receive `ERROR` when the identifier can be safely associated with controller node `0`. - -Broadcast commands do not generate responses. This avoids an acknowledgement storm when multiple receiver boards share the bus. - -### Result codes - -| Value | Meaning | -|---:|---| -| 0 | OK | -| 1 | Invalid payload length | -| 2 | Invalid field value or identifier | -| 3 | Unsupported message type | -| 4 | Hardware operation failed | -| 5 | Receiver busy or retained safe lockout active | - -Runtime SPI failures are treated as firmware faults and reset the MCU rather than continuing with an uncertain RF state. Consequently, the controller may observe a missing acknowledgement instead of result code 4. - -### STATUS payload - -| Byte | Field | -|---:|---| -| 0 | Protocol version | -| 1 | Receiver node ID | -| 2 | Current phase state | -| 3 | Current phase address | -| 4 | Current attenuation in dB | -| 5 | Health flags | -| 6 | Saturated receive-drop count | -| 7 | Saturated invalid-command count | - -Health flags: - -| Bit | Meaning | -|---:|---| -| 0 | Running on HSI48 fallback instead of HSE/PLL | -| 1 | One or more CAN receive frames were dropped | -| 2 | One or more invalid commands were observed | -| 3 | Retained safe lockout is active | -| 4 | bxCAN currently reports bus-off | -| 5 | One or more response frames could not be queued into a transmit mailbox | - -The receive-overrun count is best-effort. bxCAN has a three-frame hardware FIFO, and an overrun arriving during FIFO release can be difficult to observe reliably. The software ISR drains the hardware FIFO into an eight-frame single-producer/single-consumer queue as quickly as possible. - -## Building node-specific firmware - -The node ID is compiled into the image: - -```bash -make -C libopencm3 TARGETS=stm32/f0 -make -C app clean all CAN_NODE_ID=1 -``` - -Valid values are `1..30`. An invalid value fails compilation. CI builds one representative -node-1 image only; choose and build the required node ID explicitly before flashing hardware. - -## Raspberry Pi SocketCAN smoke test - -The included tool uses only the Python standard library: - -```bash -sudo ip link set can0 down 2>/dev/null || true -sudo ip link set can0 type can bitrate 500000 restart-ms 100 -sudo ip link set can0 up -ip -details link show can0 -``` - -Verify CANH, CANL, common ground, transceiver voltage, and exactly two 120 Ω terminators before powering the network. - -Ping three nodes: - -```bash -python3 tools/can_smoke_test.py --interface can0 ping 1 -python3 tools/can_smoke_test.py --interface can0 ping 2 -python3 tools/can_smoke_test.py --interface can0 ping 3 -``` - -Apply commands: - -```bash -python3 tools/can_smoke_test.py phase 1 146 3 -python3 tools/can_smoke_test.py vga 1 23 -python3 tools/can_smoke_test.py combined 1 64 4 12 -python3 tools/can_smoke_test.py safe 1 3 +SET_PHASE [state, channel] | [PS1, PS2, PS3, PS4] +SET_VGA [atten, channel] | [VGA1, VGA2, VGA3, VGA4] +SET_COMBINED [state, channel, atten] | [PS1..PS4, VGA1..VGA4] +ENTER_SAFE [channel] ``` -The tool waits for a sequence-matched `ACK`, `ERROR`, or `STATUS` response and exits nonzero for errors or timeouts. +Channels are zero-based; PE44820 addresses are `1..4`. Phase changes use attenuation, phase, restore/apply ordering. -## Bench-test sequence +The F0480 operation retains its channel, but the checked-in board map names only SPI1 and PA4 CS. Add the final selector-net mapping in the driver; do not guess GPIOs. -1. Test one receiver and the Raspberry Pi with termination at both ends. -2. Confirm repeated pings return stable node IDs and state. -3. Confirm phase-only and VGA-only commands produce acknowledgements and expected waveforms. -4. Confirm combined commands visibly apply maximum attenuation before the phase transition. -5. Add nodes 2 and 3 and confirm each ignores frames for the other nodes. -6. Send a broadcast safe command and confirm all boards enter safe output without transmitting responses. -7. Send invalid lengths and out-of-range values and confirm unicast `ERROR` responses. -8. Generate a controlled burst and verify status drop counters remain zero at the intended command rate. -9. Disconnect CANH/CANL temporarily and verify automatic bus-off recovery after restoring the bus. -10. Record firmware commit, node image, board revision, transceiver, connector pinout, termination, and captured traces. +ACK confirms STM32 validation and SPI completion, not RF-device readback. diff --git a/stm32/docs/HARDWARE_VALIDATION.md b/stm32/docs/HARDWARE_VALIDATION.md index c5c4b80..11f1628 100644 --- a/stm32/docs/HARDWARE_VALIDATION.md +++ b/stm32/docs/HARDWARE_VALIDATION.md @@ -1,105 +1,103 @@ -# Hardware Validation Checklist +# Hardware validation checklist -Complete this checklist for every PCB revision and every release artifact before enabling an operational RF path. Record the firmware Git revision, ELF SHA-256, board revision, board serial number, test equipment, operator, and date with the evidence. +Run for every PCB revision and release artifact before enabling RF. Record firmware revision, ELF SHA256, board revision/serial, equipment, operator, date, and evidence. ## Identification -- [ ] Populated MCU marking confirms STM32F072R8T6. -- [ ] Schematic and PCB revision are recorded. -- [ ] Firmware revision, compiled CAN node ID, and ELF SHA-256 are recorded. -- [ ] Power rails and logic levels match all connected devices. -- [ ] External pull resistors establish documented RF-safe reset states. - -## Power and reset - -- [ ] Test normal power ramp. -- [ ] Test slow power ramp. -- [ ] Test repeated power cycling. -- [ ] Test brownout and recovery. -- [ ] Confirm watchdog reset recovery. -- [ ] Confirm reset flags and retained diagnostics are readable after reset. -- [ ] Confirm corrupted or erased retained RAM is safely reinitialized. -- [ ] Confirm three incomplete boots cause safe lockout on the next boot. -- [ ] Confirm a newly flashed build clears the previous build's lockout streak. - -## Clock behavior - -- [ ] HSE starts and the system runs at 48 MHz. -- [ ] A missing or failed HSE causes the bounded HSI48 fallback. -- [ ] SPI clock frequencies are correct in both clock modes. -- [ ] Clock-security failure records a fault and resets the MCU. - -## VGA transaction - -Capture CS, SCK, and data using a logic analyzer or oscilloscope. - -- [ ] CS is high while idle. -- [ ] SPI mode is CPOL=0, CPHA=0. -- [ ] Exactly eight clock edges occur. -- [ ] The 23 dB command is `0x5c`. -- [ ] Logical data order is LSB-first. -- [ ] CS setup, hold, and pulse timing meet the current F0480 datasheet. -- [ ] Measured attenuation agrees with the requested setting for every value from 0 through 23 dB. -- [ ] A forced pre-write timeout leaves CS high. -- [ ] A forced in-flight timeout does not intentionally latch partial data before reset. - -## Phase-shifter transaction - -Capture LE, SCK, data, and serial-programming select. - -- [ ] Serial-programming select is high before programming. -- [ ] LE is high while idle and low during the transfer. -- [ ] SPI mode is CPOL=0, CPHA=0. -- [ ] Exactly 13 clock edges occur. -- [ ] The 205.3-degree, address-3 raw command is `0x092c`. -- [ ] The phase and address fields appear in the device-required logical order. -- [ ] OPT tracks phase-state bit D6 over representative states on both sides of each transition. -- [ ] LE setup, hold, and pulse timing meet the current PE44820 datasheet. -- [ ] Measured RF phase agrees with the requested state over all 256 states or an approved sampling plan. -- [ ] A forced pre-write timeout leaves LE high. -- [ ] A forced in-flight timeout does not intentionally latch partial data before reset. - -## CAN network - -Use the exact node-specific CI artifacts and the procedure in [`CAN_PROTOCOL.md`](CAN_PROTOCOL.md). - -- [ ] CAN1 RX is PA11/AF4 and CAN1 TX is PA12/AF4 on the tested board revision. -- [ ] The external transceiver is compatible with the board's 3.3 V logic and bus voltage. -- [ ] CANH, CANL, and common ground are verified against the actual connector pinout. -- [ ] Exactly two 120 Ω terminators are installed at the physical ends of the test bus. -- [ ] Measured bitrate is 500 kbit/s with the expected sample point. -- [ ] Nodes 1, 2, and 3 each answer a unicast ping with their own node ID. -- [ ] Each node ignores unicast frames addressed to the other two nodes. -- [ ] Standard-ID and remote frames do not reach the application queue. -- [ ] A unicast valid command receives a sequence-matched ACK. -- [ ] A unicast invalid length or value receives a sequence-matched ERROR. -- [ ] A broadcast safe command changes all nodes and produces no responses. -- [ ] Combined commands apply 23 dB attenuation before the phase transition and apply the requested final attenuation afterward. -- [ ] Receive-queue overflow can be induced in a controlled test and appears in STATUS health flags. -- [ ] Transmit-mailbox exhaustion can be induced in a controlled test and appears in STATUS health flags. -- [ ] Disconnecting the bus produces bus-off where expected and automatic recovery after reconnection. -- [ ] The Raspberry Pi SocketCAN tool completes repeated ping, phase, VGA, combined, and safe cycles without unexpected timeouts. -- [ ] RJ11 and RJ45 harnesses are separately documented; no connector pinout is inferred from connector type alone. +- [ ] MCU is STM32F072R8T6. +- [ ] Schematic and PCB revisions recorded. +- [ ] Firmware revision, CAN node ID, and ELF SHA256 recorded. +- [ ] Rails and logic levels verified. +- [ ] Pull resistors provide RF-safe reset states. + +## Power/reset + +- [ ] Normal and slow ramps tested. +- [ ] Repeated power cycles tested. +- [ ] Brownout/recovery tested. +- [ ] Watchdog recovery verified. +- [ ] Reset flags and retained diagnostics readable. +- [ ] Invalid retained RAM reinitializes safely. +- [ ] Three incomplete boots trigger safe lockout. +- [ ] New firmware revision clears the prior lockout streak. + +## Clock + +- [ ] HSE runs at 48 MHz. +- [ ] Missing HSE uses bounded HSI48 fallback. +- [ ] SPI clocks correct in both modes. +- [ ] Clock-security failure records fault and resets. + +## F0480 VGA + +Capture CS, SCK, and data. + +- [ ] CS idle high. +- [ ] CPOL=0, CPHA=0. +- [ ] Eight clocks per word. +- [ ] 23 dB command is `0x5C`. +- [ ] Data is LSB-first. +- [ ] CS timing meets datasheet. +- [ ] Measured attenuation matches every setting `0..23` dB. +- [ ] Each channel selector reaches the intended F0480 only. +- [ ] Pre-write timeout leaves CS high. +- [ ] In-flight timeout does not intentionally latch partial data before reset. + +## PE44820 phase shifter + +Capture LE, SCK, data, and serial-select. + +- [ ] Serial-select high before programming. +- [ ] LE idle high and low during transfer. +- [ ] CPOL=0, CPHA=0. +- [ ] Thirteen clocks per word. +- [ ] State 146 -> `0x08D`; address 3 -> `0x162C`. +- [ ] Phase/address fields use required order. +- [ ] Representative states match `docs/PE44820_Lookup_2.4GHz.csv`, including OPT. +- [ ] LE timing meets datasheet. +- [ ] Measured phase matches all 256 states or approved sample plan. +- [ ] Pre-write timeout leaves LE high. +- [ ] In-flight timeout does not intentionally latch partial data before reset. + +## CAN + +- [ ] PA11/AF4 RX and PA12/AF4 TX verified. +- [ ] Transceiver voltage compatibility verified. +- [ ] CANH/CANL/ground connector mapping verified. +- [ ] Two 120-ohm terminators at bus ends. +- [ ] 500 kbit/s and sample point measured. +- [ ] Nodes 1, 2, 3 answer only their unicast pings. +- [ ] Standard-ID and remote frames rejected. +- [ ] Valid unicast gets sequence-matched ACK. +- [ ] Invalid DLC/value gets sequence-matched ERROR. +- [ ] Broadcast safe changes all nodes with no responses. +- [ ] Individual and bulk phase/VGA/combined payloads verified. +- [ ] Phase changes apply 23 dB before phase and restore/apply afterward. +- [ ] RX overflow appears in STATUS. +- [ ] TX exhaustion appears in STATUS. +- [ ] Bus-off and recovery verified. +- [ ] Pi completes repeated command cycles without unexpected timeout. +- [ ] RJ11/RJ45 pinouts documented independently of connector type. ## Fault injection -- [ ] Disconnect or hold each SPI clock/data line and verify bounded failure behavior. -- [ ] Force SPI status errors where practical and verify recorded fault codes. -- [ ] Trigger HardFault and verify reset plus retained fault history. -- [ ] Trigger an unused peripheral interrupt and verify reset plus retained fault history. -- [ ] Verify the watchdog is not refreshed in a fault path. +- [ ] Disconnect/hold each SPI line; failure remains bounded. +- [ ] Inject SPI errors; fault codes recorded. +- [ ] HardFault resets and records history. +- [ ] Unused interrupt resets and records history. +- [ ] Watchdog is not refreshed in fault paths. -## Environmental and endurance testing +## Environment/endurance -- [ ] Test at minimum and maximum qualified supply voltage. -- [ ] Test over the intended temperature range. -- [ ] Perform repeated startup and command cycles. -- [ ] Perform an EMI susceptibility test appropriate to the installation. -- [ ] Run a soak test for the intended deployment duration or an approved accelerated duration. +- [ ] Min/max qualified supply voltage. +- [ ] Intended temperature range. +- [ ] Repeated startup/command cycles. +- [ ] Installation-appropriate EMI test. +- [ ] Deployment-duration or approved accelerated soak. ## Release approval -- [ ] All automated CI checks pass for the exact revision. -- [ ] The tested ELF checksum matches the archived release artifact. -- [ ] All deviations are documented and accepted by the responsible hardware and firmware reviewers. -- [ ] Rollback instructions and the previous qualified artifact are available. +- [ ] CI passes for exact revision. +- [ ] Tested ELF SHA256 matches archive. +- [ ] Deviations accepted and recorded. +- [ ] Rollback procedure and prior qualified artifact available. diff --git a/stm32/tests/Makefile b/stm32/tests/Makefile index bd8e7ff..6efd6fb 100644 --- a/stm32/tests/Makefile +++ b/stm32/tests/Makefile @@ -1,49 +1,49 @@ CC ?= cc -CFLAGS ?= -std=c17 -Wall -Wextra -Werror -pedantic -O2 +CFLAGS ?= -std=c2x -Wall -Wextra -Werror -pedantic -O2 CPPFLAGS += -I../app/include -I../../protocol/generated -TARGETS := test_protocol test_diagnostics test_can_protocol test_can_queue test_can_control test_build_info test_can_tx_queue test_protocol_vectors +TARGETS := test_rf_commands test_rf_plan test_diagnostics test_can_protocol test_can_rx_queue test_can_tx_queue test_build_info test_protocol_vectors .PHONY: all test test_invalid_node_config clean all: $(TARGETS) -test_protocol: test_protocol.c ../app/src/beamforming_protocol.c ../app/include/beamforming_protocol.h +test_rf_commands: test_rf_commands.c ../app/src/rf/commands.c ../app/include/rf/commands.h ../app/include/rf/limits.h ../app/include/rf/phase_states_2_4ghz.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ -test_diagnostics: test_diagnostics.c ../app/src/diagnostic_record.c ../app/include/diagnostic_record.h +test_rf_plan: test_rf_plan.c ../app/src/rf/plan.c ../app/src/rf/commands.c ../app/include/rf/plan.h ../app/include/can/protocol.h ../app/include/rf/commands.h ../app/include/rf/limits.h ../app/include/rf/phase_states_2_4ghz.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ -test_can_protocol: test_can_protocol.c ../app/src/can_protocol.c ../app/include/can_protocol.h +test_diagnostics: test_diagnostics.c ../app/src/platform/diagnostic_record.c ../app/include/platform/diagnostic_record.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ -test_can_queue: test_can_queue.c ../app/src/can_queue.c ../app/include/can_queue.h ../app/include/can_protocol.h +test_can_protocol: test_can_protocol.c ../app/src/can/protocol.c ../app/include/can/protocol.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ -test_can_control: test_can_control.c ../app/src/can_control.c ../app/src/beamforming_protocol.c ../app/include/can_control.h ../app/include/can_protocol.h ../app/include/beamforming_protocol.h +test_can_rx_queue: test_can_rx_queue.c ../app/src/can/rx_queue.c ../app/include/can/rx_queue.h ../app/include/can/protocol.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ -test_build_info: test_build_info.c ../app/src/build_info.c ../app/include/build_info.h ../app/include/firmware_config.h - $(CC) $(CPPFLAGS) $(CFLAGS) -DFIRMWARE_REVISION=\"test-revision\" -DBEAMFORMER_NODE_ID=7 $(filter %.c,$^) -o $@ - -test_can_tx_queue: test_can_tx_queue.c ../app/src/can_tx_queue.c ../app/include/can_tx_queue.h ../app/include/can_bus.h ../app/include/can_protocol.h +test_can_tx_queue: test_can_tx_queue.c ../app/src/can/tx_queue.c ../app/include/can/tx_queue.h ../app/include/can/protocol.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ -test_protocol_vectors: test_protocol_vectors.c ../app/src/can_protocol.c ../app/include/can_protocol.h ../../protocol/generated/protocol_vectors.h +test_build_info: test_build_info.c ../app/src/platform/build_info.c ../app/include/platform/build_info.h ../app/include/platform/firmware_config.h + $(CC) $(CPPFLAGS) $(CFLAGS) -DFIRMWARE_REVISION=\"test-revision\" -DBEAMFORMER_NODE_ID=7 $(filter %.c,$^) -o $@ + +test_protocol_vectors: test_protocol_vectors.c ../app/src/can/protocol.c ../app/include/can/protocol.h ../../protocol/generated/protocol_vectors.h $(CC) $(CPPFLAGS) $(CFLAGS) $(filter %.c,$^) -o $@ test: $(TARGETS) test_invalid_node_config - ./test_protocol + ./test_rf_commands + ./test_rf_plan ./test_diagnostics ./test_can_protocol - ./test_can_queue - ./test_can_control - ./test_build_info + ./test_can_rx_queue ./test_can_tx_queue + ./test_build_info ./test_protocol_vectors test_invalid_node_config: - @if printf '#include "firmware_config.h"\n' \ + @if printf '#include "platform/firmware_config.h"\n' \ | $(CC) $(CPPFLAGS) -DBEAMFORMER_NODE_ID=31 -x c -fsyntax-only - >/dev/null 2>&1; then \ echo "invalid node configuration unexpectedly compiled"; \ exit 1; \ diff --git a/stm32/tests/test_build_info.c b/stm32/tests/test_build_info.c index c95ad94..c4182db 100644 --- a/stm32/tests/test_build_info.c +++ b/stm32/tests/test_build_info.c @@ -1,4 +1,4 @@ -#include "build_info.h" +#include "platform/build_info.h" #include #include diff --git a/stm32/tests/test_can_control.c b/stm32/tests/test_can_control.c deleted file mode 100644 index bd237d1..0000000 --- a/stm32/tests/test_can_control.c +++ /dev/null @@ -1,185 +0,0 @@ -#include "can_control.h" -#include "beamforming_protocol.h" - -#include -#include - -static can_command_t command_of(can_message_type_t type) -{ - can_command_t command = {0}; - command.type = type; - command.source = CAN_NODE_CONTROLLER; - command.destination = 1u; - command.sequence = 1u; - return command; -} - -static can_control_state_t initial_state(void) -{ - const can_control_state_t state = { - .phase_state = 0u, - .phase_address = 3u, - .attenuation_db = 23u, - }; - return state; -} - -static void assert_operation( - const can_control_plan_t *plan, - uint8_t index, - can_control_operation_type_t type, - uint16_t command) -{ - assert(index < plan->operation_count); - assert(plan->operations[index].type == type); - assert(plan->operations[index].command == command); -} - -static void test_phase_plan(void) -{ - const can_control_state_t current = initial_state(); - can_command_t command = command_of(CAN_MESSAGE_SET_PHASE); - command.phase_state = 146u; - command.phase_address = 3u; - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 1u); - assert_operation(&plan, 0u, CAN_CONTROL_OPERATION_PHASE, 0x092cu); - assert(plan.resulting_state.phase_state == 146u); - assert(plan.resulting_state.phase_address == 3u); - assert(plan.resulting_state.attenuation_db == 23u); -} - -static void test_phase_plan_temporarily_attenuates_and_restores_active_output(void) -{ - const can_control_state_t current = { - .phase_state = 10u, - .phase_address = 3u, - .attenuation_db = 8u, - }; - can_command_t command = command_of(CAN_MESSAGE_SET_PHASE); - command.phase_state = 146u; - command.phase_address = 3u; - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 3u); - assert_operation(&plan, 0u, CAN_CONTROL_OPERATION_VGA, 0x5cu); - assert_operation(&plan, 1u, CAN_CONTROL_OPERATION_PHASE, 0x092cu); - assert_operation(&plan, 2u, CAN_CONTROL_OPERATION_VGA, 0x20u); - assert(plan.resulting_state.phase_state == 146u); - assert(plan.resulting_state.attenuation_db == 8u); -} - -static void test_vga_plan(void) -{ - const can_control_state_t current = initial_state(); - can_command_t command = command_of(CAN_MESSAGE_SET_VGA); - command.attenuation_db = 8u; - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 1u); - assert_operation(&plan, 0u, CAN_CONTROL_OPERATION_VGA, 0x20u); - assert(plan.resulting_state.attenuation_db == 8u); -} - -static void test_combined_plan_uses_safe_transition_order(void) -{ - const can_control_state_t current = initial_state(); - can_command_t command = command_of(CAN_MESSAGE_SET_COMBINED); - command.phase_state = 64u; - command.phase_address = 4u; - command.attenuation_db = 12u; - - uint16_t expected_phase = 0u; - assert(phase_command_from_state(64u, 4u, &expected_phase)); - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 3u); - assert_operation(&plan, 0u, CAN_CONTROL_OPERATION_VGA, 0x5cu); - assert_operation(&plan, 1u, CAN_CONTROL_OPERATION_PHASE, expected_phase); - assert_operation(&plan, 2u, CAN_CONTROL_OPERATION_VGA, 0x30u); - assert(plan.resulting_state.phase_state == 64u); - assert(plan.resulting_state.phase_address == 4u); - assert(plan.resulting_state.attenuation_db == 12u); -} - -static void test_combined_plan_avoids_duplicate_max_attenuation_write(void) -{ - const can_control_state_t current = initial_state(); - can_command_t command = command_of(CAN_MESSAGE_SET_COMBINED); - command.phase_state = 64u; - command.phase_address = 4u; - command.attenuation_db = 23u; - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 2u); - assert_operation(&plan, 0u, CAN_CONTROL_OPERATION_VGA, 0x5cu); - assert(plan.operations[1].type == CAN_CONTROL_OPERATION_PHASE); -} - -static void test_safe_plan_forces_maximum_attenuation_and_zero_phase(void) -{ - const can_control_state_t current = { - .phase_state = 200u, - .phase_address = 2u, - .attenuation_db = 0u, - }; - can_command_t command = command_of(CAN_MESSAGE_ENTER_SAFE); - command.phase_address = 3u; - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 2u); - assert_operation(&plan, 0u, CAN_CONTROL_OPERATION_VGA, 0x5cu); - assert_operation(&plan, 1u, CAN_CONTROL_OPERATION_PHASE, 0x000cu); - assert(plan.resulting_state.phase_state == 0u); - assert(plan.resulting_state.phase_address == 3u); - assert(plan.resulting_state.attenuation_db == 23u); -} - -static void test_ping_has_no_hardware_actions(void) -{ - const can_control_state_t current = { - .phase_state = 1u, - .phase_address = 2u, - .attenuation_db = 3u, - }; - const can_command_t command = command_of(CAN_MESSAGE_PING); - - can_control_plan_t plan = {0}; - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_OK); - assert(plan.operation_count == 0u); - assert(plan.resulting_state.phase_state == current.phase_state); - assert(plan.resulting_state.attenuation_db == current.attenuation_db); -} - -static void test_invalid_arguments_and_unsupported_types(void) -{ - const can_control_state_t current = {0}; - can_control_plan_t plan = {0}; - can_command_t command = command_of(CAN_MESSAGE_STATUS); - - assert(can_control_plan_command(NULL, ¤t, &plan) == CAN_COMMAND_RESULT_INVALID_PAYLOAD); - assert(can_control_plan_command(&command, NULL, &plan) == CAN_COMMAND_RESULT_INVALID_PAYLOAD); - assert(can_control_plan_command(&command, ¤t, NULL) == CAN_COMMAND_RESULT_INVALID_PAYLOAD); - assert(can_control_plan_command(&command, ¤t, &plan) == CAN_COMMAND_RESULT_UNSUPPORTED); -} - -int main(void) -{ - test_phase_plan(); - test_phase_plan_temporarily_attenuates_and_restores_active_output(); - test_vga_plan(); - test_combined_plan_uses_safe_transition_order(); - test_combined_plan_avoids_duplicate_max_attenuation_write(); - test_safe_plan_forces_maximum_attenuation_and_zero_phase(); - test_ping_has_no_hardware_actions(); - test_invalid_arguments_and_unsupported_types(); - puts("CAN control tests passed"); - return 0; -} diff --git a/stm32/tests/test_can_protocol.c b/stm32/tests/test_can_protocol.c index faf3eab..95100dd 100644 --- a/stm32/tests/test_can_protocol.c +++ b/stm32/tests/test_can_protocol.c @@ -1,4 +1,4 @@ -#include "can_protocol.h" +#include "can/protocol.h" #include #include @@ -76,33 +76,115 @@ static void test_safe_command_has_highest_command_arbitration_priority(void) assert(phase_id < vga_id); } -static void test_decode_phase_command(void) +static void test_decode_bulk_commands(void) { - const uint8_t payload[] = {146u, 3u}; - const can_frame_t frame = frame_for( + can_command_t command = {0}; + + const uint8_t phase_payload[] = {146u, 128u, 64u, 0u}; + can_frame_t frame = frame_for( CAN_MESSAGE_SET_PHASE, 1u, CAN_NODE_CONTROLLER, 7u, - payload, - sizeof(payload)); - - can_command_t command = {0}; + phase_payload, + sizeof(phase_payload)); assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); assert(command.type == CAN_MESSAGE_SET_PHASE); assert(command.source == CAN_NODE_CONTROLLER); assert(command.destination == 1u); assert(command.sequence == 7u); - assert(command.phase_state == 146u); - assert(command.phase_address == 3u); + assert(command.bulk_update); + assert(memcmp(command.phase_states, phase_payload, RF_CHANNEL_COUNT) == 0); + + const uint8_t vga_payload[] = {0u, 8u, 12u, 23u}; + frame = frame_for( + CAN_MESSAGE_SET_VGA, + 1u, + CAN_NODE_CONTROLLER, + 8u, + vga_payload, + sizeof(vga_payload)); + memset(&command, 0, sizeof(command)); + assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); + assert(command.bulk_update); + assert(memcmp(command.attenuation_db, vga_payload, RF_CHANNEL_COUNT) == 0); + + const uint8_t combined_payload[] = { + 10u, 20u, 30u, 40u, + 1u, 2u, 3u, 4u, + }; + frame = frame_for( + CAN_MESSAGE_SET_COMBINED, + 1u, + CAN_NODE_CONTROLLER, + 9u, + combined_payload, + sizeof(combined_payload)); + memset(&command, 0, sizeof(command)); + assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); + assert(command.bulk_update); + assert(memcmp(command.phase_states, combined_payload, RF_CHANNEL_COUNT) == 0); + assert(memcmp( + command.attenuation_db, + &combined_payload[RF_CHANNEL_COUNT], + RF_CHANNEL_COUNT) == 0); } -static void test_broadcast_accepts_only_enter_safe(void) + +static void test_decode_individual_commands(void) { can_command_t command = {0}; - /* v1.1: broadcast SET_COMBINED (any non-ENTER_SAFE) is not allowed. */ - const uint8_t combined_payload[] = {64u, 4u, 23u}; + const uint8_t phase_payload[] = {146u, 2u}; + can_frame_t frame = frame_for( + CAN_MESSAGE_SET_PHASE, + 1u, + CAN_NODE_CONTROLLER, + 10u, + phase_payload, + sizeof(phase_payload)); + assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); + assert(!command.bulk_update); + assert(command.channel == 2u); + assert(command.phase_states[2] == 146u); + + const uint8_t vga_payload[] = {8u, 1u}; + frame = frame_for( + CAN_MESSAGE_SET_VGA, + 1u, + CAN_NODE_CONTROLLER, + 11u, + vga_payload, + sizeof(vga_payload)); + memset(&command, 0, sizeof(command)); + assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); + assert(!command.bulk_update); + assert(command.channel == 1u); + assert(command.attenuation_db[1] == 8u); + + const uint8_t combined_payload[] = {64u, 3u, 12u}; + frame = frame_for( + CAN_MESSAGE_SET_COMBINED, + 1u, + CAN_NODE_CONTROLLER, + 12u, + combined_payload, + sizeof(combined_payload)); + memset(&command, 0, sizeof(command)); + assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); + assert(!command.bulk_update); + assert(command.channel == 3u); + assert(command.phase_states[3] == 64u); + assert(command.attenuation_db[3] == 12u); +} + +static void test_broadcast_accepts_only_enter_safe(void) +{ + can_command_t command = {0}; + const uint8_t combined_payload[] = { + 10u, 20u, 30u, 40u, + 1u, 2u, 3u, 4u, + }; const can_frame_t combined = frame_for( CAN_MESSAGE_SET_COMBINED, CAN_NODE_BROADCAST, @@ -110,30 +192,40 @@ static void test_broadcast_accepts_only_enter_safe(void) 8u, combined_payload, sizeof(combined_payload)); - assert(can_protocol_decode_command(&combined, 5u, &command) == - CAN_DECODE_BROADCAST_NOT_ALLOWED); + assert(can_protocol_decode_command(&combined, 5u, &command) + == CAN_DECODE_BROADCAST_NOT_ALLOWED); - /* Broadcast SET_PHASE / SET_VGA / PING are also rejected. */ - const uint8_t phase_payload[] = {128u, 2u}; + const uint8_t phase_payload[] = {128u, 64u, 32u, 16u}; const can_frame_t phase = frame_for( - CAN_MESSAGE_SET_PHASE, CAN_NODE_BROADCAST, CAN_NODE_CONTROLLER, 9u, - phase_payload, sizeof(phase_payload)); - assert(can_protocol_decode_command(&phase, 5u, &command) == - CAN_DECODE_BROADCAST_NOT_ALLOWED); + CAN_MESSAGE_SET_PHASE, + CAN_NODE_BROADCAST, + CAN_NODE_CONTROLLER, + 9u, + phase_payload, + sizeof(phase_payload)); + assert(can_protocol_decode_command(&phase, 5u, &command) + == CAN_DECODE_BROADCAST_NOT_ALLOWED); const can_frame_t ping = frame_for( - CAN_MESSAGE_PING, CAN_NODE_BROADCAST, CAN_NODE_CONTROLLER, 10u, - NULL, 0u); - assert(can_protocol_decode_command(&ping, 5u, &command) == - CAN_DECODE_BROADCAST_NOT_ALLOWED); + CAN_MESSAGE_PING, + CAN_NODE_BROADCAST, + CAN_NODE_CONTROLLER, + 10u, + NULL, + 0u); + assert(can_protocol_decode_command(&ping, 5u, &command) + == CAN_DECODE_BROADCAST_NOT_ALLOWED); - /* Broadcast ENTER_SAFE remains accepted. */ const uint8_t safe_payload[] = {0u}; const can_frame_t safe = frame_for( - CAN_MESSAGE_ENTER_SAFE, CAN_NODE_BROADCAST, CAN_NODE_CONTROLLER, 11u, - safe_payload, sizeof(safe_payload)); + CAN_MESSAGE_ENTER_SAFE, + CAN_NODE_BROADCAST, + CAN_NODE_CONTROLLER, + 11u, + safe_payload, + sizeof(safe_payload)); assert(can_protocol_decode_command(&safe, 5u, &command) == CAN_DECODE_OK); - assert(command.phase_address == 0u); + assert(command.channel == 0u); } static void test_decode_ignores_other_nodes_and_non_commands(void) @@ -152,75 +244,89 @@ static void test_decode_ignores_other_nodes_and_non_commands(void) frame.id = 0x123u; assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_INVALID_ID); - frame = frame_for( - CAN_MESSAGE_PING, - 1u, - CAN_NODE_CONTROLLER, - 1u, - NULL, - 0u); + frame = frame_for(CAN_MESSAGE_PING, 1u, CAN_NODE_CONTROLLER, 1u, NULL, 0u); frame.remote = true; assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_INVALID_ID); - frame = frame_for( - CAN_MESSAGE_STATUS, - 1u, - CAN_NODE_CONTROLLER, - 1u, - NULL, - 0u); - assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_UNSUPPORTED_TYPE); + frame = frame_for(CAN_MESSAGE_STATUS, 1u, CAN_NODE_CONTROLLER, 1u, NULL, 0u); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_UNSUPPORTED_TYPE); } static void test_decode_validates_lengths_and_payloads(void) { can_command_t command = {0}; - const uint8_t phase_payload[] = {1u, 16u}; + + const uint8_t short_phase[] = {1u, 2u, 3u}; can_frame_t frame = frame_for( CAN_MESSAGE_SET_PHASE, 1u, - 0u, + CAN_NODE_CONTROLLER, 1u, - phase_payload, - sizeof(phase_payload)); - assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_INVALID_PAYLOAD); + short_phase, + sizeof(short_phase)); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_INVALID_LENGTH); - const uint8_t vga_payload[] = {24u}; + const uint8_t bad_vga[] = {0u, 8u, 24u, 23u}; frame = frame_for( CAN_MESSAGE_SET_VGA, 1u, - 0u, - 1u, - vga_payload, - sizeof(vga_payload)); - assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_INVALID_PAYLOAD); + CAN_NODE_CONTROLLER, + 2u, + bad_vga, + sizeof(bad_vga)); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_INVALID_PAYLOAD); - const uint8_t combined_payload[] = {1u, 3u}; + const uint8_t short_combined[] = {1u, 2u, 3u, 4u, 5u, 6u, 7u}; frame = frame_for( CAN_MESSAGE_SET_COMBINED, 1u, - 0u, - 1u, - combined_payload, - sizeof(combined_payload)); - assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_INVALID_LENGTH); + CAN_NODE_CONTROLLER, + 3u, + short_combined, + sizeof(short_combined)); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_INVALID_LENGTH); + /* A two-byte individual frame padded to four bytes is a bulk frame. */ + const uint8_t ambiguous_phase[] = {128u, 2u, 0u, 0u}; frame = frame_for( - CAN_MESSAGE_PING, + CAN_MESSAGE_SET_PHASE, 1u, - 0u, + CAN_NODE_CONTROLLER, + 4u, + ambiguous_phase, + sizeof(ambiguous_phase)); + assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); + assert(command.bulk_update); + assert(command.phase_states[0] == 128u); + assert(command.phase_states[1] == 2u); + + const uint8_t invalid_phase_length[] = {1u, 2u, 3u, 4u, 0u}; + frame = frame_for( + CAN_MESSAGE_SET_PHASE, 1u, - vga_payload, - 1u); - assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_RESERVED_BYTES); + CAN_NODE_CONTROLLER, + 4u, + invalid_phase_length, + sizeof(invalid_phase_length)); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_INVALID_LENGTH); + const uint8_t nonzero[] = {1u}; frame = frame_for( CAN_MESSAGE_PING, 1u, - 2u, - 1u, - NULL, - 0u); + CAN_NODE_CONTROLLER, + 5u, + nonzero, + sizeof(nonzero)); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_INVALID_LENGTH); + + frame = frame_for(CAN_MESSAGE_PING, 1u, 2u, 6u, NULL, 0u); assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_INVALID_ID); } @@ -231,14 +337,25 @@ static void test_safe_and_ping_commands(void) can_frame_t frame = frame_for( CAN_MESSAGE_ENTER_SAFE, 1u, - 0u, + CAN_NODE_CONTROLLER, 9u, safe_payload, sizeof(safe_payload)); assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); - assert(command.phase_address == 3u); + assert(command.channel == 3u); + + const uint8_t invalid_safe[] = {4u}; + frame = frame_for( + CAN_MESSAGE_ENTER_SAFE, + 1u, + CAN_NODE_CONTROLLER, + 10u, + invalid_safe, + sizeof(invalid_safe)); + assert(can_protocol_decode_command(&frame, 1u, &command) + == CAN_DECODE_INVALID_PAYLOAD); - frame = frame_for(CAN_MESSAGE_PING, 1u, 0u, 10u, NULL, 0u); + frame = frame_for(CAN_MESSAGE_PING, 1u, CAN_NODE_CONTROLLER, 11u, NULL, 0u); assert(can_protocol_decode_command(&frame, 1u, &command) == CAN_DECODE_OK); assert(command.type == CAN_MESSAGE_PING); } @@ -276,22 +393,20 @@ static void test_response_builders(void) const can_status_payload_t status = { .node_id = 1u, - .phase_state = 146u, - .phase_address = 3u, - .attenuation_db = 23u, .health_flags = CAN_HEALTH_CLOCK_FALLBACK, - .rx_dropped = 300u, + .rx_dropped = 3u, + .tx_dropped = 4u, .invalid_commands = 2u, }; assert(can_protocol_make_status(1u, 0u, 44u, &status, &response)); assert(response.length == 8u); - assert(response.data[0] == CAN_PROTOCOL_VERSION); - assert(response.data[1] == 1u); - assert(response.data[2] == 146u); - assert(response.data[3] == 3u); - assert(response.data[4] == 23u); - assert(response.data[5] == CAN_HEALTH_CLOCK_FALLBACK); - assert(response.data[6] == 44u); /* 300 & 0xff */ + assert(response.data[0] == CAN_PROTOCOL_VERSION_MAJOR); + assert(response.data[1] == CAN_PROTOCOL_VERSION_MINOR); + assert(response.data[2] == CAN_PROTOCOL_VERSION_PATCH); + assert(response.data[3] == 1u); + assert(response.data[4] == CAN_HEALTH_CLOCK_FALLBACK); + assert(response.data[5] == 3u); + assert(response.data[6] == 4u); assert(response.data[7] == 2u); } @@ -300,7 +415,8 @@ int main(void) test_identifier_round_trip(); test_identifier_rejects_invalid_fields(); test_safe_command_has_highest_command_arbitration_priority(); - test_decode_phase_command(); + test_decode_bulk_commands(); + test_decode_individual_commands(); test_broadcast_accepts_only_enter_safe(); test_decode_ignores_other_nodes_and_non_commands(); test_decode_validates_lengths_and_payloads(); diff --git a/stm32/tests/test_can_queue.c b/stm32/tests/test_can_rx_queue.c similarity index 97% rename from stm32/tests/test_can_queue.c rename to stm32/tests/test_can_rx_queue.c index 6809887..f063317 100644 --- a/stm32/tests/test_can_queue.c +++ b/stm32/tests/test_can_rx_queue.c @@ -1,4 +1,4 @@ -#include "can_queue.h" +#include "can/rx_queue.h" #include #include @@ -91,6 +91,6 @@ int main(void) test_full_queue_drops_new_frame(); test_wraparound(); test_invalid_arguments(); - puts("CAN queue tests passed"); + puts("CAN RX queue tests passed"); return 0; } diff --git a/stm32/tests/test_can_smoke_tool.py b/stm32/tests/test_can_smoke_tool.py index 0da91ef..de2ae87 100644 --- a/stm32/tests/test_can_smoke_tool.py +++ b/stm32/tests/test_can_smoke_tool.py @@ -4,9 +4,10 @@ import unittest from pathlib import Path - MODULE_PATH = Path(__file__).resolve().parents[1] / "tools" / "can_smoke_test.py" -HEADER_PATH = Path(__file__).resolve().parents[1] / "app" / "include" / "can_protocol.h" +INCLUDE_PATH = Path(__file__).resolve().parents[1] / "app" / "include" +PROTOCOL_HEADER_PATH = INCLUDE_PATH / "can" / "protocol.h" +RF_HEADER_PATH = INCLUDE_PATH / "rf" / "limits.h" SPEC = importlib.util.spec_from_file_location("can_smoke_test", MODULE_PATH) assert SPEC is not None and SPEC.loader is not None MODULE = importlib.util.module_from_spec(SPEC) @@ -15,31 +16,54 @@ class CanSmokeToolTests(unittest.TestCase): def test_python_constants_match_c_protocol_header(self) -> None: - header = HEADER_PATH.read_text(encoding="utf-8") + protocol_header = PROTOCOL_HEADER_PATH.read_text(encoding="utf-8") + rf_header = RF_HEADER_PATH.read_text(encoding="utf-8") - def macro(name: str) -> int: - match = re.search(rf"^#define {name} (0x[0-9a-fA-F]+|[0-9]+)u$", header, re.MULTILINE) + def macro(header: str, name: str) -> int: + match = re.search( + rf"^#define {name} (0x[0-9a-fA-F]+|[0-9]+)u$", header, re.MULTILINE + ) self.assertIsNotNone(match, name) assert match is not None return int(match.group(1), 0) def enum_value(name: str) -> int: - match = re.search(rf"^ {name} = ([0-9]+),?$", header, re.MULTILINE) + match = re.search( + rf"^ {name} = ([0-9]+),?$", protocol_header, re.MULTILINE + ) self.assertIsNotNone(match, name) assert match is not None return int(match.group(1), 10) - self.assertEqual(MODULE.CAN_PROTOCOL_VERSION, macro("CAN_PROTOCOL_VERSION")) - self.assertEqual(MODULE.CAN_NODE_CONTROLLER, macro("CAN_NODE_CONTROLLER")) - self.assertEqual(MODULE.CAN_NODE_MIN, macro("CAN_NODE_MIN")) - self.assertEqual(MODULE.CAN_NODE_MAX, macro("CAN_NODE_MAX")) - self.assertEqual(MODULE.CAN_NODE_BROADCAST, macro("CAN_NODE_BROADCAST")) - self.assertEqual(MODULE.CAN_ID_TYPE_SHIFT, macro("CAN_ID_TYPE_SHIFT")) + self.assertEqual( + MODULE.CAN_PROTOCOL_VERSION, + ( + macro(protocol_header, "CAN_PROTOCOL_VERSION_MAJOR"), + macro(protocol_header, "CAN_PROTOCOL_VERSION_MINOR"), + macro(protocol_header, "CAN_PROTOCOL_VERSION_PATCH"), + ), + ) + self.assertEqual(MODULE.RF_CHANNEL_COUNT, macro(rf_header, "RF_CHANNEL_COUNT")) + self.assertEqual(MODULE.RF_CHANNEL_MAX, macro(rf_header, "RF_CHANNEL_MAX")) + self.assertEqual( + MODULE.CAN_NODE_CONTROLLER, macro(protocol_header, "CAN_NODE_CONTROLLER") + ) + self.assertEqual(MODULE.CAN_NODE_MIN, macro(protocol_header, "CAN_NODE_MIN")) + self.assertEqual(MODULE.CAN_NODE_MAX, macro(protocol_header, "CAN_NODE_MAX")) + self.assertEqual( + MODULE.CAN_NODE_BROADCAST, macro(protocol_header, "CAN_NODE_BROADCAST") + ) + self.assertEqual( + MODULE.CAN_ID_TYPE_SHIFT, macro(protocol_header, "CAN_ID_TYPE_SHIFT") + ) self.assertEqual( MODULE.CAN_ID_DESTINATION_SHIFT, - macro("CAN_ID_DESTINATION_SHIFT"), + macro(protocol_header, "CAN_ID_DESTINATION_SHIFT"), + ) + self.assertEqual( + MODULE.CAN_ID_SOURCE_SHIFT, + macro(protocol_header, "CAN_ID_SOURCE_SHIFT"), ) - self.assertEqual(MODULE.CAN_ID_SOURCE_SHIFT, macro("CAN_ID_SOURCE_SHIFT")) for message_type in MODULE.MessageType: self.assertEqual( @@ -57,26 +81,48 @@ def test_identifier_layout_matches_firmware(self) -> None: self.assertEqual(identifier, 0x08201234) def test_linux_frame_round_trip(self) -> None: - raw = MODULE.pack_linux_can_frame(0x08201234, bytes([146, 3])) + payload = bytes([146, 128, 64, 0]) + raw = MODULE.pack_linux_can_frame(0x08201234, payload) self.assertEqual(len(raw), 16) can_id, length = struct.unpack_from("=IB", raw) self.assertEqual(can_id, MODULE.CAN_EFF_FLAG | 0x08201234) - self.assertEqual(length, 2) + self.assertEqual(length, 4) decoded = MODULE.unpack_linux_can_frame(raw) self.assertTrue(decoded.extended) self.assertFalse(decoded.remote) self.assertEqual(decoded.identifier, 0x08201234) - self.assertEqual(decoded.data, bytes([146, 3])) + self.assertEqual(decoded.data, payload) def test_command_payload_validation(self) -> None: self.assertEqual( - MODULE.command_payload(MODULE.MessageType.SET_VGA, [23]), - bytes([23]), + MODULE.command_payload(MODULE.MessageType.SET_PHASE, [128, 2]), + bytes([128, 2]), + ) + self.assertEqual( + MODULE.command_payload(MODULE.MessageType.SET_VGA, [8, 1]), + bytes([8, 1]), + ) + self.assertEqual( + MODULE.command_payload(MODULE.MessageType.SET_VGA, [0, 8, 12, 23]), + bytes([0, 8, 12, 23]), ) + self.assertEqual( + MODULE.command_payload(MODULE.MessageType.SET_COMBINED, [64, 3, 12]), + bytes([64, 3, 12]), + ) + self.assertEqual( + MODULE.command_payload( + MODULE.MessageType.SET_COMBINED, + [10, 20, 30, 40, 1, 2, 3, 4], + ), + bytes([10, 20, 30, 40, 1, 2, 3, 4]), + ) + with self.assertRaises(ValueError): + MODULE.command_payload(MODULE.MessageType.SET_PHASE, [1, 2, 3]) with self.assertRaises(ValueError): - MODULE.command_payload(MODULE.MessageType.SET_VGA, [24]) + MODULE.command_payload(MODULE.MessageType.SET_VGA, [0, 8, 24, 23]) with self.assertRaises(ValueError): MODULE.make_identifier(MODULE.MessageType.PING, 32, 0, 0) diff --git a/stm32/tests/test_can_tx_queue b/stm32/tests/test_can_tx_queue deleted file mode 100755 index 9e0d499..0000000 Binary files a/stm32/tests/test_can_tx_queue and /dev/null differ diff --git a/stm32/tests/test_can_tx_queue.c b/stm32/tests/test_can_tx_queue.c index eb1b262..5ff247d 100644 --- a/stm32/tests/test_can_tx_queue.c +++ b/stm32/tests/test_can_tx_queue.c @@ -1,10 +1,10 @@ -/* Host test for the prioritized TX queue (can_tx_queue.c). No libopencm3. */ +/* Host test for the prioritized TX queue. No libopencm3. */ #include #include #include #include -#include "can_tx_queue.h" +#include "can/tx_queue.h" static can_frame_t frame_with(uint32_t id, uint8_t tag) { diff --git a/stm32/tests/test_diagnostics.c b/stm32/tests/test_diagnostics.c index f131b4f..ed7dfc6 100644 --- a/stm32/tests/test_diagnostics.c +++ b/stm32/tests/test_diagnostics.c @@ -1,4 +1,4 @@ -#include "diagnostic_record.h" +#include "platform/diagnostic_record.h" #include #include @@ -67,11 +67,11 @@ static void test_fault_and_command_history(void) record = diagnostic_record_begin_boot(&record, 0xa5u, 7u); diagnostic_record_set_clock(&record, FIRMWARE_CLOCK_HSI48); - diagnostic_record_set_commands(&record, 0x092cu, 0x5cu); + diagnostic_record_set_commands(&record, 0x162cu, 0x5cu); diagnostic_record_set_fault(&record, FIRMWARE_FAULT_PHASE_SPI_TIMEOUT); assert(record.clock_source == FIRMWARE_CLOCK_HSI48); - assert(record.last_phase_command == 0x092cu); + assert(record.last_phase_command == 0x162cu); assert(record.last_vga_command == 0x5cu); assert(record.current_fault == FIRMWARE_FAULT_PHASE_SPI_TIMEOUT); assert(diagnostic_record_is_valid(&record)); diff --git a/stm32/tests/test_protocol.c b/stm32/tests/test_protocol.c deleted file mode 100644 index 0bfba78..0000000 --- a/stm32/tests/test_protocol.c +++ /dev/null @@ -1,85 +0,0 @@ -#include "beamforming_protocol.h" - -#include -#include -#include -#include - -static void test_known_phase_command(void) -{ - uint8_t state = 0u; - assert(phase_state_from_millidegrees(205300u, &state)); - assert(state == 146u); - - uint16_t command = 0; - assert(phase_command_from_millidegrees(205300u, 3u, &command)); - assert(command == 0x092cu); -} - -static void test_phase_boundaries(void) -{ - uint16_t command = 0xffffu; - - assert(phase_command_from_millidegrees(0u, 3u, &command)); - assert(command == 0x000cu); - - assert(phase_command_from_millidegrees(360000u, 3u, &command)); - assert(command == 0x000cu); - - assert(!phase_command_from_millidegrees(360001u, 3u, &command)); - assert(!phase_command_from_millidegrees(0u, 16u, &command)); - assert(!phase_command_from_millidegrees(0u, 0u, NULL)); - - uint8_t state = 0xffu; - assert(phase_state_from_millidegrees(360000u, &state)); - assert(state == 0u); - assert(!phase_state_from_millidegrees(360001u, &state)); - assert(!phase_state_from_millidegrees(0u, NULL)); -} - -static void test_every_phase_state(void) -{ - for (uint16_t state = 0; state < PHASE_STATE_COUNT; ++state) { - uint16_t command = 0; - assert(phase_command_from_state((uint8_t)state, 0x0fu, &command)); - assert(command <= PHASE_COMMAND_MAX); - - const bool expected_option = (state & PHASE_OPTION_STATE_BIT) != 0u; - const bool encoded_option = (command & PHASE_COMMAND_OPTION_MASK) != 0u; - assert(expected_option == encoded_option); - } -} - -static void test_bit_reversal(void) -{ - assert(beamforming_reverse_bits(0x92u, 8u) == 0x49u); - assert(beamforming_reverse_bits(0x03u, 4u) == 0x0cu); - assert(beamforming_reverse_bits(0xffffu, 0u) == 0u); -} - -static void test_vga_commands(void) -{ - for (uint8_t attenuation = 0; attenuation <= VGA_MAX_ATTENUATION_DB; ++attenuation) { - uint8_t command = 0; - assert(vga_command_from_attenuation(attenuation, &command)); - assert(command == (uint8_t)(attenuation << VGA_ATTENUATION_SHIFT)); - } - - uint8_t command = 0; - assert(vga_command_from_attenuation(23u, &command)); - assert(command == 0x5cu); - assert(!vga_command_from_attenuation(24u, &command)); - assert(!vga_command_from_attenuation(0u, NULL)); -} - -int main(void) -{ - test_known_phase_command(); - test_phase_boundaries(); - test_every_phase_state(); - test_bit_reversal(); - test_vga_commands(); - - puts("protocol tests passed"); - return 0; -} diff --git a/stm32/tests/test_protocol_vectors b/stm32/tests/test_protocol_vectors deleted file mode 100755 index e375ac0..0000000 Binary files a/stm32/tests/test_protocol_vectors and /dev/null differ diff --git a/stm32/tests/test_protocol_vectors.c b/stm32/tests/test_protocol_vectors.c index 27d63ad..1e43539 100644 --- a/stm32/tests/test_protocol_vectors.c +++ b/stm32/tests/test_protocol_vectors.c @@ -1,5 +1,5 @@ /* Protocol contract test: the C firmware must agree with the shared protocol - * vectors. Compiles protocol/generated/protocol_vectors.h against can_protocol.c + * vectors. Compiles protocol/generated/protocol_vectors.h against can/protocol.c * so the generated header is real, compilable C AND matches the firmware. * * Build: make -C stm32/tests test @@ -9,7 +9,7 @@ #include #include -#include "can_protocol.h" +#include "can/protocol.h" #include "protocol_vectors.h" static void test_id_vectors(void) diff --git a/stm32/tests/test_rf_commands.c b/stm32/tests/test_rf_commands.c new file mode 100644 index 0000000..ddafb3c --- /dev/null +++ b/stm32/tests/test_rf_commands.c @@ -0,0 +1,98 @@ +#include "rf/commands.h" + +#include +#include +#include +#include + +static void test_known_phase_command(void) +{ + uint8_t stateWordTableIndex = 0u; + assert(phase_state_from_millidegrees(205300u, &stateWordTableIndex)); + assert(stateWordTableIndex == 146u); + + const optimizedPhaseState_e phaseState = + GetOptimizedPhaseState(stateWordTableIndex); + assert(phaseState == OPTIMIZED_STATE_146); + assert(phaseState == 0b010001101); + assert(MakePSCommand(phaseState, 0b0011) == 0x162cu); + + assert(GetOptimizedPhaseState(1u) == OPTIMIZED_STATE_1); + assert(OPTIMIZED_STATE_1 == 0b100000001); + assert(MakePSCommand(OPTIMIZED_STATE_1, 0b0011) == 0x101cu); +} + +static void test_phase_boundaries(void) +{ + uint8_t stateWordTableIndex = 0xffu; + + assert(phase_state_from_millidegrees(0u, &stateWordTableIndex)); + assert(stateWordTableIndex == 0u); + assert(MakePSCommand(GetOptimizedPhaseState(stateWordTableIndex), 0b0011) + == 0x000cu); + + assert(phase_state_from_millidegrees(360000u, &stateWordTableIndex)); + assert(stateWordTableIndex == 0u); + assert(!phase_state_from_millidegrees(360001u, &stateWordTableIndex)); + assert(!phase_state_from_millidegrees(0u, NULL)); +} + +static void test_every_phase_state(void) +{ + for (uint16_t index = 0u; index < PHASE_STATE_COUNT; ++index) { + const optimizedPhaseState_e phaseState = + GetOptimizedPhaseState((uint8_t)index); + const uint16_t stateWord = (uint16_t)phaseState; + assert(stateWord < (1u << PHASE_CONTROL_WORD_BITS)); + + const uint16_t command = MakePSCommand(phaseState, 0b1111); + assert(command <= PHASE_COMMAND_MAX); + + const bool expectedOptBit = + (stateWord & PHASE_CONTROL_WORD_OPTION_MASK) != 0u; + const bool encodedOptBit = + (command & PHASE_COMMAND_OPTION_MASK) != 0u; + assert(expectedOptBit == encodedOptBit); + } + + assert(GetOptimizedPhaseState(71u) == OPTIMIZED_STATE_71); + assert(GetOptimizedPhaseState(72u) == OPTIMIZED_STATE_72); + assert(OPTIMIZED_STATE_71 == OPTIMIZED_STATE_72); + assert(GetOptimizedPhaseState(255u) == OPTIMIZED_STATE_255); +} + +static void test_bit_reversal(void) +{ + assert(reverseBits(0x92u, 8u) == 0x49u); + assert(reverseBits(0x03u, 4u) == 0x0cu); + assert(reverseBits(0xffffu, 0u) == 0u); +} + +static void test_vga_commands(void) +{ + for (uint8_t attenuation = 0u; attenuation <= VGA_MAX_ATTENUATION_DB; ++attenuation) { + uint8_t command = 0u; + assert(MakeVGACommand(attenuation, &command)); + assert(command == (uint8_t)(attenuation << VGA_COMMAND_ATTENUATION_SHIFT)); + } + + uint8_t command = 0u; + assert(MakeVGACommand(3u, &command)); + assert(command == 0x0cu); + assert(MakeVGACommand(23u, &command)); + assert(command == 0x5cu); + assert(!MakeVGACommand(24u, &command)); + assert(!MakeVGACommand(0u, NULL)); +} + +int main(void) +{ + test_known_phase_command(); + test_phase_boundaries(); + test_every_phase_state(); + test_bit_reversal(); + test_vga_commands(); + + puts("RF command tests passed"); + return 0; +} diff --git a/stm32/tests/test_rf_plan.c b/stm32/tests/test_rf_plan.c new file mode 100644 index 0000000..38fce3a --- /dev/null +++ b/stm32/tests/test_rf_plan.c @@ -0,0 +1,361 @@ +#include "rf/commands.h" +#include "rf/plan.h" + +#include +#include +#include + +static can_command_t command_of(can_message_type_t type) +{ + can_command_t command = {0}; + command.type = type; + command.source = CAN_NODE_CONTROLLER; + command.destination = 1u; + command.sequence = 1u; + return command; +} + +static rf_state_t initial_state(void) +{ + const rf_state_t state = { + .phase_states = {0u, 0u, 0u, 0u}, + .attenuation_db = {23u, 23u, 23u, 23u}, + }; + return state; +} + +static uint16_t phase_command(uint8_t channel, uint8_t state_index) +{ + const optimizedPhaseState_e phase_state = + GetOptimizedPhaseState(state_index); + return MakePSCommand( + phase_state, + (uint8_t)(PHASE_UNIT_ADDRESS_MIN + channel)); +} + +static void assert_operation( + const rf_plan_t *plan, + uint8_t index, + rf_operation_type_t type, + uint8_t channel, + uint16_t command); + +static void test_startup_plan(void) +{ + rf_plan_t plan = {0}; + assert(rf_plan_startup(146u, 23u, &plan)); + assert(plan.operation_count == 8u); + + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + assert_operation(&plan, channel, RF_OPERATION_VGA, channel, 0x5cu); + assert_operation( + &plan, + (uint8_t)(RF_CHANNEL_COUNT + channel), + RF_OPERATION_PHASE, + channel, + phase_command(channel, 146u)); + assert(plan.resulting_state.phase_states[channel] == 146u); + assert(plan.resulting_state.attenuation_db[channel] == 23u); + } + + assert(!rf_plan_startup(0u, 24u, &plan)); + assert(!rf_plan_startup(0u, 0u, NULL)); +} + +static void assert_operation( + const rf_plan_t *plan, + uint8_t index, + rf_operation_type_t type, + uint8_t channel, + uint16_t command) +{ + assert(index < plan->operation_count); + assert(plan->operations[index].type == type); + assert(plan->operations[index].channel == channel); + assert(plan->operations[index].command == command); +} + +static void test_bulk_phase_plan(void) +{ + const rf_state_t current = initial_state(); + can_command_t command = command_of(CAN_MESSAGE_SET_PHASE); + command.bulk_update = true; + const uint8_t requested[] = {146u, 128u, 64u, 0u}; + memcpy(command.phase_states, requested, sizeof(requested)); + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 4u); + + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + assert_operation( + &plan, + channel, + RF_OPERATION_PHASE, + channel, + phase_command(channel, requested[channel])); + } + + assert(memcmp(plan.resulting_state.phase_states, requested, sizeof(requested)) == 0); + assert(memcmp( + plan.resulting_state.attenuation_db, + current.attenuation_db, + RF_CHANNEL_COUNT) == 0); +} + +static void test_bulk_phase_plan_attenuates_then_restores(void) +{ + const rf_state_t current = { + .phase_states = {1u, 2u, 3u, 4u}, + .attenuation_db = {8u, 23u, 12u, 23u}, + }; + can_command_t command = command_of(CAN_MESSAGE_SET_PHASE); + command.bulk_update = true; + const uint8_t requested[] = {10u, 20u, 30u, 40u}; + memcpy(command.phase_states, requested, sizeof(requested)); + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 8u); + + assert_operation(&plan, 0u, RF_OPERATION_VGA, 0u, 0x5cu); + assert_operation(&plan, 1u, RF_OPERATION_VGA, 2u, 0x5cu); + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + assert_operation( + &plan, + (uint8_t)(2u + channel), + RF_OPERATION_PHASE, + channel, + phase_command(channel, requested[channel])); + } + assert_operation(&plan, 6u, RF_OPERATION_VGA, 0u, 0x20u); + assert_operation(&plan, 7u, RF_OPERATION_VGA, 2u, 0x30u); + + assert(memcmp(plan.resulting_state.phase_states, requested, sizeof(requested)) == 0); + assert(memcmp( + plan.resulting_state.attenuation_db, + current.attenuation_db, + RF_CHANNEL_COUNT) == 0); +} + +static void test_bulk_vga_plan(void) +{ + const rf_state_t current = initial_state(); + can_command_t command = command_of(CAN_MESSAGE_SET_VGA); + command.bulk_update = true; + const uint8_t requested[] = {0u, 8u, 12u, 23u}; + memcpy(command.attenuation_db, requested, sizeof(requested)); + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 4u); + + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + assert_operation( + &plan, + channel, + RF_OPERATION_VGA, + channel, + (uint16_t)(requested[channel] << VGA_COMMAND_ATTENUATION_SHIFT)); + } + assert(memcmp( + plan.resulting_state.attenuation_db, + requested, + sizeof(requested)) == 0); +} + +static void test_bulk_combined_plan_uses_safe_transition_order(void) +{ + const rf_state_t current = initial_state(); + can_command_t command = command_of(CAN_MESSAGE_SET_COMBINED); + command.bulk_update = true; + const uint8_t phases[] = {64u, 65u, 66u, 67u}; + const uint8_t attenuations[] = {12u, 23u, 0u, 8u}; + memcpy(command.phase_states, phases, sizeof(phases)); + memcpy(command.attenuation_db, attenuations, sizeof(attenuations)); + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 11u); + + for (uint8_t channel = 0u; channel < RF_CHANNEL_COUNT; ++channel) { + assert_operation( + &plan, + channel, + RF_OPERATION_VGA, + channel, + 0x5cu); + assert_operation( + &plan, + (uint8_t)(4u + channel), + RF_OPERATION_PHASE, + channel, + phase_command(channel, phases[channel])); + } + assert_operation(&plan, 8u, RF_OPERATION_VGA, 0u, 0x30u); + assert_operation(&plan, 9u, RF_OPERATION_VGA, 2u, 0x00u); + assert_operation(&plan, 10u, RF_OPERATION_VGA, 3u, 0x20u); + + assert(memcmp(plan.resulting_state.phase_states, phases, sizeof(phases)) == 0); + assert(memcmp( + plan.resulting_state.attenuation_db, + attenuations, + sizeof(attenuations)) == 0); +} + +static void test_bulk_combined_all_max_uses_eight_operations(void) +{ + const rf_state_t current = initial_state(); + can_command_t command = command_of(CAN_MESSAGE_SET_COMBINED); + command.bulk_update = true; + const uint8_t phases[] = {1u, 2u, 3u, 4u}; + const uint8_t attenuations[] = {23u, 23u, 23u, 23u}; + memcpy(command.phase_states, phases, sizeof(phases)); + memcpy(command.attenuation_db, attenuations, sizeof(attenuations)); + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 8u); +} + +static void test_individual_phase_plan(void) +{ + const rf_state_t current = { + .phase_states = {1u, 2u, 3u, 4u}, + .attenuation_db = {23u, 8u, 23u, 23u}, + }; + can_command_t command = command_of(CAN_MESSAGE_SET_PHASE); + command.channel = 1u; + command.phase_states[1] = 146u; + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 3u); + assert_operation(&plan, 0u, RF_OPERATION_VGA, 1u, 0x5cu); + assert_operation( + &plan, 1u, RF_OPERATION_PHASE, 1u, phase_command(1u, 146u)); + assert_operation(&plan, 2u, RF_OPERATION_VGA, 1u, 0x20u); + assert(plan.resulting_state.phase_states[1] == 146u); + assert(plan.resulting_state.phase_states[0] == current.phase_states[0]); +} + +static void test_individual_vga_plan(void) +{ + const rf_state_t current = initial_state(); + can_command_t command = command_of(CAN_MESSAGE_SET_VGA); + command.channel = 2u; + command.attenuation_db[2] = 12u; + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 1u); + assert_operation(&plan, 0u, RF_OPERATION_VGA, 2u, 0x30u); + assert(plan.resulting_state.attenuation_db[2] == 12u); + assert(plan.resulting_state.attenuation_db[1] == current.attenuation_db[1]); +} + +static void test_individual_combined_plan(void) +{ + const rf_state_t current = initial_state(); + can_command_t command = command_of(CAN_MESSAGE_SET_COMBINED); + command.channel = 3u; + command.phase_states[3] = 64u; + command.attenuation_db[3] = 8u; + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 3u); + assert_operation(&plan, 0u, RF_OPERATION_VGA, 3u, 0x5cu); + assert_operation( + &plan, 1u, RF_OPERATION_PHASE, 3u, phase_command(3u, 64u)); + assert_operation(&plan, 2u, RF_OPERATION_VGA, 3u, 0x20u); + assert(plan.resulting_state.phase_states[3] == 64u); + assert(plan.resulting_state.attenuation_db[3] == 8u); +} + +static void test_safe_plan_targets_one_channel(void) +{ + const rf_state_t current = { + .phase_states = {100u, 110u, 120u, 130u}, + .attenuation_db = {0u, 1u, 2u, 3u}, + }; + can_command_t command = command_of(CAN_MESSAGE_ENTER_SAFE); + command.channel = 2u; + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 2u); + assert_operation(&plan, 0u, RF_OPERATION_VGA, 2u, 0x5cu); + assert_operation( + &plan, + 1u, + RF_OPERATION_PHASE, + 2u, + phase_command(2u, 0u)); + assert(plan.resulting_state.phase_states[2] == 0u); + assert(plan.resulting_state.attenuation_db[2] == 23u); + assert(plan.resulting_state.phase_states[1] == current.phase_states[1]); +} + +static void test_ping_has_no_hardware_actions(void) +{ + const rf_state_t current = { + .phase_states = {1u, 2u, 3u, 4u}, + .attenuation_db = {3u, 4u, 5u, 6u}, + }; + const can_command_t command = command_of(CAN_MESSAGE_PING); + + rf_plan_t plan = {0}; + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_OK); + assert(plan.operation_count == 0u); + assert(memcmp(&plan.resulting_state, ¤t, sizeof(current)) == 0); +} + +static void test_invalid_arguments_and_channel(void) +{ + const rf_state_t current = initial_state(); + rf_plan_t plan = {0}; + can_command_t command = command_of(CAN_MESSAGE_ENTER_SAFE); + command.channel = RF_CHANNEL_COUNT; + + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_INVALID_PAYLOAD); + assert(rf_plan_command(NULL, ¤t, &plan) + == CAN_COMMAND_RESULT_INVALID_PAYLOAD); + assert(rf_plan_command(&command, NULL, &plan) + == CAN_COMMAND_RESULT_INVALID_PAYLOAD); + assert(rf_plan_command(&command, ¤t, NULL) + == CAN_COMMAND_RESULT_INVALID_PAYLOAD); + + command = command_of(CAN_MESSAGE_STATUS); + assert(rf_plan_command(&command, ¤t, &plan) + == CAN_COMMAND_RESULT_UNSUPPORTED); +} + +int main(void) +{ + test_startup_plan(); + test_bulk_phase_plan(); + test_bulk_phase_plan_attenuates_then_restores(); + test_bulk_vga_plan(); + test_bulk_combined_plan_uses_safe_transition_order(); + test_bulk_combined_all_max_uses_eight_operations(); + test_individual_phase_plan(); + test_individual_vga_plan(); + test_individual_combined_plan(); + test_safe_plan_targets_one_channel(); + test_ping_has_no_hardware_actions(); + test_invalid_arguments_and_channel(); + puts("RF plan tests passed"); + return 0; +} diff --git a/stm32/tools/can_smoke_test.py b/stm32/tools/can_smoke_test.py index 6565985..1361e1c 100755 --- a/stm32/tools/can_smoke_test.py +++ b/stm32/tools/can_smoke_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Send protocol-v1 smoke-test commands over Linux SocketCAN.""" +"""Send BeamControl protocol v2.1 commands over Linux SocketCAN.""" from __future__ import annotations @@ -8,8 +8,9 @@ import struct import sys import time +from collections.abc import Sequence from enum import IntEnum -from typing import NamedTuple, Sequence +from typing import NamedTuple CAN_EFF_FLAG = 0x80000000 CAN_RTR_FLAG = 0x40000000 @@ -21,7 +22,9 @@ CAN_NODE_MIN = 1 CAN_NODE_MAX = 30 CAN_NODE_BROADCAST = 31 -CAN_PROTOCOL_VERSION = 1 +CAN_PROTOCOL_VERSION = (2, 1, 0) +RF_CHANNEL_COUNT = 4 +RF_CHANNEL_MAX = 3 CAN_ID_TYPE_SHIFT = 26 CAN_ID_DESTINATION_SHIFT = 21 @@ -49,6 +52,8 @@ class CommandResult(IntEnum): UNSUPPORTED = 3 HARDWARE = 4 BUSY = 5 + SEQUENCE_REUSE = 6 + BROADCAST_NOT_ALLOWED = 7 class DecodedFrame(NamedTuple): @@ -73,7 +78,7 @@ def make_identifier( sequence: int, ) -> int: if not isinstance(message_type, MessageType): - raise ValueError("message_type must be a MessageType") + raise TypeError("message_type must be a MessageType") if not 0 <= destination <= CAN_NODE_BROADCAST: raise ValueError("destination must be in the range 0..31") if not 0 <= source <= CAN_NODE_BROADCAST: @@ -105,24 +110,39 @@ def command_payload(message_type: MessageType, values: Sequence[int]) -> bytes: values = list(values) if message_type is MessageType.SET_PHASE: - if len(values) != 2 or not 0 <= values[0] <= 255 or not 0 <= values[1] <= 15: - raise ValueError("phase requires STATE 0..255 and ADDRESS 0..15") + valid = (len(values) == 4 and all(0 <= value <= 255 for value in values)) or ( + len(values) == 2 + and 0 <= values[0] <= 255 + and 0 <= values[1] <= RF_CHANNEL_MAX + ) + if not valid: + raise ValueError("phase requires STATE CHANNEL or four states") elif message_type is MessageType.SET_VGA: - if len(values) != 1 or not 0 <= values[0] <= 23: - raise ValueError("vga requires ATTENUATION_DB 0..23") + valid = (len(values) == 4 and all(0 <= value <= 23 for value in values)) or ( + len(values) == 2 + and 0 <= values[0] <= 23 + and 0 <= values[1] <= RF_CHANNEL_MAX + ) + if not valid: + raise ValueError("vga requires ATTENUATION CHANNEL or four attenuations") elif message_type is MessageType.SET_COMBINED: - if ( - len(values) != 3 - or not 0 <= values[0] <= 255 - or not 0 <= values[1] <= 15 - or not 0 <= values[2] <= 23 - ): + valid = ( + len(values) == 8 + and all(0 <= value <= 255 for value in values[:4]) + and all(0 <= value <= 23 for value in values[4:]) + ) or ( + len(values) == 3 + and 0 <= values[0] <= 255 + and 0 <= values[1] <= RF_CHANNEL_MAX + and 0 <= values[2] <= 23 + ) + if not valid: raise ValueError( - "combined requires STATE 0..255, ADDRESS 0..15, and ATTENUATION_DB 0..23" + "combined requires STATE CHANNEL ATTENUATION or four states and four attenuations" ) elif message_type is MessageType.ENTER_SAFE: - if len(values) != 1 or not 0 <= values[0] <= 15: - raise ValueError("safe requires ADDRESS 0..15") + if len(values) != 1 or not 0 <= values[0] <= RF_CHANNEL_MAX: + raise ValueError("safe requires CHANNEL 0..3") elif message_type is MessageType.PING: if values: raise ValueError("ping has no payload") @@ -185,7 +205,8 @@ def receive_matching_response( fields.destination == CAN_NODE_CONTROLLER and fields.source == expected_source and fields.sequence == sequence - and fields.message_type in (MessageType.STATUS, MessageType.ACK, MessageType.ERROR) + and fields.message_type + in (MessageType.STATUS, MessageType.ACK, MessageType.ERROR) ): return fields, frame.data @@ -204,14 +225,16 @@ def print_response(fields: IdentifierFields, data: bytes) -> int: if len(data) != 8: raise ValueError("STATUS response must contain eight bytes") - if data[0] != CAN_PROTOCOL_VERSION: - raise ValueError(f"unsupported status protocol version {data[0]}") + version = tuple(data[:3]) + if version != CAN_PROTOCOL_VERSION: + raise ValueError(f"unsupported status protocol version {version}") print( "STATUS: " - f"node={data[1]} phase_state={data[2]} phase_address={data[3]} " - f"attenuation_db={data[4]} health_flags=0x{data[5]:02x} " - f"rx_dropped={data[6]} invalid_commands={data[7]} " + f"version={data[0]}.{data[1]}.{data[2]} " + f"node={data[3]} health_flags=0x{data[4]:02x} " + f"rx_dropped={data[5]} tx_dropped={data[6]} " + f"invalid_commands={data[7]} " f"sequence={fields.sequence}" ) return 0 @@ -257,24 +280,23 @@ def parser() -> argparse.ArgumentParser: ping = subparsers.add_parser("ping", help="request node status") ping.add_argument("node", type=int) - phase = subparsers.add_parser("phase", help="set one phase-shifter state") + phase = subparsers.add_parser("phase", help="set one or four phase states") phase.add_argument("node", type=int) - phase.add_argument("state", type=int) - phase.add_argument("address", type=int) + phase.add_argument("values", type=int, nargs="+", metavar="VALUE") - vga = subparsers.add_parser("vga", help="set VGA attenuation") + vga = subparsers.add_parser("vga", help="set one or four VGA attenuations") vga.add_argument("node", type=int) - vga.add_argument("attenuation_db", type=int) + vga.add_argument("values", type=int, nargs="+", metavar="VALUE") - combined = subparsers.add_parser("combined", help="safely set phase and attenuation") + combined = subparsers.add_parser("combined", help="set phase and attenuation") combined.add_argument("node", type=int) - combined.add_argument("state", type=int) - combined.add_argument("address", type=int) - combined.add_argument("attenuation_db", type=int) + combined.add_argument("values", type=int, nargs="+", metavar="VALUE") - safe = subparsers.add_parser("safe", help="set maximum attenuation and zero phase") + safe = subparsers.add_parser( + "safe", help="set one channel to maximum attenuation and zero phase" + ) safe.add_argument("node", type=int) - safe.add_argument("address", type=int) + safe.add_argument("channel", type=int) return result @@ -289,17 +311,10 @@ def main(argv: Sequence[str] | None = None) -> int: action_map = { "ping": (MessageType.PING, []), - "phase": (MessageType.SET_PHASE, [getattr(args, "state", 0), getattr(args, "address", 0)]), - "vga": (MessageType.SET_VGA, [getattr(args, "attenuation_db", 0)]), - "combined": ( - MessageType.SET_COMBINED, - [ - getattr(args, "state", 0), - getattr(args, "address", 0), - getattr(args, "attenuation_db", 0), - ], - ), - "safe": (MessageType.ENTER_SAFE, [getattr(args, "address", 0)]), + "phase": (MessageType.SET_PHASE, getattr(args, "values", [])), + "vga": (MessageType.SET_VGA, getattr(args, "values", [])), + "combined": (MessageType.SET_COMBINED, getattr(args, "values", [])), + "safe": (MessageType.ENTER_SAFE, [getattr(args, "channel", 0)]), } message_type, values = action_map[args.action] diff --git a/tools/generate-protocol-vectors.py b/tools/generate-protocol-vectors.py index 4ffaa8f..3e721f1 100644 --- a/tools/generate-protocol-vectors.py +++ b/tools/generate-protocol-vectors.py @@ -1,4 +1,4 @@ -"""Generate protocol/generated/protocol_vectors.h from protocol/v1.1-vectors.toml. +"""Generate protocol/generated/protocol_vectors.h from protocol/v2.1-vectors.toml. Run `--check` in CI to fail if the committed header is stale. """ @@ -13,11 +13,11 @@ import tomllib ROOT = Path(__file__).resolve().parent.parent -TOML = ROOT / "protocol" / "v1.1-vectors.toml" +TOML = ROOT / "protocol" / "v2.1-vectors.toml" OUT = ROOT / "protocol" / "generated" / "protocol_vectors.h" HEADER = """/* - * Generated by tools/generate-protocol-vectors.py from protocol/v1.1-vectors.toml. + * Generated by tools/generate-protocol-vectors.py from protocol/v2.1-vectors.toml. * Do not edit by hand. Run `python3 tools/generate-protocol-vectors.py` to regenerate. */ #ifndef PROTOCOL_VECTORS_H