diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 597c450..c5460e0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,3 +25,15 @@ jobs: shell: bash run: | nix develop -c make bindgen-packets-test + - name: Test Proto Plugin + shell: bash + run: | + nix develop -c make proto-plugin-test + - name: Test Proto C++ Conversion Plugin + shell: bash + run: | + nix develop -c make proto-cpp-plugin-test + - name: Test SSL League Proto Compatibility + shell: bash + run: | + nix develop -c make ssl-proto-test diff --git a/.gitignore b/.gitignore index f63786c..28b3c53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ -# nix aritfacts +# nix artifacts result result/ + +# Python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ diff --git a/Makefile b/Makefile index b2b6cfa..3fec606 100644 --- a/Makefile +++ b/Makefile @@ -20,3 +20,46 @@ bindgen-packets-test: bindgen-packets-clean cd ateam-common-packets/rust-lib && \ cargo test test:: bindgen-packets-test + +proto-plugin-test: + cd ateam-common-packets && \ + python3 -m pytest cmake/tests/test_plugin.py -v +test:: proto-plugin-test + +proto-cpp-plugin-test: + cd ateam-common-packets && \ + python3 -m pytest cmake/tests/test_cpp_plugin.py -v +test:: proto-cpp-plugin-test + +ssl-proto-test: + python3 -m pytest ssl-league-protobufs/tests/test_ssl_protos.py -v +test:: ssl-proto-test + +# Detect Wireshark personal plugins directory (Linux/macOS). +_WS_PLUGIN_DIR ?= $(shell \ + wireshark -G folders 2>/dev/null \ + | awk -F'\t' '$$1 == "Personal Lua Plugins" {print $$2}' \ +) + +.PHONY: install-wireshark-plugin +install-wireshark-plugin: + @if [ -z "$(_WS_PLUGIN_DIR)" ]; then \ + echo "Could not detect Wireshark plugin directory."; \ + echo "Copy wireshark/ateam_radio.lua manually to your personal Lua plugins folder."; \ + echo "(Help → About Wireshark → Folders → Personal Lua Plugins)"; \ + exit 1; \ + fi + mkdir -p "$(_WS_PLUGIN_DIR)" + cp wireshark/ateam_radio.lua "$(_WS_PLUGIN_DIR)/" + @echo "Installed to $(_WS_PLUGIN_DIR)/ateam_radio.lua" + @echo "Reload in Wireshark with Ctrl+Shift+L or restart." + +.PHONY: uninstall-wireshark-plugin +uninstall-wireshark-plugin: + @if [ -z "$(_WS_PLUGIN_DIR)" ]; then \ + echo "Could not detect Wireshark plugin directory."; \ + echo "Remove wireshark/ateam_radio.lua from your personal Lua plugins folder manually."; \ + exit 1; \ + fi + rm -f "$(_WS_PLUGIN_DIR)/ateam_radio.lua" + @echo "Removed $(_WS_PLUGIN_DIR)/ateam_radio.lua" diff --git a/README.md b/README.md index 572fc78..7f615cf 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,36 @@ -# Software Common Respository ![Build Status Badge](https://github.com/SSL-A-Team/common/actions/workflows/CI.yml/badge.svg) +# Software Common Repository ![Build Status Badge](https://github.com/SSL-A-Team/common/actions/workflows/CI.yml/badge.svg) -Contains software artifacts shared across the firmware/software boundary. +Shared artifacts across the firmware/software boundary: packet definitions, proto schemas, and generated bindings. -Sub folders contain relevant sub README files. - - `ateam-common-packets/` - all packet definitions used in Robot\<-\>AI communications. - - `radio-protocol/` - the top level radio communication spec (bot discovery, coms, etc) +## Repository Structure -# Development Setup +``` +ateam-common-packets/ Robot↔AI communication packet definitions (C headers, protos, Rust bindings) +ssl-league-protobufs/ SSL league proto definitions (game controller, vision, simulation) +wireshark/ Wireshark Lua dissector for the radio link +flake.nix Nix dev environment (protoc, Python, arm-none-eabi-gcc for bindgen) +Makefile Top-level build/test targets +``` -These artifacts are generally included in other projects that produce actual -build artifacts. As such, there is no default setup. A nix flake is included -to independently support bindgen if desired. Nix setup is described in -the [firmware repository readme](https://github.com/SSL-A-Team/firmware/blob/main/README.md). +## Development Setup +A Nix flake provides all required tools. See the [firmware repository README](https://github.com/SSL-A-Team/firmware/blob/main/README.md) for Nix setup instructions. + +```sh +nix develop # enter dev shell (protoc, Python 3, arm-none-eabi-gcc, cargo) +make test # run all test suites +make # build Rust bindings +``` + +## Wireshark Dissector + +[`wireshark/`](wireshark/README.md) — Lua dissector for the radio link. Decodes `CRC32 | varint(len) | RadioPacket` frames; delegates field decoding to Wireshark's built-in protobuf dissector using the `.proto` files in this repo. + +```sh +make install-wireshark-plugin # install to Wireshark personal plugins directory +make uninstall-wireshark-plugin # remove it +``` + +## Sub-package READMEs + +- [`ateam-common-packets/README.md`](ateam-common-packets/README.md) — C headers, proto schemas, ROS2 msg generation, Rust bindings diff --git a/ateam-common-packets/README.md b/ateam-common-packets/README.md index 3a63315..6003491 100644 --- a/ateam-common-packets/README.md +++ b/ateam-common-packets/README.md @@ -1,20 +1,121 @@ -# Radio Packets +# ateam-common-packets -This folder includes all common packets sent via radio, which requires a C/Rust interface. +Packet definitions for Robot↔AI radio communication: C headers, Protocol Buffer schemas, ROS2 `.msg` generation, and Rust bindings. -The headers are defined in C and generated for Rust using bind gen. +## Directory Structure -## Including C Code +``` +include/ + common.h Shared primitive types (fixed-width ints, assert_size macro) + robot_metadata.h Robot identity metadata + radio/ Packets exchanged over the radio link + radio.h Top-level packet union (RadioData) + basic_control.h AI→Robot motion command + basic_telemetry.h Robot→AI status packet + extended_telemetry.h Robot→AI full debug telemetry + body_control.h Body controller extended telemetry + discovery.h Hello request/response + error_telemetry.h Robot→AI error report + robot_parameters.h Runtime-tunable parameter read/write + robot_maneuvers/ Per-mode command and telemetry structs + wire/ Packets on the robot-internal SPI/UART buses (not sent over radio) + stspin.h Motor controller velocity command + stspin_current.h Motor controller current/telemetry packets + kicker.h Kicker board command and telemetry + power.h Power board telemetry -You can include the C headers into any C/C++ program as you would normally. +proto/ Protocol Buffer schemas (proto3) + radio.proto Top-level RadioPacket oneof (replaces C CommandCode + RadioData union) + control.proto BasicControl message + telemetry.proto BasicTelemetry and ExtendedTelemetry messages + maneuvers.proto Per-mode command messages and body controller telemetry stubs + body_control.proto BodyControlExtendedTelemetry message + discovery.proto HelloRequest / HelloResponse messages + diagnostics.proto ErrorTelemetry message + robot_parameters.proto ParameterCommand message (runtime tuning) + motor.proto CcmTelemetry messages + power.proto PowerTelemetry messages + kicker.proto KickerTelemetry messages + ateam_options.proto Custom proto options (bitmask annotation) +cmake/ + Ros2MsgGen.cmake CMake function: generate ROS2 .msg files from protos at configure time + Ros2CppConvertGen.cmake CMake function: generate C++ fromProto() headers from protos + protoc_gen_ros2msg.py protoc plugin — .proto → ROS2 .msg + protoc_gen_ros2cpp.py protoc plugin — .proto → C++ fromProto() headers + ateam_proto_shared.py Shared helpers used by both plugins + tests/ pytest suites for both plugins -## Building +rust-lib/ + build.rs Runs bindgen (C→Rust) and micropb-gen (proto→Rust) at build time + src/ + lib.rs Public API + basic control safety checks + radio.rs Rust types mirroring C radio packet structs + translation.rs From<&c::T> for proto::T conversion impls + bindings.rs bindgen-generated C bindings (from include/radio/) + metadata_bindings.rs bindgen-generated bindings (from include/robot_metadata.h) + proto_packets_gen.rs micropb-generated Rust proto types (from proto/) +``` -Enter the development environment shell using the direction in the top level readme. +## Wire Framing -Run `cargo build` to generate the bindings, and verify they compile. +Radio packets use the format: `CRC32 || varint(len) || RadioPacket bytes` -Run `cargo test` to run bindgen packing tests. +The `RadioPacket` proto `oneof` wire tag replaces the legacy C `CommandCode` byte. Field numbers in `radio.proto` intentionally match the old `CC_*` enum values for cross-reference. CRC32 and length are transport-layer concerns external to the proto encoding. +## C Headers +Include the top-level radio header for all radio-facing types: + +```c +#include "ateam-common-packets/include/radio/radio.h" +``` + +Wire-internal headers (motor controller, kicker, power board) are under `include/wire/` and are not needed by AI software. + +## Proto / ROS2 Integration + +Generate ROS2 `.msg` files from the proto schemas using the provided CMake function: + +```cmake +include(Ros2MsgGen) +generate_ros2_msgs( + PROTO_FILES ${PROTO_FILES} + PROTO_PATHS ${PROTO_DIR} + OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/msg +) +rosidl_generate_interfaces(${PROJECT_NAME} ${GENERATED_ROS2_MSGS}) +``` + +Generate C++ `fromProto()` conversion headers: + +```cmake +include(Ros2CppConvertGen) +generate_ros2_cpp_conversions( + PROTO_FILES ${PROTO_FILES} + PROTO_PATHS ${PROTO_DIR} + PROTO_INCLUDE_PREFIX ateam_common_packets + ROS2_PACKAGE ateam_radio_msgs + OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/include/conversions +) +``` + +## Rust Bindings + +```sh +nix develop +cargo build # generates bindings.rs and proto_packets_gen.rs +cargo test # runs packing/size tests +``` + +Requires `arm-none-eabi-gcc` on the path (provided by the Nix shell) for bindgen's cross-compilation target headers. Set `$ARM_NONE_EABI_ROOT` to override the sysroot search. + +## Testing + +```sh +nix develop +make test # all suites +python3 -m pytest cmake/tests/test_plugin.py -v # ROS2 .msg plugin tests +python3 -m pytest cmake/tests/test_cpp_plugin.py -v # C++ conversion plugin tests +cargo test # Rust binding tests +``` diff --git a/ateam-common-packets/cmake/README.md b/ateam-common-packets/cmake/README.md new file mode 100644 index 0000000..fdeef76 --- /dev/null +++ b/ateam-common-packets/cmake/README.md @@ -0,0 +1,448 @@ +# ROS2 .msg Generation from Proto + +`Ros2MsgGen.cmake` wraps `protoc_gen_ros2msg.py`, a protoc plugin that converts +`.proto` definitions into ROS2 `.msg` files at CMake configure time. + +## Prerequisites + +| Tool | Nix attribute | Notes | +|------|--------------|-------| +| `protoc` | `pkgs.protobuf` | 3.x or later | +| Python 3.11+ | — | with `google.protobuf` pip package | +| `ateam_options_pb2.py` | checked in at `cmake/` | see [Custom options](#bitmask-custom-field-option) | + +The nix dev shell (`flake.nix`) provides all of these. + +## Quick start + +```cmake +# In your CMakeLists.txt: +include(path/to/ateam-common-packets/cmake/Ros2MsgGen.cmake) + +generate_ros2_msgs( + PROTO_FILES + ${PROTO_DIR}/motor.proto + ${PROTO_DIR}/telemetry.proto + PROTO_PATHS + ${PROTO_DIR} + OUTPUT_DIR + ${CMAKE_CURRENT_BINARY_DIR}/msg +) + +rosidl_generate_interfaces(${PROJECT_NAME} + ${GENERATED_ROS2_MSGS} +) +``` + +`GENERATED_ROS2_MSGS` is set in the calling scope after `generate_ros2_msgs()` returns. + +## Function reference: `generate_ros2_msgs` + +``` +generate_ros2_msgs( + PROTO_FILES ... # required; absolute paths preferred + PROTO_PATHS ... # --proto_path roots passed to protoc + OUTPUT_DIR # default: ${CMAKE_CURRENT_BINARY_DIR}/ros2_msgs + OPTIONAL_SUBMSG HAS_FIELD|ERROR # default: HAS_FIELD +) +``` + +### `PROTO_FILES` +Absolute paths to the `.proto` files to generate `.msg` files for. Imported +files (e.g. `ateam_options.proto`, `google/protobuf/descriptor.proto`) do not +need to be listed here; include their containing directory in `PROTO_PATHS`. + +### `PROTO_PATHS` +One or more directories passed as `--proto_path` to protoc. At minimum this +must contain the directory holding any files listed in `PROTO_FILES` and any +directories needed to resolve imports. The `ateam-common-packets/proto/` +directory must always be included when using the `[(ateam.bitmask)]` option +(it holds `ateam_options.proto`). + +### `OUTPUT_DIR` +Destination for generated `.msg` files. Defaults to +`${CMAKE_CURRENT_BINARY_DIR}/ros2_msgs`. Created automatically. + +### `OPTIONAL_SUBMSG` + +#### Why this option exists + +Proto3 has no wire concept of "field not present" for message-type fields. A +submessage field is either absent from the wire (equivalent to the default — +an empty message) or present with its encoded fields. At the application level +this means a field like `PowerTelemetry power_status = 3` could be absent +because power data is unavailable, or present but legitimately all-zero — and +those two cases are **indistinguishable** once decoded. + +ROS2 `.msg` structs have no optional mechanism: every field is always present, +zero-initialized. When the plugin flattens a proto message with a submessage +field into a `.msg`, it must decide how to carry proto3's implicit presence +information across to ROS2. `OPTIONAL_SUBMSG` controls that decision. + +| Value | Behaviour | +|-------|-----------| +| `HAS_FIELD` (default) | Emits `bool has_` immediately before each such field | +| `ERROR` | Fails the configure step with an error listing every offending field | + +#### `HAS_FIELD` — permissive, with explicit sentinel + +A `bool has_` is prepended to every non-`oneof` message-type field. +Consumers set or check this bool to signal presence. + +Given the proto field: +```proto +PowerTelemetry power_status = 3; +``` + +Generated `.msg` output: +``` +bool has_power_status +PowerTelemetry power_status +``` + +Use `HAS_FIELD` when: +- Generating from existing protos you do not own or cannot restructure. +- Any submessage field is genuinely optional and you need presence tracking. +- You want the build to succeed and handle presence manually in your ROS2 code. + +#### `ERROR` — strict, forces explicit schema design + +The build fails at CMake configure time with an error for each offending field. +The only way to resolve the error is to either move the field into a `oneof` +or switch back to `HAS_FIELD`. + +Use `ERROR` when: +- Writing new protos where you control the schema and want to enforce that + all optionality is explicit. +- You want the generated `.msg` to have no hidden sentinel bools — every field + has a clear meaning without a companion `has_` flag. + +#### Using `oneof` to satisfy `ERROR` mode + +`oneof` fields always carry a discriminant (`_case`) and work in both +modes. Wrapping an optional submessage in a `oneof` is the idiomatic proto3 +way to express "this field may or may not be present": + +```proto +// Before: bare submessage field — rejected in ERROR mode +PowerTelemetry power_status = 3; + +// After: oneof wrapping — accepted in both modes +oneof power { + PowerTelemetry power_status = 3; +} +``` + +Generated `.msg` output for the `oneof` form: +``` +# oneof power +uint8 ONEOF_POWER_NONE=0 +uint8 ONEOF_POWER_POWER_STATUS=1 +uint8 power_case +PowerTelemetry power_status +``` + +Consumers read `power_case` to check presence (`ONEOF_POWER_NONE` means +absent) before accessing `power_status`. This is more explicit than a loose +`bool has_power_status` and is fully self-describing in the `.msg` file. + +## Type mappings + +### Scalar fields + +| Proto type | ROS2 type | +|-----------|-----------| +| `double` | `float64` | +| `float` | `float32` | +| `int32`, `sint32`, `sfixed32` | `int32` | +| `int64`, `sint64`, `sfixed64` | `int64` | +| `uint32`, `fixed32` | `uint32` | +| `uint64`, `fixed64` | `uint64` | +| `bool` | `bool` | +| `string` | `string` | +| `bytes` | `uint8[]` | + +### Enum fields + +Proto enums map to `int32` fields in the `.msg`. ROS2 has no native enum type. +The enum's constants are available as a separate `.msg` file +(constants-only message) generated alongside the main message. + +### Repeated fields + +`repeated T field` becomes `T[] field` (ROS2 dynamic array). Fixed-size +arrays are not supported; all repeated fields produce dynamic arrays. + +### Message fields (submessages) + +Non-`oneof` message-type fields get a `bool has_` sentinel prepended +(with `HAS_FIELD` mode) to carry proto3's implicit presence information across +to ROS2. The referenced type name is used verbatim — the type must be generated +in the same `rosidl_generate_interfaces()` call. + +### `oneof` fields + +A `oneof foo { A a = 1; B b = 2; }` expands to: + +``` +# oneof foo +uint8 ONEOF_FOO_NONE=0 +uint8 ONEOF_FOO_A=1 +uint8 ONEOF_FOO_B=2 +uint8 foo_case +A a +B b +``` + +`foo_case` carries the discriminant. All arm fields are always present in the +`.msg` struct regardless of which arm is set — ROS2 has no union type. +Consumers must check `foo_case` before using an arm. + +## `[(ateam.bitmask)]` custom field option + +`uint32` fields annotated with `[(ateam.bitmask).flags_enum = "EnumName"]` +receive special treatment: + +1. The referenced enum's maximum value is inspected to determine the smallest + unsigned type that fits: `uint8` (max < 256), `uint16` (max < 65536), + or `uint32` otherwise. +2. All enum constants are emitted inline above the field as typed constants. +3. The field itself is emitted with the inferred type instead of `uint32`. + +Example output for `CcmTelemetry.error_flags` (16 flag bits, max = 32768): + +``` +# bitmask: CcmErrorFlag +uint16 CCM_ERR_NONE=0 +uint16 CCM_ERR_MASTER_ERROR=1 +... +uint16 CCM_ERR_RESET_PIN=32768 +uint16 error_flags +``` + +**Constraints:** +- The annotated field must be `uint32` in the proto source. Any other type + causes a build error. +- The enum name in `flags_enum` must resolve to an enum visible to protoc in + the current set of compiled files. An unknown name causes a build error. +- Import `ateam_options.proto` in any `.proto` file that uses this annotation. + +The annotation has **no wire overhead** — it is compile-time metadata only and +does not alter the proto encoding. + +## Re-generation and incremental builds + +Generation runs at CMake configure time. CMake automatically re-configures +when any file listed in `PROTO_FILES` changes (`CMAKE_CONFIGURE_DEPENDS`). +Changes to **imported** proto files (files in `PROTO_PATHS` but not in +`PROTO_FILES`) do not trigger re-configuration. If you modify an imported +file, run `cmake .` manually or touch one of the listed `PROTO_FILES`. + +## Limitations + +- **No nested messages or enums.** Proto allows defining a message or enum + inside another message (`message Outer { message Inner { ... } }`). The + plugin only iterates `fd.message_type` and `fd.enum_type`, which are + file-scope (top-level) definitions only. This causes three distinct failures: + + 1. **Nested message definition:** no `.msg` is generated for the inner type. + `rosidl_generate_interfaces` then fails at ROS2 build time because the + field that references it names a `.msg` that does not exist. + 2. **Field referencing a nested type:** `strip_package` takes the last + `.`-delimited component of the fully-qualified type name + (`.ateam.Outer.Inner` → `Inner`). The type name in the `.msg` is + correct, but because no `Inner.msg` was generated (see above), the ROS2 + build still fails. + 3. **Nested enum in a `[(ateam.bitmask)]` annotation:** the `all_enums` + lookup only contains file-scope enums. A bitmask referencing a nested + enum (`Outer.InnerFlag`) will not be found and the plugin emits a build + error: `unknown enum 'InnerFlag'`. + + **Workaround:** define all messages and enums at file scope. There is no + semantic difference in proto3 — nesting is purely a namespace convention, + and the `package` declaration already controls the proto namespace. +- **No `map` fields.** Proto map fields are not handled and will not + appear in the output. +- **No proto3 `optional` scalar fields.** The `optional` keyword on scalar + fields is ignored; presence is not tracked for scalars. +- **`oneof` arms are always emitted.** Because ROS2 has no union type, all + arms coexist in the struct. Memory layout is not compact. +- **Enum type in `.msg` is `int32`.** ROS2 constants are used for named + access but the field itself is untyped from ROS2's perspective. +- **Single package only.** The plugin strips the package prefix from type + names (`strip_package`). If you import messages from multiple proto packages + with colliding short names, the generated `.msg` will have name conflicts. +- **Protoc version.** Tested with protobuf 3.x/4.x. The plugin uses the + binary `CodeGeneratorRequest`/`CodeGeneratorResponse` protocol; it is + insensitive to the protobuf Python library version as long as + `google.protobuf.compiler.plugin_pb2` is available. + +--- + +# C++ fromProto() Conversion Header Generation + +`Ros2CppConvertGen.cmake` wraps `protoc_gen_ros2cpp.py`, a protoc plugin that +generates C++ `fromProto()` conversion functions from `.proto` definitions. + +For each `.proto` file, it produces a `_conversions.hpp` header with +`inline` free functions that convert proto C++ types to ROS2 message types: + +```cpp +ros2_package::msg::MsgType fromProto(const ateam::MsgType& p); +``` + +## Quick start + +```cmake +include(path/to/ateam-common-packets/cmake/Ros2CppConvertGen.cmake) + +generate_ros2_cpp_conversions( + PROTO_FILES + ${PROTO_DIR}/motor.proto + ${PROTO_DIR}/telemetry.proto + PROTO_PATHS + ${PROTO_DIR} + PROTO_INCLUDE_PREFIX ateam_common_packets + ROS2_PACKAGE ateam_radio_msgs + OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/include/conversions + NAMESPACE ateam_conversions +) + +target_include_directories(my_target PUBLIC + ${CMAKE_CURRENT_BINARY_DIR}/include/conversions +) +``` + +Then include and call: + +```cpp +#include "motor_conversions.hpp" + +ateam::CcmTelemetry proto_msg = ...; // decoded from wire +auto ros_msg = ateam_conversions::fromProto(proto_msg); +``` + +`GENERATED_ROS2_CPP_HEADERS` is set in the calling scope after the call returns. + +## Function reference: `generate_ros2_cpp_conversions` + +``` +generate_ros2_cpp_conversions( + PROTO_FILES ... # required; absolute paths preferred + PROTO_PATHS ... # --proto_path roots passed to protoc + PROTO_INCLUDE_PREFIX # required; prefix in #include + ROS2_PACKAGE # required; ROS2 package name + OUTPUT_DIR # default: ${CMAKE_CURRENT_BINARY_DIR}/ros2_cpp_conversions + NAMESPACE # default: ateam_conversions + OPTIONAL_SUBMSG HAS_FIELD|ERROR # default: HAS_FIELD +) +``` + +### `PROTO_INCLUDE_PREFIX` + +The directory component in the proto-generated C++ include path: + +```cpp +#include +// ^^^^^^^^^^^^^^^^^^^^^ PROTO_INCLUDE_PREFIX +``` + +This must match however `protoc --cpp_out` places its generated `.pb.h` files +in your build system's include tree. + +### `ROS2_PACKAGE` + +The ROS2 package that provides the generated `.msg` C++ headers. Used for +include paths (``) and type names +(`ros2_package::msg::MsgName`). + +### `NAMESPACE` + +C++ namespace wrapping all generated `fromProto()` functions. Defaults to +`ateam_conversions`. All functions across all generated headers share the same +namespace, so proto imports between files resolve without qualification. + +### `OPTIONAL_SUBMSG` + +Same semantics as in `generate_ros2_msgs()` — see [OPTIONAL_SUBMSG](#optional_submsg) +above. Controls handling of non-`oneof` message-type fields: + +| Value | Behaviour | +|-------|-----------| +| `HAS_FIELD` (default) | Emits `msg.has_ = p.has_()` and a guarded `fromProto` call | +| `ERROR` | Fails configure if any such field is present | + +## Type mapping (C++) + +| Proto type | C++ conversion | +|-----------|----------------| +| Scalar (int32, float, bool, …) | `msg.f = p.f();` | +| `enum` | `msg.f = static_cast(p.f());` | +| `bytes` | `msg.f = std::vector(p.f().begin(), p.f().end());` | +| `repeated T` (scalar) | `for (v : p.f()) msg.f.push_back(v);` | +| `repeated T` (message) | `for (v : p.f()) msg.f.push_back(fromProto(v));` | +| `message T` (non-oneof) | `msg.has_f = p.has_f(); if (p.has_f()) msg.f = fromProto(p.f());` | +| `oneof` discriminant | `msg.name_case = static_cast(p.name_case());` | +| `oneof` message arm | `if (p.has_arm()) msg.arm = fromProto(p.arm());` | +| `oneof` scalar arm | `msg.arm = p.arm();` | +| `[(ateam.bitmask)]` | `msg.f = static_cast(p.f());` (narrowed) | + +## Generated header structure + +For `motor.proto` with `PROTO_INCLUDE_PREFIX=ateam_common_packets`, +`ROS2_PACKAGE=ateam_radio_msgs`, `NAMESPACE=ateam_conversions`: + +```cpp +#pragma once + +#include +#include +// ... other message includes + +#include +#include + +namespace ateam_conversions { + +inline ateam_radio_msgs::msg::CcmTelemetry fromProto(const ateam::CcmTelemetry& p) { + ateam_radio_msgs::msg::CcmTelemetry msg; + msg.error_flags = static_cast(p.error_flags()); // bitmask narrowing + msg.motion_control_type = static_cast(p.motion_control_type()); + msg.gain_stage_index = p.gain_stage_index(); + msg.has_current_telem = p.has_current_telem(); + if (p.has_current_telem()) msg.current_telem = fromProto(p.current_telem()); + // ... + return msg; +} + +} // namespace ateam_conversions +``` + +## Import dependencies + +If `control.proto` imports `maneuvers.proto`, `control_conversions.hpp` will +automatically include `maneuvers_conversions.hpp` using a relative include. +Generate all conversion headers for a proto graph into the same `OUTPUT_DIR` +so relative includes resolve. + +--- + +## Running tests + +From the `ateam-common-packets/` directory: + +```sh +python3 -m pytest cmake/tests/test_plugin.py -v # .msg plugin +python3 -m pytest cmake/tests/test_cpp_plugin.py -v # C++ plugin +``` + +Or via the repo Makefile: + +```sh +make proto-plugin-test +make proto-cpp-plugin-test +``` + +Tests cover: scalar/bitmask type inference, `oneof` expansion, error mode +rejection, `optional_submsg` option parsing, and per-proto output correctness +including `diagnostics.proto` and all bitmask-annotated fields. diff --git a/ateam-common-packets/cmake/Ros2CppConvertGen.cmake b/ateam-common-packets/cmake/Ros2CppConvertGen.cmake new file mode 100644 index 0000000..4bbfbdc --- /dev/null +++ b/ateam-common-packets/cmake/Ros2CppConvertGen.cmake @@ -0,0 +1,156 @@ +# Ros2CppConvertGen.cmake +# +# Provides generate_ros2_cpp_conversions() — runs the protoc ros2cpp plugin at +# CMake configure time and returns the list of generated .hpp headers. +# +# Usage: +# generate_ros2_cpp_conversions( +# PROTO_FILES path/to/a.proto path/to/b.proto ... +# PROTO_PATHS path/to/proto/include/dir ... +# PROTO_INCLUDE_PREFIX ateam_common_packets # prefix for #include +# ROS2_PACKAGE ateam_radio_msgs # ROS2 package name +# OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/include/conversions # optional +# NAMESPACE ateam_conversions # optional, default: ateam_conversions +# OPTIONAL_SUBMSG HAS_FIELD # or ERROR; default HAS_FIELD +# ) +# # After the call, ${GENERATED_ROS2_CPP_HEADERS} contains the .hpp file list. +# target_include_directories(my_target PUBLIC ${OUTPUT_DIR}) +# +# Each output file is named _conversions.hpp and contains inline +# fromProto(const pkg::MsgType&) → ros2pkg::msg::MsgType functions. +# +# If a proto file imports another proto file, the generated header will include +# the corresponding _conversions.hpp; all conversion headers should be +# generated into the same OUTPUT_DIR. +# +# Generation runs at configure time. CMake re-runs automatically when any +# PROTO_FILES changes (CMAKE_CONFIGURE_DEPENDS). + +cmake_minimum_required(VERSION 3.16) + +function(generate_ros2_cpp_conversions) + cmake_parse_arguments( + _ARG + "" + "OUTPUT_DIR;PROTO_INCLUDE_PREFIX;ROS2_PACKAGE;NAMESPACE;OPTIONAL_SUBMSG" + "PROTO_FILES;PROTO_PATHS" + ${ARGN} + ) + + # --- Validate arguments --- + if(NOT _ARG_PROTO_FILES) + message(FATAL_ERROR "generate_ros2_cpp_conversions: PROTO_FILES is required") + endif() + + if(NOT _ARG_PROTO_INCLUDE_PREFIX) + message(FATAL_ERROR "generate_ros2_cpp_conversions: PROTO_INCLUDE_PREFIX is required") + endif() + + if(NOT _ARG_ROS2_PACKAGE) + message(FATAL_ERROR "generate_ros2_cpp_conversions: ROS2_PACKAGE is required") + endif() + + # --- Defaults --- + if(NOT _ARG_OUTPUT_DIR) + set(_ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/ros2_cpp_conversions") + endif() + + if(NOT _ARG_NAMESPACE) + set(_ARG_NAMESPACE "ateam_conversions") + endif() + + if(NOT _ARG_OPTIONAL_SUBMSG) + set(_ARG_OPTIONAL_SUBMSG "HAS_FIELD") + endif() + string(TOLOWER "${_ARG_OPTIONAL_SUBMSG}" _opt_submsg) + + if(NOT _opt_submsg STREQUAL "has_field" AND NOT _opt_submsg STREQUAL "error") + message(FATAL_ERROR + "generate_ros2_cpp_conversions: OPTIONAL_SUBMSG must be HAS_FIELD or ERROR, " + "got '${_ARG_OPTIONAL_SUBMSG}'" + ) + endif() + + # --- Find tools --- + find_program(_PROTOC protoc REQUIRED + DOC "protoc compiler (install via nix: protobuf)" + ) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + + # --- Plugin path --- + get_filename_component(_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + set(_PLUGIN_SRC "${_CMAKE_DIR}/protoc_gen_ros2cpp.py") + + if(NOT EXISTS "${_PLUGIN_SRC}") + message(FATAL_ERROR + "generate_ros2_cpp_conversions: plugin not found at ${_PLUGIN_SRC}" + ) + endif() + + # Generate an executable wrapper in the build tree so we can pass an + # explicit Python interpreter without relying on the script's shebang. + set(_PLUGIN_WRAPPER "${CMAKE_BINARY_DIR}/protoc_gen_ros2cpp") + file(WRITE "${_PLUGIN_WRAPPER}" + "#!/bin/sh\nset -e\nexec \"${Python3_EXECUTABLE}\" \"${_PLUGIN_SRC}\" \"$@\"\n" + ) + file(CHMOD "${_PLUGIN_WRAPPER}" + PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE + ) + + # --- Output directory --- + file(MAKE_DIRECTORY "${_ARG_OUTPUT_DIR}") + + # --- Build --proto_path arguments --- + set(_proto_path_args) + foreach(_path ${_ARG_PROTO_PATHS}) + list(APPEND _proto_path_args "--proto_path=${_path}") + endforeach() + + # Build plugin option string + set(_plugin_opts + "proto_include_prefix=${_ARG_PROTO_INCLUDE_PREFIX}" + "ros2_package=${_ARG_ROS2_PACKAGE}" + "namespace=${_ARG_NAMESPACE}" + "optional_submsg=${_opt_submsg}" + ) + list(JOIN _plugin_opts "," _plugin_opts_str) + + # --- Run protoc at configure time --- + execute_process( + COMMAND + "${_PROTOC}" + "--plugin=protoc-gen-ros2cpp=${_PLUGIN_WRAPPER}" + "--ros2cpp_opt=${_plugin_opts_str}" + "--ros2cpp_out=${_ARG_OUTPUT_DIR}" + ${_proto_path_args} + ${_ARG_PROTO_FILES} + RESULT_VARIABLE _result + ERROR_VARIABLE _stderr + OUTPUT_QUIET + ) + + if(NOT _result EQUAL 0) + message(FATAL_ERROR + "generate_ros2_cpp_conversions: protoc failed (exit ${_result}):\n${_stderr}" + ) + endif() + + # Re-run CMake configure when any proto file changes. + set_property( + DIRECTORY APPEND PROPERTY + CMAKE_CONFIGURE_DEPENDS ${_ARG_PROTO_FILES} + ) + + # Collect results and expose to caller. + file(GLOB _generated "${_ARG_OUTPUT_DIR}/*.hpp") + if(NOT _generated) + message(FATAL_ERROR + "generate_ros2_cpp_conversions: no .hpp files found in ${_ARG_OUTPUT_DIR} after generation" + ) + endif() + + set(GENERATED_ROS2_CPP_HEADERS "${_generated}" PARENT_SCOPE) +endfunction() diff --git a/ateam-common-packets/cmake/Ros2MsgGen.cmake b/ateam-common-packets/cmake/Ros2MsgGen.cmake new file mode 100644 index 0000000..44e4c5e --- /dev/null +++ b/ateam-common-packets/cmake/Ros2MsgGen.cmake @@ -0,0 +1,131 @@ +# Ros2MsgGen.cmake +# +# Provides generate_ros2_msgs() — runs the protoc ros2msg plugin at CMake +# configure time and returns the list of generated .msg files. +# +# Usage: +# generate_ros2_msgs( +# PROTO_FILES path/to/a.proto path/to/b.proto ... +# PROTO_PATHS path/to/proto/include/dir ... # --proto_path roots +# OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/msg # default if omitted +# OPTIONAL_SUBMSG HAS_FIELD # or ERROR; default HAS_FIELD +# ) +# # After the call, ${GENERATED_ROS2_MSGS} contains the .msg file list. +# rosidl_generate_interfaces(${PROJECT_NAME} ${GENERATED_ROS2_MSGS}) +# +# OPTIONAL_SUBMSG controls handling of non-oneof message-type fields: +# HAS_FIELD (default) Emit a bool has_ presence sentinel. +# ERROR Fail the build if any such field exists, forcing the schema +# author to either move it into a oneof or switch to HAS_FIELD. +# +# Generation runs at configure time so .msg files exist when +# rosidl_generate_interfaces() is called. CMake re-runs automatically when +# any PROTO_FILES changes (CMAKE_CONFIGURE_DEPENDS). + +cmake_minimum_required(VERSION 3.16) + +function(generate_ros2_msgs) + cmake_parse_arguments( + _ARG + "" + "OUTPUT_DIR;OPTIONAL_SUBMSG" + "PROTO_FILES;PROTO_PATHS" + ${ARGN} + ) + + # --- Validate arguments --- + if(NOT _ARG_PROTO_FILES) + message(FATAL_ERROR "generate_ros2_msgs: PROTO_FILES is required") + endif() + + # --- Defaults --- + if(NOT _ARG_OUTPUT_DIR) + set(_ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/ros2_msgs") + endif() + + if(NOT _ARG_OPTIONAL_SUBMSG) + set(_ARG_OPTIONAL_SUBMSG "HAS_FIELD") + endif() + string(TOLOWER "${_ARG_OPTIONAL_SUBMSG}" _opt_submsg) + + if(NOT _opt_submsg STREQUAL "has_field" AND NOT _opt_submsg STREQUAL "error") + message(FATAL_ERROR + "generate_ros2_msgs: OPTIONAL_SUBMSG must be HAS_FIELD or ERROR, " + "got '${_ARG_OPTIONAL_SUBMSG}'" + ) + endif() + + # --- Find tools --- + find_program(_PROTOC protoc REQUIRED + DOC "protoc compiler (install via nix: protobuf)" + ) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + + # --- Plugin path (sibling of this .cmake file) --- + get_filename_component(_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + set(_PLUGIN_SRC "${_CMAKE_DIR}/protoc_gen_ros2msg.py") + + if(NOT EXISTS "${_PLUGIN_SRC}") + message(FATAL_ERROR + "generate_ros2_msgs: plugin not found at ${_PLUGIN_SRC}" + ) + endif() + + # Generate an executable wrapper in the build tree so we can pass an + # explicit Python interpreter without relying on the script's shebang. + set(_PLUGIN_WRAPPER "${CMAKE_BINARY_DIR}/protoc_gen_ros2msg") + file(WRITE "${_PLUGIN_WRAPPER}" + "#!/bin/sh\nset -e\nexec \"${Python3_EXECUTABLE}\" \"${_PLUGIN_SRC}\" \"$@\"\n" + ) + file(CHMOD "${_PLUGIN_WRAPPER}" + PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE + ) + + # --- Output directory --- + file(MAKE_DIRECTORY "${_ARG_OUTPUT_DIR}") + + # --- Build --proto_path arguments --- + set(_proto_path_args) + foreach(_path ${_ARG_PROTO_PATHS}) + list(APPEND _proto_path_args "--proto_path=${_path}") + endforeach() + + # --- Run protoc at configure time --- + execute_process( + COMMAND + "${_PROTOC}" + "--plugin=protoc-gen-ros2msg=${_PLUGIN_WRAPPER}" + "--ros2msg_opt=optional_submsg=${_opt_submsg}" + "--ros2msg_out=${_ARG_OUTPUT_DIR}" + ${_proto_path_args} + ${_ARG_PROTO_FILES} + RESULT_VARIABLE _result + ERROR_VARIABLE _stderr + OUTPUT_QUIET + ) + + if(NOT _result EQUAL 0) + message(FATAL_ERROR + "generate_ros2_msgs: protoc failed (exit ${_result}):\n${_stderr}" + ) + endif() + + # Re-run CMake configure when any proto file changes. + set_property( + DIRECTORY APPEND PROPERTY + CMAKE_CONFIGURE_DEPENDS ${_ARG_PROTO_FILES} + ) + + # Collect results and expose to caller. + file(GLOB _generated "${_ARG_OUTPUT_DIR}/*.msg") + if(NOT _generated) + message(FATAL_ERROR + "generate_ros2_msgs: no .msg files found in ${_ARG_OUTPUT_DIR} after generation" + ) + endif() + + set(GENERATED_ROS2_MSGS "${_generated}" PARENT_SCOPE) +endfunction() diff --git a/ateam-common-packets/cmake/ateam_options_pb2.py b/ateam-common-packets/cmake/ateam_options_pb2.py new file mode 100644 index 0000000..ac17082 --- /dev/null +++ b/ateam-common-packets/cmake/ateam_options_pb2.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: ateam_options.proto +# Protobuf Python Version: 7.34.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 34, + 0, + '', + 'ateam_options.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13\x61team_options.proto\x12\x05\x61team\x1a google/protobuf/descriptor.proto\"%\n\x0f\x42itfieldOptions\x12\x12\n\nflags_enum\x18\x01 \x01(\t:H\n\x07\x62itmask\x12\x1d.google.protobuf.FieldOptions\x18\xd0\x86\x03 \x01(\x0b\x32\x16.ateam.BitfieldOptionsb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ateam_options_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_BITFIELDOPTIONS']._serialized_start=64 + _globals['_BITFIELDOPTIONS']._serialized_end=101 +# @@protoc_insertion_point(module_scope) diff --git a/ateam-common-packets/cmake/ateam_proto_shared.py b/ateam-common-packets/cmake/ateam_proto_shared.py new file mode 100644 index 0000000..9d72dca --- /dev/null +++ b/ateam-common-packets/cmake/ateam_proto_shared.py @@ -0,0 +1,69 @@ +""" +Shared helpers used by both protoc_gen_ros2msg.py and protoc_gen_ros2cpp.py. + +Factored out to avoid duplication; import with: + from ateam_proto_shared import ( + parse_options, flatten_type_name, strip_package, + build_map_entry_type_names, iter_messages, + ) +""" + +from google.protobuf import descriptor_pb2 + + +def parse_options(parameter: str) -> dict: + if not parameter: + return {} + return dict(kv.split("=", 1) for kv in parameter.split(",") if "=" in kv) + + +def flatten_type_name(type_name: str) -> str: + """Convert a fully-qualified proto type name to a flat ROS2/C++-compatible name. + + Package components (conventionally all-lowercase) are stripped; nested type + components (CamelCase, start with uppercase) are joined with '_'. + + Examples: + .ateam.BasicControl → BasicControl + .GameEvent.BallLeftField → GameEvent_BallLeftField + .ateam_test.OuterMessage.Inner → OuterMessage_Inner + """ + parts = type_name.lstrip(".").split(".") + type_parts = [p for p in parts if p and p[0].isupper()] + return "_".join(type_parts) if type_parts else parts[-1] + + +# Alias preserved for callers that import the name directly. +strip_package = flatten_type_name + + +def build_map_entry_type_names(request) -> frozenset: + """Return fully-qualified field.type_name values that are synthetic map-entry types.""" + result: set = set() + + def _walk(msg, parent_fqn: str) -> None: + fqn = f"{parent_fqn}.{msg.name}" + if msg.options.map_entry: + result.add(fqn) + for nested in msg.nested_type: + _walk(nested, fqn) + + for fd in request.proto_file: + pkg_prefix = f".{fd.package}" if fd.package else "" + for msg in fd.message_type: + _walk(msg, pkg_prefix) + + return frozenset(result) + + +def iter_messages(fd): + """Yield (flat_name, msg) for all non-map-entry messages in fd, including nested.""" + def _walk(msg, parent_flat: str): + flat = f"{parent_flat}_{msg.name}" if parent_flat else msg.name + if not msg.options.map_entry: + yield flat, msg + for nested in msg.nested_type: + yield from _walk(nested, flat) + + for msg in fd.message_type: + yield from _walk(msg, "") diff --git a/ateam-common-packets/cmake/protoc_gen_ros2cpp.py b/ateam-common-packets/cmake/protoc_gen_ros2cpp.py new file mode 100755 index 0000000..1e8fd05 --- /dev/null +++ b/ateam-common-packets/cmake/protoc_gen_ros2cpp.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +protoc plugin: generates C++ fromProto() conversion headers from .proto definitions. + +For each .proto file in the request, generates _conversions.hpp containing +inline fromProto(const pkg::MsgType&) → ros2_pkg::msg::MsgType functions. + +Invoked by protoc as a subprocess; reads CodeGeneratorRequest from stdin, +writes CodeGeneratorResponse to stdout (standard protoc plugin protocol). + +Nested message types are flattened: a message Bar nested inside Foo generates +FooBar_conversions.hpp entries and is referenced as Foo_Bar in the C++ output, +matching the proto C++ generated naming convention (Foo_Bar is the actual class). + +map fields are silently skipped (no ROS2 equivalent). + +Options (via --ros2cpp_opt=key=value,...): + proto_include_prefix= required; directory in #include + ros2_package= required; ROS2 package, e.g. 'ateam_radio_msgs' + namespace= C++ namespace for generated functions (default: ateam_conversions) + optional_submsg=has_field (default) emit presence check for non-oneof message fields + optional_submsg=error fail if any non-oneof message-type field is present +""" + +import re +import sys +from pathlib import Path +from google.protobuf.compiler import plugin_pb2 +from google.protobuf import descriptor_pb2 + +sys.path.insert(0, str(Path(__file__).parent)) +import ateam_options_pb2 # noqa: E402 +from ateam_proto_shared import ( # noqa: E402 + parse_options, + flatten_type_name, + build_map_entry_type_names, + iter_messages, +) + +# Public alias: tests import cpp_plugin.strip_package; keep it in this module's namespace. +strip_package = flatten_type_name + +FD = descriptor_pb2.FieldDescriptorProto + +_SCALAR_TYPE_MAP = { + FD.TYPE_DOUBLE: "double", + FD.TYPE_FLOAT: "float", + FD.TYPE_INT64: "int64_t", + FD.TYPE_UINT64: "uint64_t", + FD.TYPE_INT32: "int32_t", + FD.TYPE_FIXED64: "uint64_t", + FD.TYPE_FIXED32: "uint32_t", + FD.TYPE_BOOL: "bool", + FD.TYPE_STRING: "std::string", + FD.TYPE_UINT32: "uint32_t", + FD.TYPE_SINT32: "int32_t", + FD.TYPE_SINT64: "int64_t", + FD.TYPE_SFIXED32: "int32_t", + FD.TYPE_SFIXED64: "int64_t", +} + +_SKIP_DEPS = frozenset({"ateam_options", "descriptor"}) + + +def to_snake_case(name: str) -> str: + """CamelCase or Flat_Name → snake_case (e.g. CcmTelemetry → ccm_telemetry)""" + return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name).lower() + + +def minimal_uint_ctype(max_value: int) -> str: + if max_value < 256: + return "uint8_t" + if max_value < 65536: + return "uint16_t" + return "uint32_t" + + +# --------------------------------------------------------------------------- # +# C++ generation helpers +# --------------------------------------------------------------------------- # + +def _generate_scalar_assign(fname: str, field_type: int) -> str: + if field_type == FD.TYPE_BYTES: + return ( + f" msg.{fname} = " + f"std::vector(p.{fname}().begin(), p.{fname}().end());" + ) + if field_type == FD.TYPE_ENUM: + return f" msg.{fname} = static_cast(p.{fname}());" + return f" msg.{fname} = p.{fname}();" + + +def _generate_repeated(field) -> str: + fname = field.name + if field.type == FD.TYPE_MESSAGE: + return f" for (const auto& v : p.{fname}()) msg.{fname}.push_back(fromProto(v));" + if field.type == FD.TYPE_ENUM: + return f" for (const auto& v : p.{fname}()) msg.{fname}.push_back(static_cast(v));" + if field.type == FD.TYPE_BYTES: + return ( + f" for (const auto& v : p.{fname}())" + f" msg.{fname}.push_back(std::vector(v.begin(), v.end()));" + ) + return f" for (const auto& v : p.{fname}()) msg.{fname}.push_back(v);" + + +def generate_field_lines( + field, + flat_name: str, + optional_submsg: str, + all_enums: dict, + errors: list, + map_entry_type_names: frozenset, +) -> list[str]: + fname = field.name + is_repeated = field.label == FD.LABEL_REPEATED + + # Skip map fields + if field.type == FD.TYPE_MESSAGE and field.type_name in map_entry_type_names: + return [] + + # [(ateam.bitmask)] annotation + if field.options.HasExtension(ateam_options_pb2.bitmask): + bitmask_opts = field.options.Extensions[ateam_options_pb2.bitmask] + enum_name = bitmask_opts.flags_enum + if field.type != FD.TYPE_UINT32: + errors.append( + f"{flat_name}.{fname}: [(ateam.bitmask)] requires uint32, got type {field.type}" + ) + return [] + if enum_name not in all_enums: + errors.append( + f"{flat_name}.{fname}: [(ateam.bitmask)] references unknown enum '{enum_name}'" + ) + return [] + max_val = max(v.number for v in all_enums[enum_name].value) + ctype = minimal_uint_ctype(max_val) + return [f" msg.{fname} = static_cast<{ctype}>(p.{fname}());"] + + if is_repeated: + return [_generate_repeated(field)] + + if field.type == FD.TYPE_MESSAGE: + if optional_submsg == "error": + errors.append( + f"{flat_name}.{fname}: non-oneof message-type field has implicit proto3 " + f"presence — set optional_submsg=has_field or move into a oneof." + ) + return [] + return [ + f" msg.has_{fname} = p.has_{fname}();", + f" if (p.has_{fname}()) msg.{fname} = fromProto(p.{fname}());", + ] + + return [_generate_scalar_assign(fname, field.type)] + + +def generate_oneof_lines(msg, oneof_index: int) -> list[str]: + oneof_name = msg.oneof_decl[oneof_index].name + arms = [f for f in msg.field if f.HasField("oneof_index") and f.oneof_index == oneof_index] + + lines = [f" msg.{oneof_name}_case = static_cast(p.{oneof_name}_case());"] + for arm in arms: + if arm.type == FD.TYPE_MESSAGE: + lines.append(f" if (p.has_{arm.name}()) msg.{arm.name} = fromProto(p.{arm.name}());") + elif arm.type == FD.TYPE_ENUM: + lines.append(f" msg.{arm.name} = static_cast(p.{arm.name}());") + else: + lines.append(f" msg.{arm.name} = p.{arm.name}();") + return lines + + +def generate_message_function( + msg, + flat_name: str, + proto_package: str, + ros2_package: str, + optional_submsg: str, + all_enums: dict, + errors: list, + map_entry_type_names: frozenset, +) -> list[str]: + # Proto C++ names nested types with underscores: Foo::Bar → Foo_Bar + cpp_proto = f"{proto_package}::{flat_name}" if proto_package else flat_name + cpp_ros2 = f"{ros2_package}::msg::{flat_name}" + + body: list[str] = [] + emitted_oneofs: set = set() + for field in msg.field: + # Skip map fields + if field.type == FD.TYPE_MESSAGE and field.type_name in map_entry_type_names: + continue + if field.HasField("oneof_index"): + oi = field.oneof_index + if oi in emitted_oneofs: + continue + emitted_oneofs.add(oi) + body.extend(generate_oneof_lines(msg, oi)) + else: + body.extend(generate_field_lines( + field, flat_name, optional_submsg, all_enums, errors, map_entry_type_names + )) + + return [ + f"inline {cpp_ros2} fromProto(const {cpp_proto}& p) {{", + f" {cpp_ros2} msg;", + *body, + " return msg;", + "}", + ] + + +def generate_header( + fd, + proto_include_prefix: str, + ros2_package: str, + namespace: str, + optional_submsg: str, + all_enums: dict, + errors: list, + map_entry_type_names: frozenset, +) -> str: + stem = Path(fd.name).stem + lines: list[str] = ["#pragma once", ""] + + # Proto generated header + lines.append(f"#include <{proto_include_prefix}/{stem}.pb.h>") + + # ROS2 message headers (one per non-map-entry message in this file) + for flat_name, _ in iter_messages(fd): + ros2_inc = to_snake_case(flat_name) + lines.append(f"#include <{ros2_package}/msg/{ros2_inc}.hpp>") + + # Conversion headers for imported proto files (skip well-known / options) + dep_includes = [ + f'#include "{Path(dep).stem}_conversions.hpp"' + for dep in fd.dependency + if Path(dep).stem not in _SKIP_DEPS and not dep.startswith("google/") + ] + if dep_includes: + lines.append("") + lines.extend(dep_includes) + + # Only emit namespace block if the file has any (non-map-entry) messages + all_msgs = list(iter_messages(fd)) + if all_msgs: + lines += ["", "#include ", "#include "] + lines += ["", f"namespace {namespace} {{", ""] + + for flat_name, msg in all_msgs: + lines.extend( + generate_message_function( + msg, flat_name, fd.package, ros2_package, + optional_submsg, all_enums, errors, map_entry_type_names, + ) + ) + lines.append("") + + lines += [f"}} // namespace {namespace}", ""] + + return "\n".join(lines) + + +def main() -> None: + data = sys.stdin.buffer.read() + request = plugin_pb2.CodeGeneratorRequest() + request.ParseFromString(data) + + response = plugin_pb2.CodeGeneratorResponse() + response.supported_features = plugin_pb2.CodeGeneratorResponse.FEATURE_PROTO3_OPTIONAL + + opts = parse_options(request.parameter) + + missing = [k for k in ("proto_include_prefix", "ros2_package") if k not in opts] + if missing: + response.error = f"Required options missing: {', '.join(missing)}" + sys.stdout.buffer.write(response.SerializeToString()) + return + + proto_include_prefix = opts["proto_include_prefix"] + ros2_package = opts["ros2_package"] + namespace = opts.get("namespace", "ateam_conversions") + optional_submsg = opts.get("optional_submsg", "has_field") + + if optional_submsg not in ("has_field", "error"): + response.error = ( + f"Unknown optional_submsg={optional_submsg!r}. " + f"Valid values: 'has_field', 'error'." + ) + sys.stdout.buffer.write(response.SerializeToString()) + return + + all_enums: dict = {} + for fd in request.proto_file: + for enum in fd.enum_type: + all_enums[enum.name] = enum + + map_entry_type_names = build_map_entry_type_names(request) + all_files = {f.name: f for f in request.proto_file} + errors: list = [] + + for file_name in request.file_to_generate: + fd = all_files[file_name] + stem = Path(file_name).stem + out = response.file.add() + out.name = f"{stem}_conversions.hpp" + out.content = generate_header( + fd, proto_include_prefix, ros2_package, namespace, + optional_submsg, all_enums, errors, map_entry_type_names, + ) + + if errors: + response.error = "\n".join(errors) + + sys.stdout.buffer.write(response.SerializeToString()) + + +if __name__ == "__main__": + main() diff --git a/ateam-common-packets/cmake/protoc_gen_ros2msg.py b/ateam-common-packets/cmake/protoc_gen_ros2msg.py new file mode 100755 index 0000000..f8c1d58 --- /dev/null +++ b/ateam-common-packets/cmake/protoc_gen_ros2msg.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +protoc plugin: generates ROS2 .msg files from .proto definitions. + +Invoked by protoc as a subprocess; reads CodeGeneratorRequest from stdin, +writes CodeGeneratorResponse to stdout (standard protoc plugin protocol). + +Type mappings: + Proto scalar → ROS2 primitive + message Foo → Foo.msg (separate file, referenced by name) + enum Foo → Foo.msg (constants-only message) + repeated T → T[] field + oneof foo → uint8 foo_case + constants + all arm fields + enum field → int32 (ROS2 has no enum type; constants in Foo.msg) + [(ateam.bitmask)] field → minimal uint type inferred from enum max value, + with flag constants emitted inline above the field + nested message Foo.Bar → Bar.msg with flat name Foo_Bar + map field → skipped (no ROS2 map type) + +Options (via --ros2msg_opt=key=value,key=value): + optional_submsg=has_field (default) Emit bool has_ before each + non-oneof message-type field. + optional_submsg=error Reject any non-oneof message-type field as a + build error; forces schema authors to be explicit. +""" + +import sys +from pathlib import Path +from google.protobuf.compiler import plugin_pb2 +from google.protobuf import descriptor_pb2 + +# Import generated options module so the bitmask extension is registered in +# the descriptor pool and accessible via field.options.Extensions[...]. +sys.path.insert(0, str(Path(__file__).parent)) +import ateam_options_pb2 # noqa: E402 +from ateam_proto_shared import ( # noqa: E402 + parse_options, + flatten_type_name, + build_map_entry_type_names, + iter_messages, +) + +# Public alias: tests import plugin.strip_package; keep it in this module's namespace. +strip_package = flatten_type_name + +FD = descriptor_pb2.FieldDescriptorProto + +SCALAR_TYPE_MAP = { + FD.TYPE_DOUBLE: "float64", + FD.TYPE_FLOAT: "float32", + FD.TYPE_INT64: "int64", + FD.TYPE_UINT64: "uint64", + FD.TYPE_INT32: "int32", + FD.TYPE_FIXED64: "uint64", + FD.TYPE_FIXED32: "uint32", + FD.TYPE_BOOL: "bool", + FD.TYPE_STRING: "string", + FD.TYPE_BYTES: "uint8[]", + FD.TYPE_UINT32: "uint32", + FD.TYPE_SINT32: "int32", + FD.TYPE_SINT64: "int64", + FD.TYPE_SFIXED32: "int32", + FD.TYPE_SFIXED64: "int64", +} + + +def ros2_field_type(field: descriptor_pb2.FieldDescriptorProto) -> str: + if field.type in SCALAR_TYPE_MAP: + return SCALAR_TYPE_MAP[field.type] + if field.type == FD.TYPE_ENUM: + return "int32" + if field.type == FD.TYPE_MESSAGE: + return flatten_type_name(field.type_name) + raise ValueError(f"unhandled proto field type {field.type} in field '{field.name}'") + + +def minimal_uint_type(max_value: int) -> str: + """Return the smallest unsigned ROS2 integer type that fits max_value.""" + if max_value < 256: + return "uint8" + if max_value < 65536: + return "uint16" + return "uint32" + + +def iter_enums(fd): + """Yield (flat_name, enum) for all enums in fd, including those nested in messages.""" + for enum in fd.enum_type: + yield enum.name, enum + + def _walk(msg, parent_flat: str): + flat = f"{parent_flat}_{msg.name}" if parent_flat else msg.name + for enum in msg.enum_type: + yield f"{flat}_{enum.name}", enum + for nested in msg.nested_type: + yield from _walk(nested, flat) + + for msg in fd.message_type: + yield from _walk(msg, "") + + +# --------------------------------------------------------------------------- # +# .msg generation +# --------------------------------------------------------------------------- # + +def generate_enum_msg(enum: descriptor_pb2.EnumDescriptorProto) -> str: + lines = [f"# Generated from proto enum {enum.name}"] + for v in enum.value: + lines.append(f"int32 {v.name}={v.number}") + return "\n".join(lines) + "\n" + + +def generate_message_msg( + msg: descriptor_pb2.DescriptorProto, + flat_name: str, + optional_submsg: str, + all_enums: dict, + errors: list, + map_entry_type_names: frozenset, +) -> str: + lines = [f"# Generated from proto message {flat_name}"] + + emitted_oneofs: set = set() + + for field in msg.field: + # Skip map fields: they reference a synthetic nested map-entry message + # and have no equivalent ROS2 type. + if field.type == FD.TYPE_MESSAGE and field.type_name in map_entry_type_names: + continue + + is_repeated = field.label == FD.LABEL_REPEATED + in_oneof = field.HasField("oneof_index") + + if in_oneof: + oi = field.oneof_index + if oi in emitted_oneofs: + continue + emitted_oneofs.add(oi) + + oneof_name = msg.oneof_decl[oi].name + oneof_fields = [ + f for f in msg.field + if f.HasField("oneof_index") and f.oneof_index == oi + ] + + lines.append("") + lines.append(f"# oneof {oneof_name}") + lines.append(f"# case constants use proto field numbers (stable across reordering)") + lines.append(f"uint8 ONEOF_{oneof_name.upper()}_NONE=0") + for of in oneof_fields: + if of.number > 255: + errors.append( + f"{flat_name}: oneof '{oneof_name}' field '{of.name}' has field " + f"number {of.number} which exceeds the uint8 range (max 255) used " + f"for the case discriminant. Use field numbers ≤ 255 in oneof " + f"declarations, or file a request to widen the discriminant type." + ) + continue + const = f"ONEOF_{oneof_name.upper()}_{of.name.upper()}" + lines.append(f"uint8 {const}={of.number}") + lines.append(f"uint8 {oneof_name}_case") + for of in oneof_fields: + lines.append(f"{ros2_field_type(of)} {of.name}") + continue + + # Check for [(ateam.bitmask)] annotation. + if field.options.HasExtension(ateam_options_pb2.bitmask): + bitmask_opts = field.options.Extensions[ateam_options_pb2.bitmask] + enum_name = bitmask_opts.flags_enum + + if field.type != FD.TYPE_UINT32: + errors.append( + f"{flat_name}.{field.name}: [(ateam.bitmask)] requires uint32, " + f"got type {field.type}" + ) + continue + + if enum_name not in all_enums: + errors.append( + f"{flat_name}.{field.name}: [(ateam.bitmask)] references unknown " + f"enum '{enum_name}'" + ) + continue + + enum = all_enums[enum_name] + max_val = max(v.number for v in enum.value) + uint_type = minimal_uint_type(max_val) + + lines.append(f"") + lines.append(f"# bitmask: {enum_name}") + for v in enum.value: + lines.append(f"{uint_type} {v.name}={v.number}") + lines.append(f"{uint_type} {field.name}") + continue + + if field.type == FD.TYPE_MESSAGE: + if optional_submsg == "error": + errors.append( + f"{flat_name}.{field.name}: non-oneof message-type field has " + f"implicit proto3 presence — set optional_submsg=has_field to " + f"auto-generate a bool presence flag, or move into a oneof." + ) + continue + lines.append(f"bool has_{field.name}") + + ros2_type = ros2_field_type(field) + if is_repeated: + lines.append(f"{ros2_type}[] {field.name}") + else: + lines.append(f"{ros2_type} {field.name}") + + return "\n".join(lines) + "\n" + + +def main() -> None: + data = sys.stdin.buffer.read() + request = plugin_pb2.CodeGeneratorRequest() + request.ParseFromString(data) + + response = plugin_pb2.CodeGeneratorResponse() + response.supported_features = ( + plugin_pb2.CodeGeneratorResponse.FEATURE_PROTO3_OPTIONAL + ) + + opts = parse_options(request.parameter) + optional_submsg = opts.get("optional_submsg", "has_field") + if optional_submsg not in ("has_field", "error"): + response.error = ( + f"Unknown optional_submsg={optional_submsg!r}. " + f"Valid values: 'has_field', 'error'." + ) + sys.stdout.buffer.write(response.SerializeToString()) + return + + # Build enum lookup across all files (file-scope enums only; bitmask + # annotations reference enums by short name and are only used in ateam + # protos where all enums are at file scope). + all_enums: dict = {} + for fd in request.proto_file: + for enum in fd.enum_type: + all_enums[enum.name] = enum + + map_entry_type_names = build_map_entry_type_names(request) + all_files = {f.name: f for f in request.proto_file} + errors: list = [] + + for file_name in request.file_to_generate: + fd = all_files[file_name] + + for flat_name, enum in iter_enums(fd): + out = response.file.add() + out.name = f"{flat_name}.msg" + out.content = generate_enum_msg(enum) + + for flat_name, msg in iter_messages(fd): + out = response.file.add() + out.name = f"{flat_name}.msg" + out.content = generate_message_msg( + msg, flat_name, optional_submsg, all_enums, errors, map_entry_type_names + ) + + if errors: + response.error = "\n".join(errors) + + sys.stdout.buffer.write(response.SerializeToString()) + + +if __name__ == "__main__": + main() diff --git a/ateam-common-packets/cmake/tests/protos/bad_bitmask_enum.proto b/ateam-common-packets/cmake/tests/protos/bad_bitmask_enum.proto new file mode 100644 index 0000000..ab5016c --- /dev/null +++ b/ateam-common-packets/cmake/tests/protos/bad_bitmask_enum.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; +package ateam_test; +import "ateam_options.proto"; + +// uint32 field annotated with an enum name that doesn't exist — should error. +message BadBitmaskEnum { + uint32 error_flags = 1 [(ateam.bitmask).flags_enum = "NonExistentEnum"]; +} diff --git a/ateam-common-packets/cmake/tests/protos/bad_bitmask_type.proto b/ateam-common-packets/cmake/tests/protos/bad_bitmask_type.proto new file mode 100644 index 0000000..d9dc309 --- /dev/null +++ b/ateam-common-packets/cmake/tests/protos/bad_bitmask_type.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; +package ateam_test; +import "ateam_options.proto"; + +enum TestFlag { + TEST_NONE = 0; + TEST_A = 1; + TEST_B = 2; +} + +// int32 field (not uint32) with bitmask annotation — should error. +message BadBitmaskType { + int32 error_flags = 1 [(ateam.bitmask).flags_enum = "TestFlag"]; +} diff --git a/ateam-common-packets/cmake/tests/protos/nested_types.proto b/ateam-common-packets/cmake/tests/protos/nested_types.proto new file mode 100644 index 0000000..9c29c87 --- /dev/null +++ b/ateam-common-packets/cmake/tests/protos/nested_types.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; +package ateam_test; + +// Nested message and enum inside a message — both should error at configure time. +message OuterMessage { + message InnerMessage { + uint32 value = 1; + } + enum InnerEnum { + INNER_NONE = 0; + INNER_A = 1; + } + uint32 x = 1; + InnerMessage inner = 2; +} diff --git a/ateam-common-packets/cmake/tests/protos/overflow_oneof.proto b/ateam-common-packets/cmake/tests/protos/overflow_oneof.proto new file mode 100644 index 0000000..757bf82 --- /dev/null +++ b/ateam-common-packets/cmake/tests/protos/overflow_oneof.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package ateam_test; + +// Oneof with a field number exceeding uint8 range (256) — must fail at configure +// time with a clear error naming the message, oneof, field, and the limit. +message OverflowOneof { + oneof choice { + uint32 a = 1; + uint32 b = 256; + } +} diff --git a/ateam-common-packets/cmake/tests/protos/sparse_oneof.proto b/ateam-common-packets/cmake/tests/protos/sparse_oneof.proto new file mode 100644 index 0000000..516c338 --- /dev/null +++ b/ateam-common-packets/cmake/tests/protos/sparse_oneof.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; +package ateam_test; + +// Non-sequential oneof field numbers — constants must use field numbers verbatim, +// not sequential indices. Reordering arms in this file must not change the constants. +message SparseOneof { + oneof choice { + uint32 a = 1; + uint32 b = 10; + uint32 c = 20; + } +} diff --git a/ateam-common-packets/cmake/tests/test_cpp_plugin.py b/ateam-common-packets/cmake/tests/test_cpp_plugin.py new file mode 100644 index 0000000..30d1c9b --- /dev/null +++ b/ateam-common-packets/cmake/tests/test_cpp_plugin.py @@ -0,0 +1,538 @@ +""" +Integration tests for protoc_gen_ros2cpp.py. + +Run with: pytest cmake/tests/test_cpp_plugin.py -v +Requires: protoc, python3-protobuf +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- # +# Paths +# --------------------------------------------------------------------------- # + +_THIS_DIR = Path(__file__).parent +_REPO_ROOT = _THIS_DIR.parent.parent +_PLUGIN = _THIS_DIR.parent / "protoc_gen_ros2cpp.py" +_PROTO_DIR = _REPO_ROOT / "proto" +_FIXTURE_DIR = _THIS_DIR / "protos" + +_DEFAULT_OPTS = ( + "proto_include_prefix=ateam_common_packets," + "ros2_package=ateam_radio_msgs," + "namespace=ateam_conversions" +) + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + +def run_cpp_plugin( + proto_files: list[str], + opts: str = _DEFAULT_OPTS, +) -> tuple[int, str, dict[str, str]]: + return _run_impl(proto_files, _PROTO_DIR, opts) + + +def run_cpp_plugin_fixture( + fixture_file: str, + opts: str = _DEFAULT_OPTS, +) -> tuple[int, str, dict[str, str]]: + return _run_impl( + [fixture_file], + _FIXTURE_DIR, + opts, + extra_proto_path=_PROTO_DIR, + ) + + +def _run_impl( + proto_files: list[str], + proto_dir: Path, + opts: str, + extra_proto_path: Path | None = None, +) -> tuple[int, str, dict[str, str]]: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + cmd = [ + "protoc", + f"--plugin=protoc-gen-ros2cpp={_PLUGIN}", + f"--ros2cpp_opt={opts}", + f"--ros2cpp_out={tmp_path}", + f"--proto_path={proto_dir}", + ] + if extra_proto_path is not None: + cmd.append(f"--proto_path={extra_proto_path}") + cmd.extend(str(proto_dir / f) for f in proto_files) + result = subprocess.run(cmd, capture_output=True, text=True) + files = {p.name: p.read_text() for p in tmp_path.glob("*.hpp")} + return result.returncode, result.stderr, files + + +# Import plugin for white-box unit tests. +sys.path.insert(0, str(_PLUGIN.parent)) +import protoc_gen_ros2cpp as cpp_plugin # noqa: E402 + + +# --------------------------------------------------------------------------- # +# Header structure tests +# --------------------------------------------------------------------------- # + +class TestHeaderStructure: + """Verify generated file structure: pragma, includes, namespace.""" + + def test_pragma_once(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert files["motor_conversions.hpp"].startswith("#pragma once") + + def test_proto_include(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "#include " in files["motor_conversions.hpp"] + + def test_ros2_message_includes(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert "#include " in content + assert "#include " in content + assert "#include " in content + + def test_namespace_open_and_close(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert "namespace ateam_conversions {" in content + assert "} // namespace ateam_conversions" in content + + def test_custom_namespace(self): + opts = _DEFAULT_OPTS.replace( + "namespace=ateam_conversions", "namespace=my_ns" + ) + code, err, files = run_cpp_plugin(["motor.proto"], opts=opts) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert "namespace my_ns {" in content + assert "} // namespace my_ns" in content + + def test_import_dependency_included(self): + """control.proto imports maneuvers.proto → maneuvers_conversions.hpp included.""" + code, err, files = run_cpp_plugin(["control.proto", "maneuvers.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert '#include "maneuvers_conversions.hpp"' in files["control_conversions.hpp"] + + def test_stdlib_headers_included(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert "#include " in content + assert "#include " in content + + def test_function_signature(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert ( + "inline ateam_radio_msgs::msg::CcmTelemetry fromProto(const ateam::CcmTelemetry& p)" + in content + ) + + +# --------------------------------------------------------------------------- # +# Scalar field tests +# --------------------------------------------------------------------------- # + +class TestScalarFields: + """Scalar proto fields map to direct assignment.""" + + def test_uint32_direct_assign(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "msg.bus_voltage_mv = p.bus_voltage_mv();" in files["motor_conversions.hpp"] + + def test_float_direct_assign(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "msg.vel_setpoint_rads = p.vel_setpoint_rads();" in files["motor_conversions.hpp"] + + def test_bool_direct_assign(self): + code, err, files = run_cpp_plugin(["control.proto", "maneuvers.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "msg.estop_brake = p.estop_brake();" in files["control_conversions.hpp"] + + def test_sint32_direct_assign(self): + """sint32 is signed; maps to int32_t via direct assign.""" + code, err, files = run_cpp_plugin(["telemetry.proto"] + [ + "maneuvers.proto", "motor.proto", "power.proto", + "kicker.proto", "body_control.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = files["telemetry_conversions.hpp"] + assert "msg.kf_pos_x = p.kf_pos_x();" in content + + def test_repeated_uint32_push_back(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "for (const auto& v : p.current_samples_ma()) " + "msg.current_samples_ma.push_back(v);" + in files["motor_conversions.hpp"] + ) + + +# --------------------------------------------------------------------------- # +# Enum field tests +# --------------------------------------------------------------------------- # + +class TestEnumFields: + """Enum proto fields map to static_cast.""" + + def test_enum_static_cast(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "msg.motion_control_type = static_cast(p.motion_control_type());" + in files["motor_conversions.hpp"] + ) + + def test_enum_in_control(self): + code, err, files = run_cpp_plugin(["control.proto", "maneuvers.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "msg.kick_request = static_cast(p.kick_request());" + in files["control_conversions.hpp"] + ) + assert ( + "msg.dribbler_mode = static_cast(p.dribbler_mode());" + in files["control_conversions.hpp"] + ) + + +# --------------------------------------------------------------------------- # +# Bitmask field tests +# --------------------------------------------------------------------------- # + +class TestBitmaskFields: + """[(ateam.bitmask)] fields use static_cast to the narrowed type.""" + + def test_uint16_bitmask_cast(self): + """CcmErrorFlag max=32768 → uint16_t cast.""" + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "msg.error_flags = static_cast(p.error_flags());" + in files["motor_conversions.hpp"] + ) + + def test_uint8_bitmask_cast(self): + """PowerStatusFlag max<256 → uint8_t cast.""" + code, err, files = run_cpp_plugin(["power.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "msg.status_flags = static_cast(p.status_flags());" + in files["power_conversions.hpp"] + ) + + def test_uint32_bitmask_cast(self): + """BasicTelemetryFlag max=67108864 → uint32_t cast.""" + code, err, files = run_cpp_plugin([ + "telemetry.proto", "maneuvers.proto", "motor.proto", + "power.proto", "kicker.proto", "body_control.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "msg.status_flags = static_cast(p.status_flags());" + in files["telemetry_conversions.hpp"] + ) + + +# --------------------------------------------------------------------------- # +# Submessage field tests (HAS_FIELD mode) +# --------------------------------------------------------------------------- # + +class TestSubmessageHasField: + """Non-oneof message fields get has_ check + conditional fromProto.""" + + def test_has_field_emitted(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert "msg.has_current_telem = p.has_current_telem();" in content + assert "if (p.has_current_telem()) msg.current_telem = fromProto(p.current_telem());" in content + + def test_has_field_for_velocity_telem(self): + code, err, files = run_cpp_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["motor_conversions.hpp"] + assert "msg.has_velocity_telem = p.has_velocity_telem();" in content + assert "if (p.has_velocity_telem()) msg.velocity_telem = fromProto(p.velocity_telem());" in content + + +# --------------------------------------------------------------------------- # +# oneof field tests +# --------------------------------------------------------------------------- # + +class TestOneofFields: + """oneof expansions: case discriminant + conditional arm assignments.""" + + def test_case_discriminant(self): + code, err, files = run_cpp_plugin(["control.proto", "maneuvers.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert ( + "msg.cmd_case = static_cast(p.cmd_case());" + in files["control_conversions.hpp"] + ) + + def test_message_arm_has_check(self): + code, err, files = run_cpp_plugin(["control.proto", "maneuvers.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = files["control_conversions.hpp"] + assert "if (p.has_global_pos()) msg.global_pos = fromProto(p.global_pos());" in content + assert "if (p.has_point_line()) msg.point_line = fromProto(p.point_line());" in content + + def test_scalar_oneof_arm_direct_assign(self): + """Scalar arms in oneof use direct assignment (no has_ check needed).""" + code, err, files = run_cpp_plugin_fixture("sparse_oneof.proto") + assert code == 0, f"protoc failed:\n{err}" + content = files["sparse_oneof_conversions.hpp"] + assert "msg.choice_case = static_cast(p.choice_case());" in content + assert "msg.a = p.a();" in content + assert "msg.b = p.b();" in content + assert "msg.c = p.c();" in content + + +# --------------------------------------------------------------------------- # +# Error mode tests +# --------------------------------------------------------------------------- # + +class TestErrorMode: + """optional_submsg=error rejects non-oneof message fields.""" + + def _error_opts(self) -> str: + return _DEFAULT_OPTS + ",optional_submsg=error" + + def test_error_mode_fails_on_submessage(self): + code, err, _ = run_cpp_plugin(["motor.proto"], opts=self._error_opts()) + assert code != 0, "Expected failure for non-oneof message fields in error mode" + + def test_error_mode_names_offending_field(self): + code, err, _ = run_cpp_plugin(["motor.proto"], opts=self._error_opts()) + assert code != 0 + assert "current_telem" in err + + def test_error_mode_passes_on_scalar_only_message(self): + """CcmCurrentTelemetry has only scalar fields — passes even in error mode.""" + code, err, files = run_cpp_plugin_fixture( + "sparse_oneof.proto", opts=self._error_opts() + ) + assert code == 0, f"Unexpected failure:\n{err}" + + def test_error_mode_passes_oneof_message_arms(self): + """oneof message arms are exempt from the error mode restriction.""" + code, err, files = run_cpp_plugin( + ["control.proto", "maneuvers.proto"], opts=self._error_opts() + ) + assert code == 0, f"Unexpected failure:\n{err}" + + +# --------------------------------------------------------------------------- # +# Required option validation +# --------------------------------------------------------------------------- # + +class TestRequiredOptions: + """plugin must error clearly when required options are missing.""" + + def test_missing_proto_include_prefix(self): + opts = "ros2_package=ateam_radio_msgs,namespace=ateam_conversions" + code, err, _ = run_cpp_plugin(["motor.proto"], opts=opts) + assert code != 0 + assert "proto_include_prefix" in err + + def test_missing_ros2_package(self): + opts = "proto_include_prefix=ateam_common_packets,namespace=ateam_conversions" + code, err, _ = run_cpp_plugin(["motor.proto"], opts=opts) + assert code != 0 + assert "ros2_package" in err + + def test_missing_both_required(self): + opts = "namespace=ateam_conversions" + code, err, _ = run_cpp_plugin(["motor.proto"], opts=opts) + assert code != 0 + assert "proto_include_prefix" in err + assert "ros2_package" in err + + def test_unknown_optional_submsg(self): + opts = _DEFAULT_OPTS + ",optional_submsg=bogus" + code, err, _ = run_cpp_plugin(["motor.proto"], opts=opts) + assert code != 0 + assert "bogus" in err + + +# --------------------------------------------------------------------------- # +# Unit tests for plugin helpers +# --------------------------------------------------------------------------- # + +class TestToSnakeCase: + def test_single_word(self): + assert cpp_plugin.to_snake_case("Motor") == "motor" + + def test_camel_two_words(self): + assert cpp_plugin.to_snake_case("CcmTelemetry") == "ccm_telemetry" + + def test_all_upper_acronym_followed_by_word(self): + assert cpp_plugin.to_snake_case("CcmCurrentTelemetry") == "ccm_current_telemetry" + + def test_already_snake(self): + assert cpp_plugin.to_snake_case("already_snake") == "already_snake" + + +class TestMinimalUintCtype: + def test_uint8(self): + assert cpp_plugin.minimal_uint_ctype(0) == "uint8_t" + assert cpp_plugin.minimal_uint_ctype(255) == "uint8_t" + + def test_uint16(self): + assert cpp_plugin.minimal_uint_ctype(256) == "uint16_t" + assert cpp_plugin.minimal_uint_ctype(65535) == "uint16_t" + + def test_uint32(self): + assert cpp_plugin.minimal_uint_ctype(65536) == "uint32_t" + assert cpp_plugin.minimal_uint_ctype(67108864) == "uint32_t" + + +class TestParseOptions: + def test_empty(self): + assert cpp_plugin.parse_options("") == {} + + def test_single(self): + assert cpp_plugin.parse_options("ros2_package=foo") == {"ros2_package": "foo"} + + def test_multiple(self): + result = cpp_plugin.parse_options("a=1,b=2") + assert result == {"a": "1", "b": "2"} + + +# --------------------------------------------------------------------------- # +# robot_parameters.proto C++ conversion tests +# --------------------------------------------------------------------------- # + +class TestRobotParameters: + """robot_parameters.proto: C++ conversion header for ParameterCommand.""" + + def test_robot_parameters_header_generated(self): + code, err, generated = run_cpp_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "robot_parameters_conversions.hpp" in generated + + def test_parameter_command_function_signature(self): + code, err, generated = run_cpp_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["robot_parameters_conversions.hpp"] + assert ( + "inline ateam_radio_msgs::msg::ParameterCommand " + "fromProto(const ateam::ParameterCommand& p)" + in content + ) + + def test_command_code_field_assigned(self): + """command_code is an enum → static_cast.""" + code, err, generated = run_cpp_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["robot_parameters_conversions.hpp"] + assert "msg.command_code = static_cast(p.command_code());" in content + + def test_parameter_name_field_assigned(self): + code, err, generated = run_cpp_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["robot_parameters_conversions.hpp"] + assert "msg.parameter_name = static_cast(p.parameter_name());" in content + + def test_repeated_float_data_assigned(self): + """repeated float → push_back loop.""" + code, err, generated = run_cpp_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["robot_parameters_conversions.hpp"] + assert "for (const auto& v : p.data()) msg.data.push_back(v);" in content + + +# --------------------------------------------------------------------------- # +# radio.proto C++ conversion tests +# --------------------------------------------------------------------------- # + +_ALL_PROTOS = [ + "maneuvers.proto", + "motor.proto", + "power.proto", + "kicker.proto", + "body_control.proto", + "control.proto", + "telemetry.proto", + "discovery.proto", + "diagnostics.proto", + "robot_parameters.proto", + "radio.proto", +] +_RADIO_DEPS = _ALL_PROTOS + + +class TestRadioPacket: + """radio.proto: C++ conversion header for RadioPacket and Keepalive.""" + + def test_radio_conversions_header_generated(self): + code, err, generated = run_cpp_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + assert "radio_conversions.hpp" in generated + + def test_radio_packet_function_signature(self): + code, err, generated = run_cpp_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["radio_conversions.hpp"] + assert ( + "inline ateam_radio_msgs::msg::RadioPacket " + "fromProto(const ateam::RadioPacket& p)" + in content + ) + + def test_keepalive_function_generated(self): + code, err, generated = run_cpp_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["radio_conversions.hpp"] + assert ( + "inline ateam_radio_msgs::msg::Keepalive " + "fromProto(const ateam::Keepalive& p)" + in content + ) + + def test_oneof_arms_use_has_check(self): + """Each oneof arm must use p.has_() before converting.""" + code, err, generated = run_cpp_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["radio_conversions.hpp"] + assert "if (p.has_control()) msg.control = fromProto(p.control());" in content + assert "if (p.has_telemetry()) msg.telemetry = fromProto(p.telemetry());" in content + assert "if (p.has_keepalive()) msg.keepalive = fromProto(p.keepalive());" in content + assert "if (p.has_parameter_command()) msg.parameter_command = fromProto(p.parameter_command());" in content + + def test_payload_case_discriminant_assigned(self): + """payload_case must be assigned from p.payload_case().""" + code, err, generated = run_cpp_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["radio_conversions.hpp"] + assert "msg.payload_case = static_cast(p.payload_case());" in content + + def test_dependency_includes_in_header(self): + """radio_conversions.hpp must include each imported proto's conversion header.""" + code, err, generated = run_cpp_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["radio_conversions.hpp"] + assert '#include "control_conversions.hpp"' in content + assert '#include "diagnostics_conversions.hpp"' in content + assert '#include "discovery_conversions.hpp"' in content + assert '#include "robot_parameters_conversions.hpp"' in content + assert '#include "telemetry_conversions.hpp"' in content diff --git a/ateam-common-packets/cmake/tests/test_plugin.py b/ateam-common-packets/cmake/tests/test_plugin.py new file mode 100644 index 0000000..d9bfbca --- /dev/null +++ b/ateam-common-packets/cmake/tests/test_plugin.py @@ -0,0 +1,530 @@ +""" +Integration and unit tests for protoc_gen_ros2msg.py. + +Run with: pytest cmake/tests/test_plugin.py -v +Requires: protoc, python3-protobuf (pip install protobuf or nix python3Packages.protobuf) +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- # +# Paths +# --------------------------------------------------------------------------- # + +_THIS_DIR = Path(__file__).parent +_REPO_ROOT = _THIS_DIR.parent.parent # ateam-common-packets/ +_PLUGIN = _THIS_DIR.parent / "protoc_gen_ros2msg.py" +_PROTO_DIR = _REPO_ROOT / "proto" + +_FIXTURE_DIR = _THIS_DIR / "protos" + +_ALL_PROTOS = [ + "maneuvers.proto", + "motor.proto", + "power.proto", + "kicker.proto", + "body_control.proto", + "control.proto", + "telemetry.proto", + "discovery.proto", + "diagnostics.proto", + "robot_parameters.proto", + "radio.proto", +] + +# --------------------------------------------------------------------------- # +# Helper +# --------------------------------------------------------------------------- # + +def run_plugin( + proto_files: list[str], + optional_submsg: str = "has_field", +) -> tuple[int, str, dict[str, str]]: + return _run_plugin_impl(proto_files, _PROTO_DIR, optional_submsg) + + +def run_plugin_fixture( + fixture_file: str, + optional_submsg: str = "has_field", +) -> tuple[int, str, dict[str, str]]: + """Run the plugin against a test fixture proto in cmake/tests/protos/.""" + return _run_plugin_impl( + [fixture_file], + _FIXTURE_DIR, + optional_submsg, + extra_proto_path=_PROTO_DIR, + ) + + +def _run_plugin_impl( + proto_files: list[str], + proto_dir: Path, + optional_submsg: str = "has_field", + extra_proto_path: Path | None = None, +) -> tuple[int, str, dict[str, str]]: + """ + Invoke protoc with the ros2msg plugin. + + Returns (exit_code, stderr_text, {filename: content}). + """ + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + cmd = [ + "protoc", + f"--plugin=protoc-gen-ros2msg={_PLUGIN}", + f"--ros2msg_opt=optional_submsg={optional_submsg}", + f"--ros2msg_out={tmp_path}", + f"--proto_path={proto_dir}", + ] + if extra_proto_path is not None: + cmd.append(f"--proto_path={extra_proto_path}") + cmd.extend(str(proto_dir / f) for f in proto_files) + result = subprocess.run(cmd, capture_output=True, text=True) + files = {p.name: p.read_text() for p in tmp_path.glob("*.msg")} + return result.returncode, result.stderr, files + + +# --------------------------------------------------------------------------- # +# Error-mode tests +# --------------------------------------------------------------------------- # + +class TestErrorMode: + """Verify optional_submsg=error rejects non-oneof message-type fields.""" + + def test_error_mode_fails_on_submessage_fields(self): + """telemetry.proto has non-oneof message fields — must fail under error mode.""" + code, err, _ = run_plugin(["telemetry.proto"], optional_submsg="error") + assert code != 0, "Expected failure but plugin succeeded" + + def test_error_mode_reports_each_offending_field(self): + """Error output must name every non-oneof message field in ExtendedTelemetry.""" + code, err, _ = run_plugin(["telemetry.proto"], optional_submsg="error") + expected_fields = [ + "power_status", + "front_left_motor", + "back_left_motor", + "back_right_motor", + "front_right_motor", + "body_control_telem", + "kicker_status", + ] + for field in expected_fields: + assert field in err, ( + f"Expected field '{field}' in error output but got:\n{err}" + ) + + def test_error_mode_passes_on_oneof_only_messages(self): + """control.proto oneof fields are fine even in error mode — no submessages outside oneof.""" + code, err, _ = run_plugin( + ["maneuvers.proto", "control.proto"], + optional_submsg="error", + ) + assert code == 0, f"Unexpected failure:\n{err}" + + def test_has_field_mode_succeeds_on_all_protos(self): + """has_field mode must succeed on every proto file without errors.""" + code, err, generated = run_plugin(_ALL_PROTOS, optional_submsg="has_field") + assert code == 0, f"protoc failed:\n{err}" + assert len(generated) > 0 + + +# --------------------------------------------------------------------------- # +# Unit tests for plugin internals +# --------------------------------------------------------------------------- # + +# Import the plugin module directly for white-box unit tests. +sys.path.insert(0, str(_PLUGIN.parent)) +import protoc_gen_ros2msg as plugin # noqa: E402 (import after path manipulation) + + +class TestParseOptions: + def test_empty_string(self): + assert plugin.parse_options("") == {} + + def test_single_pair(self): + assert plugin.parse_options("optional_submsg=has_field") == { + "optional_submsg": "has_field" + } + + def test_multiple_pairs(self): + result = plugin.parse_options("a=1,b=2,c=three") + assert result == {"a": "1", "b": "2", "c": "three"} + + def test_value_with_equals(self): + # Value itself contains '=' — only first '=' is the separator. + result = plugin.parse_options("key=val=ue") + assert result == {"key": "val=ue"} + + def test_entry_without_equals_is_skipped(self): + result = plugin.parse_options("bare,key=value") + assert result == {"key": "value"} + + +class TestStripPackage: + def test_strips_package_prefix(self): + assert plugin.strip_package(".ateam.FooBar") == "FooBar" + + def test_nested_package(self): + assert plugin.strip_package(".a.b.c.Msg") == "Msg" + + def test_no_package(self): + assert plugin.strip_package("Msg") == "Msg" + + +class TestUnknownOption: + """Plugin must return a non-zero error response for unknown option values.""" + + def test_unknown_optional_submsg_value(self): + code, err, _ = run_plugin(["maneuvers.proto"], optional_submsg="bogus") + assert code != 0, "Expected failure for unknown option but plugin succeeded" + assert "bogus" in err + + +class TestMinimalUintType: + def test_fits_uint8(self): + assert plugin.minimal_uint_type(0) == "uint8" + assert plugin.minimal_uint_type(64) == "uint8" + assert plugin.minimal_uint_type(255) == "uint8" + + def test_fits_uint16(self): + assert plugin.minimal_uint_type(256) == "uint16" + assert plugin.minimal_uint_type(32768) == "uint16" + assert plugin.minimal_uint_type(65535) == "uint16" + + def test_needs_uint32(self): + assert plugin.minimal_uint_type(65536) == "uint32" + assert plugin.minimal_uint_type(67108864) == "uint32" + + +class TestBitmaskAnnotation: + """Verify [(ateam.bitmask)] fields get minimal type and inline constants.""" + + def test_uint8_bitmask_battery_status(self): + """BatteryStatusFlag max=64 → uint8 field and constants.""" + code, err, generated = run_plugin(["power.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["BatteryInfo.msg"] + assert "uint8 status_flags" in content + assert "uint8 BATTERY_STATUS_OK=1" in content + assert "uint8 BATTERY_STATUS_CELL_IMBALANCE_WARN=64" in content + + def test_uint8_bitmask_power_status(self): + """PowerStatusFlag max=32 → uint8.""" + code, err, generated = run_plugin(["power.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["PowerTelemetry.msg"] + assert "uint8 status_flags" in content + assert "uint8 POWER_STATUS_OK=1" in content + + def test_uint8_bitmask_kicker_status(self): + """KickerStatusFlag max=64 → uint8.""" + code, err, generated = run_plugin(["motor.proto", "kicker.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["KickerTelemetry.msg"] + assert "uint8 status_flags" in content + assert "uint8 KICKER_STATUS_DRIBBLER_FW_LOADED=64" in content + + def test_uint16_bitmask_ccm_error(self): + """CcmErrorFlag max=32768 → uint16.""" + code, err, generated = run_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["CcmTelemetry.msg"] + assert "uint16 error_flags" in content + assert "uint16 CCM_ERR_RESET_PIN=32768" in content + + def test_uint32_bitmask_basic_telemetry(self): + """BasicTelemetryFlag max=67108864 → uint32.""" + code, err, generated = run_plugin(_ALL_PROTOS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["BasicTelemetry.msg"] + assert "uint32 status_flags" in content + assert "uint32 BTEL_FLAG_CONTROLLER_RESET=67108864" in content + + def test_bitmask_constants_appear_before_field(self): + """Flag constants must be declared before the field that uses them.""" + code, err, generated = run_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["CcmTelemetry.msg"] + const_pos = content.index("uint16 CCM_ERR_MASTER_ERROR=1") + field_pos = content.index("uint16 error_flags") + assert const_pos < field_pos + + def test_bitmask_section_header_comment(self): + """A '# bitmask: EnumName' comment must precede the constants.""" + code, err, generated = run_plugin(["motor.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "# bitmask: CcmErrorFlag" in generated["CcmTelemetry.msg"] + + +# --------------------------------------------------------------------------- # +# Oneof field-number tests +# --------------------------------------------------------------------------- # + +class TestOneofFieldNumbers: + """ + Oneof case constants must use proto field numbers, not sequential declaration + indices. Field numbers are the stable proto identifier; declaration order + may change freely without altering wire semantics. + """ + + def test_sparse_oneof_constants_use_field_numbers(self): + """Non-sequential field numbers (1,10,20) must appear verbatim, not as 1,2,3.""" + code, err, generated = run_plugin_fixture("sparse_oneof.proto") + assert code == 0, f"protoc failed:\n{err}" + content = generated["SparseOneof.msg"] + assert "uint8 ONEOF_CHOICE_A=1" in content + assert "uint8 ONEOF_CHOICE_B=10" in content + assert "uint8 ONEOF_CHOICE_C=20" in content + + def test_sparse_oneof_none_is_always_zero(self): + """NONE sentinel must be 0 regardless of field numbers used by arms.""" + code, err, generated = run_plugin_fixture("sparse_oneof.proto") + assert code == 0, f"protoc failed:\n{err}" + assert "uint8 ONEOF_CHOICE_NONE=0" in generated["SparseOneof.msg"] + + def test_sparse_oneof_no_sequential_constants(self): + """Verify the old sequential values (2,3) are NOT present.""" + code, err, generated = run_plugin_fixture("sparse_oneof.proto") + assert code == 0, f"protoc failed:\n{err}" + content = generated["SparseOneof.msg"] + assert "ONEOF_CHOICE_B=2" not in content + assert "ONEOF_CHOICE_C=3" not in content + + def test_sequential_oneof_still_works(self): + """Sequential field numbers (common case) are unaffected by the change.""" + code, err, generated = run_plugin(["control.proto", "maneuvers.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["BasicControl.msg"] + # Field numbers 1-9 happen to equal the old sequential indices. + assert "uint8 ONEOF_CMD_GLOBAL_POS=1" in content + assert "uint8 ONEOF_CMD_GLOBAL_VEL=2" in content + assert "uint8 ONEOF_CMD_POINT_LINE=9" in content + + def test_oneof_field_number_over_255_fails(self): + """Field number 256 in a oneof must fail at configure time.""" + code, err, _ = run_plugin_fixture("overflow_oneof.proto") + assert code != 0, "Expected failure for oneof field number > 255" + assert "256" in err + assert "255" in err # the stated limit + + def test_overflow_error_names_message_oneof_and_field(self): + """Error message must identify the message, oneof, and field by name.""" + code, err, _ = run_plugin_fixture("overflow_oneof.proto") + assert code != 0 + assert "OverflowOneof" in err + assert "choice" in err + assert "b" in err + + +# --------------------------------------------------------------------------- # +# Bitmask validation error tests +# --------------------------------------------------------------------------- # + +class TestBitmaskValidation: + """Verify plugin rejects invalid [(ateam.bitmask)] annotations.""" + + def test_bitmask_on_non_uint32_field_fails(self): + """[(ateam.bitmask)] on int32 field must fail with a descriptive error.""" + code, err, _ = run_plugin_fixture("bad_bitmask_type.proto") + assert code != 0, "Expected failure for bitmask on non-uint32 field" + assert "BadBitmaskType.error_flags" in err + assert "uint32" in err + + def test_bitmask_unknown_enum_fails(self): + """[(ateam.bitmask)] referencing an undefined enum must fail.""" + code, err, _ = run_plugin_fixture("bad_bitmask_enum.proto") + assert code != 0, "Expected failure for bitmask with unknown enum reference" + assert "BadBitmaskEnum.error_flags" in err + assert "NonExistentEnum" in err + + +# --------------------------------------------------------------------------- # +# Nested type error tests +# --------------------------------------------------------------------------- # + +class TestNestedTypes: + """Nested message/enum definitions are supported via flattened names. + + A message Bar defined inside Foo generates Foo_Bar.msg, matching the naming + convention proto C++ uses for nested types (Foo_Bar is the actual class name). + """ + + def test_nested_message_generates_flat_msg(self): + """InnerMessage nested in OuterMessage → OuterMessage_InnerMessage.msg.""" + code, err, generated = run_plugin_fixture("nested_types.proto") + assert code == 0, f"protoc failed:\n{err}" + assert "OuterMessage_InnerMessage.msg" in generated + + def test_nested_enum_generates_flat_msg(self): + """InnerEnum nested in OuterMessage → OuterMessage_InnerEnum.msg.""" + code, err, generated = run_plugin_fixture("nested_types.proto") + assert code == 0, f"protoc failed:\n{err}" + assert "OuterMessage_InnerEnum.msg" in generated + + def test_outer_references_inner_by_flat_name(self): + """The 'inner' field in OuterMessage must use type OuterMessage_InnerMessage.""" + code, err, generated = run_plugin_fixture("nested_types.proto") + assert code == 0, f"protoc failed:\n{err}" + content = generated["OuterMessage.msg"] + assert "OuterMessage_InnerMessage inner" in content + + def test_nested_inner_message_content(self): + """OuterMessage_InnerMessage.msg must contain the nested message's field.""" + code, err, generated = run_plugin_fixture("nested_types.proto") + assert code == 0, f"protoc failed:\n{err}" + assert "uint32 value" in generated["OuterMessage_InnerMessage.msg"] + + def test_nested_enum_content(self): + """OuterMessage_InnerEnum.msg must contain the enum constants.""" + code, err, generated = run_plugin_fixture("nested_types.proto") + assert code == 0, f"protoc failed:\n{err}" + content = generated["OuterMessage_InnerEnum.msg"] + assert "int32 INNER_NONE=0" in content + assert "int32 INNER_A=1" in content + + +# --------------------------------------------------------------------------- # +# diagnostics.proto output tests +# --------------------------------------------------------------------------- # + +class TestDiagnosticsProto: + """Verify diagnostics.proto generates the expected .msg output.""" + + def test_error_telemetry_msg_generated(self): + """diagnostics.proto must produce ErrorTelemetry.msg.""" + code, err, generated = run_plugin(["diagnostics.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "ErrorTelemetry.msg" in generated + + def test_error_telemetry_has_timestamp_field(self): + code, err, generated = run_plugin(["diagnostics.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "uint32 timestamp" in generated["ErrorTelemetry.msg"] + + def test_error_telemetry_has_error_message_field(self): + code, err, generated = run_plugin(["diagnostics.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "string error_message" in generated["ErrorTelemetry.msg"] + + +# --------------------------------------------------------------------------- # +# robot_parameters.proto output tests +# --------------------------------------------------------------------------- # + +class TestRobotParameters: + """robot_parameters.proto: ParameterCommand + enums generate expected .msg files.""" + + def test_parameter_command_msg_generated(self): + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "ParameterCommand.msg" in generated + + def test_parameter_command_code_enum_generated(self): + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "ParameterCommandCode.msg" in generated + + def test_parameter_name_enum_generated(self): + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "ParameterName.msg" in generated + + def test_parameter_command_has_command_code_field(self): + """Enum fields emit as int32 in ROS2 .msg.""" + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "int32 command_code" in generated["ParameterCommand.msg"] + + def test_parameter_command_has_parameter_name_field(self): + """Enum fields emit as int32 in ROS2 .msg.""" + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "int32 parameter_name" in generated["ParameterCommand.msg"] + + def test_parameter_command_has_repeated_float_data(self): + """data is repeated float — ROS2 maps repeated float to float32[].""" + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "float32[] data" in generated["ParameterCommand.msg"] + + def test_parameter_command_code_constants(self): + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ParameterCommandCode.msg"] + assert "int32 PCC_READ=0" in content + assert "int32 PCC_WRITE=1" in content + assert "int32 PCC_ACK=2" in content + assert "int32 PCC_NACK_INVALID_NAME=3" in content + assert "int32 PCC_NACK_INVALID_TYPE_FOR_NAME=4" in content + + def test_parameter_name_constants(self): + code, err, generated = run_plugin(["robot_parameters.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ParameterName.msg"] + assert "int32 PN_KF_PROCESS_STD=0" in content + assert "int32 PN_PHYS_FRICTION_MODEL=6" in content + assert "int32 PN_TWIST_FB_PIDII_ANGULAR=13" in content + + +# --------------------------------------------------------------------------- # +# radio.proto output tests +# --------------------------------------------------------------------------- # + +_RADIO_DEPS = _ALL_PROTOS + + +class TestRadioPacket: + """radio.proto: oneof replaces CommandCode; field numbers match legacy CC_* values.""" + + def test_radio_packet_msg_generated(self): + code, err, generated = run_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + assert "RadioPacket.msg" in generated + + def test_keepalive_msg_generated(self): + """Empty Keepalive message generates a (header-only) .msg file.""" + code, err, generated = run_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + assert "Keepalive.msg" in generated + + def test_oneof_case_constants_match_legacy_cc_values(self): + """Field numbers intentionally align with legacy CC_* values for migration docs.""" + code, err, generated = run_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["RadioPacket.msg"] + assert "uint8 ONEOF_PAYLOAD_KEEPALIVE=4" in content # CC_KEEPALIVE=4 + assert "uint8 ONEOF_PAYLOAD_HELLO_REQUEST=21" in content # CC_HELLO_REQ=21 + assert "uint8 ONEOF_PAYLOAD_HELLO_RESPONSE=22" in content # CC_HELLO_RESP=22 + assert "uint8 ONEOF_PAYLOAD_TELEMETRY=41" in content # CC_TELEMETRY=41 + assert "uint8 ONEOF_PAYLOAD_EXTENDED_TELEMETRY=42" in content # CC_CONTROL_DEBUG_TELEMETRY=42 + assert "uint8 ONEOF_PAYLOAD_PARAMETER_COMMAND=43" in content # CC_ROBOT_PARAMETER_COMMAND=43 + assert "uint8 ONEOF_PAYLOAD_ERROR_TELEMETRY=44" in content # CC_ERROR_TELEMETRY=44 + assert "uint8 ONEOF_PAYLOAD_CONTROL=61" in content # CC_CONTROL=61 + + def test_oneof_none_is_zero(self): + code, err, generated = run_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + assert "uint8 ONEOF_PAYLOAD_NONE=0" in generated["RadioPacket.msg"] + + def test_payload_case_field_present(self): + code, err, generated = run_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + assert "uint8 payload_case" in generated["RadioPacket.msg"] + + def test_arm_types_reference_correct_msg_names(self): + """Each oneof arm field uses the flat ROS2 message type name.""" + code, err, generated = run_plugin(_RADIO_DEPS) + assert code == 0, f"protoc failed:\n{err}" + content = generated["RadioPacket.msg"] + assert "HelloRequest hello_request" in content + assert "BasicControl control" in content + assert "BasicTelemetry telemetry" in content + assert "ExtendedTelemetry extended_telemetry" in content + assert "ParameterCommand parameter_command" in content + assert "ErrorTelemetry error_telemetry" in content + assert "Keepalive keepalive" in content diff --git a/ateam-common-packets/include/basic_control.h b/ateam-common-packets/include/radio/basic_control.h similarity index 97% rename from ateam-common-packets/include/basic_control.h rename to ateam-common-packets/include/radio/basic_control.h index 2e36568..39b8921 100644 --- a/ateam-common-packets/include/basic_control.h +++ b/ateam-common-packets/include/radio/basic_control.h @@ -1,7 +1,7 @@ #pragma once -#include "common.h" -#include "kicker.h" +#include "../common.h" +#include "../wire/kicker.h" #include "robot_maneuvers/global_position.h" #include "robot_maneuvers/global_velocity.h" diff --git a/ateam-common-packets/include/basic_telemetry.h b/ateam-common-packets/include/radio/basic_telemetry.h similarity index 99% rename from ateam-common-packets/include/basic_telemetry.h rename to ateam-common-packets/include/radio/basic_telemetry.h index 075415e..d8eb0f7 100644 --- a/ateam-common-packets/include/basic_telemetry.h +++ b/ateam-common-packets/include/radio/basic_telemetry.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" #include "body_control.h" #include "robot_maneuvers/global_position.h" diff --git a/ateam-common-packets/include/body_control.h b/ateam-common-packets/include/radio/body_control.h similarity index 98% rename from ateam-common-packets/include/body_control.h rename to ateam-common-packets/include/radio/body_control.h index c43725a..5b9af6a 100644 --- a/ateam-common-packets/include/body_control.h +++ b/ateam-common-packets/include/radio/body_control.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" #include "basic_control.h" typedef union BodyControlManeuverExtendedTelemetry { diff --git a/ateam-common-packets/include/discovery.h b/ateam-common-packets/include/radio/discovery.h similarity index 96% rename from ateam-common-packets/include/discovery.h rename to ateam-common-packets/include/radio/discovery.h index c435b87..edd411c 100644 --- a/ateam-common-packets/include/discovery.h +++ b/ateam-common-packets/include/radio/discovery.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" typedef enum TeamColor : uint8_t { TC_YELLOW = 0, diff --git a/ateam-common-packets/include/error_telemetry.h b/ateam-common-packets/include/radio/error_telemetry.h similarity index 87% rename from ateam-common-packets/include/error_telemetry.h rename to ateam-common-packets/include/radio/error_telemetry.h index aa0c60f..1996f24 100644 --- a/ateam-common-packets/include/error_telemetry.h +++ b/ateam-common-packets/include/radio/error_telemetry.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" typedef struct ErrorTelemetry { uint32_t timestamp; diff --git a/ateam-common-packets/include/extended_telemetry.h b/ateam-common-packets/include/radio/extended_telemetry.h similarity index 83% rename from ateam-common-packets/include/extended_telemetry.h rename to ateam-common-packets/include/radio/extended_telemetry.h index a6c828a..6b238df 100644 --- a/ateam-common-packets/include/extended_telemetry.h +++ b/ateam-common-packets/include/radio/extended_telemetry.h @@ -1,10 +1,10 @@ #pragma once -#include "common.h" +#include "../common.h" #include "body_control.h" -#include "kicker.h" -#include "power.h" -#include "stspin_current.h" +#include "../wire/kicker.h" +#include "../wire/power.h" +#include "../wire/stspin_current.h" typedef struct ExtendedTelemetry { uint32_t timestamp_us_lo; diff --git a/ateam-common-packets/include/radio.h b/ateam-common-packets/include/radio/radio.h similarity index 94% rename from ateam-common-packets/include/radio.h rename to ateam-common-packets/include/radio/radio.h index 1195f68..9a659d7 100644 --- a/ateam-common-packets/include/radio.h +++ b/ateam-common-packets/include/radio/radio.h @@ -1,14 +1,14 @@ #pragma once -#include "common.h" +#include "../common.h" #include "discovery.h" #include "basic_control.h" #include "basic_telemetry.h" #include "error_telemetry.h" #include "extended_telemetry.h" -#include "power.h" #include "robot_parameters.h" -#include "stspin_current.h" +#include "../wire/power.h" +#include "../wire/stspin_current.h" typedef enum CommandCode : uint8_t { // bidirectional commands and status diff --git a/ateam-common-packets/include/robot_maneuvers/global_acceleration.h b/ateam-common-packets/include/radio/robot_maneuvers/global_acceleration.h similarity index 95% rename from ateam-common-packets/include/robot_maneuvers/global_acceleration.h rename to ateam-common-packets/include/radio/robot_maneuvers/global_acceleration.h index 53c9527..d79360b 100644 --- a/ateam-common-packets/include/robot_maneuvers/global_acceleration.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/global_acceleration.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" typedef struct GlobalAccelerationCommand { float global_xdd; diff --git a/ateam-common-packets/include/robot_maneuvers/global_position.h b/ateam-common-packets/include/radio/robot_maneuvers/global_position.h similarity index 95% rename from ateam-common-packets/include/robot_maneuvers/global_position.h rename to ateam-common-packets/include/radio/robot_maneuvers/global_position.h index eac6c58..f03fc61 100644 --- a/ateam-common-packets/include/robot_maneuvers/global_position.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/global_position.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" typedef struct GlobalPositionCommand { float global_x; diff --git a/ateam-common-packets/include/robot_maneuvers/global_velocity.h b/ateam-common-packets/include/radio/robot_maneuvers/global_velocity.h similarity index 95% rename from ateam-common-packets/include/robot_maneuvers/global_velocity.h rename to ateam-common-packets/include/radio/robot_maneuvers/global_velocity.h index 98a3589..bd1f743 100644 --- a/ateam-common-packets/include/robot_maneuvers/global_velocity.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/global_velocity.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" typedef struct GlobalVelocityCommand { float global_xd; diff --git a/ateam-common-packets/include/robot_maneuvers/line.h b/ateam-common-packets/include/radio/robot_maneuvers/line.h similarity index 99% rename from ateam-common-packets/include/robot_maneuvers/line.h rename to ateam-common-packets/include/radio/robot_maneuvers/line.h index 8b8ed06..396627b 100644 --- a/ateam-common-packets/include/robot_maneuvers/line.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/line.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" // Follow a line at a constant velocity while holding a fixed heading // (BCM_HEADING_LINE). diff --git a/ateam-common-packets/include/robot_maneuvers/local_acceleration.h b/ateam-common-packets/include/radio/robot_maneuvers/local_acceleration.h similarity index 95% rename from ateam-common-packets/include/robot_maneuvers/local_acceleration.h rename to ateam-common-packets/include/radio/robot_maneuvers/local_acceleration.h index 2132d7f..69f44a2 100644 --- a/ateam-common-packets/include/robot_maneuvers/local_acceleration.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/local_acceleration.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" typedef struct LocalAccelerationCommand { float local_xdd; diff --git a/ateam-common-packets/include/robot_maneuvers/local_velocity.h b/ateam-common-packets/include/radio/robot_maneuvers/local_velocity.h similarity index 95% rename from ateam-common-packets/include/robot_maneuvers/local_velocity.h rename to ateam-common-packets/include/radio/robot_maneuvers/local_velocity.h index 12a3745..2cac4dd 100644 --- a/ateam-common-packets/include/robot_maneuvers/local_velocity.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/local_velocity.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" typedef struct LocalVelocityCommand { float local_xd; diff --git a/ateam-common-packets/include/robot_maneuvers/pivot.h b/ateam-common-packets/include/radio/robot_maneuvers/pivot.h similarity index 98% rename from ateam-common-packets/include/robot_maneuvers/pivot.h rename to ateam-common-packets/include/radio/robot_maneuvers/pivot.h index a645b82..96e8328 100644 --- a/ateam-common-packets/include/robot_maneuvers/pivot.h +++ b/ateam-common-packets/include/radio/robot_maneuvers/pivot.h @@ -1,6 +1,6 @@ #pragma once -#include "../common.h" +#include "../../common.h" // Whether the robot drives forward or backward around the orbit. typedef enum PivotDirection : uint8_t { diff --git a/ateam-common-packets/include/robot_parameters.h b/ateam-common-packets/include/radio/robot_parameters.h similarity index 98% rename from ateam-common-packets/include/robot_parameters.h rename to ateam-common-packets/include/radio/robot_parameters.h index 3aab2fd..07917e8 100644 --- a/ateam-common-packets/include/robot_parameters.h +++ b/ateam-common-packets/include/radio/robot_parameters.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" typedef enum ParameterCommandCode : uint8_t { PCC_READ = 0, diff --git a/ateam-common-packets/include/kicker.h b/ateam-common-packets/include/wire/kicker.h similarity index 98% rename from ateam-common-packets/include/kicker.h rename to ateam-common-packets/include/wire/kicker.h index 4c427ea..a0720aa 100644 --- a/ateam-common-packets/include/kicker.h +++ b/ateam-common-packets/include/wire/kicker.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" #include "stspin_current.h" typedef enum KickRequest : uint8_t { diff --git a/ateam-common-packets/include/power.h b/ateam-common-packets/include/wire/power.h similarity index 98% rename from ateam-common-packets/include/power.h rename to ateam-common-packets/include/wire/power.h index 70c40ca..15c208a 100644 --- a/ateam-common-packets/include/power.h +++ b/ateam-common-packets/include/wire/power.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" typedef struct BatteryInfo { uint16_t battery_ok : 1; diff --git a/ateam-common-packets/include/stspin.h b/ateam-common-packets/include/wire/stspin.h similarity index 99% rename from ateam-common-packets/include/stspin.h rename to ateam-common-packets/include/wire/stspin.h index cb73ef0..333e31d 100644 --- a/ateam-common-packets/include/stspin.h +++ b/ateam-common-packets/include/wire/stspin.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" typedef enum MotorCommandType { MCP_PARAMS = 0x20, diff --git a/ateam-common-packets/include/stspin_current.h b/ateam-common-packets/include/wire/stspin_current.h similarity index 99% rename from ateam-common-packets/include/stspin_current.h rename to ateam-common-packets/include/wire/stspin_current.h index d23f5ed..595d0bf 100644 --- a/ateam-common-packets/include/stspin_current.h +++ b/ateam-common-packets/include/wire/stspin_current.h @@ -1,6 +1,6 @@ #pragma once -#include "common.h" +#include "../common.h" typedef enum CcmCommandType { CCM_CMD_PARAMS = 0x20, diff --git a/ateam-common-packets/proto/ateam_options.proto b/ateam-common-packets/proto/ateam_options.proto new file mode 100644 index 0000000..2a0bdc6 --- /dev/null +++ b/ateam-common-packets/proto/ateam_options.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package ateam; + +import "google/protobuf/descriptor.proto"; + +// Bitmask field annotation. +// Attach to a uint32 field to declare it as a packed bitfield whose named +// bits are defined by an enum in the same package. +// +// Example: +// uint32 status_flags = 4 [(ateam.bitmask).flags_enum = "MyStatusFlag"]; +// +// The code generators (ROS2 .msg plugin, Python helper generator) use this +// to produce typed bitfield accessors and inline the flag constants. +// The annotation is compile-time metadata only — it has no wire encoding. +message BitfieldOptions { + // Name of the enum (in the same package) whose values define the flag bits. + string flags_enum = 1; +} + +extend google.protobuf.FieldOptions { + BitfieldOptions bitmask = 50000; +} diff --git a/ateam-common-packets/proto/body_control.proto b/ateam-common-packets/proto/body_control.proto new file mode 100644 index 0000000..faa3990 --- /dev/null +++ b/ateam-common-packets/proto/body_control.proto @@ -0,0 +1,78 @@ +syntax = "proto3"; +package ateam; + +import "maneuvers.proto"; + +// Body controller extended telemetry. +// Maps to BodyControlExtendedTelemetry in body_control.h (raw: always 204 bytes). +message BodyControlExtendedTelemetry { + BodyControlMode body_control_mode = 1; + bool vision_update = 2; + + // Echo of the active maneuver command (fields 3-11 → 1-byte tags). + // Matches BodyControlManeuverExtendedTelemetry union in body_control.h. + oneof maneuver_echo { + GlobalPositionCommand global_pos = 3; + GlobalVelocityCommand global_vel = 4; + LocalVelocityCommand local_vel = 5; + GlobalAccelerationCommand global_acc = 6; + LocalAccelerationCommand local_acc = 7; + HeadingPivotCommand heading_pivot = 8; + PointPivotCommand point_pivot = 9; + HeadingLineCommand heading_line = 10; + PointLineCommand point_line = 11; + } + + // IMU data — imu_gyro[3] and imu_accel[3] from body_control.h. + // Fields 12-15 → 1-byte tags; 16-17 → 2-byte tags. + float imu_gyro_x = 12; + float imu_gyro_y = 13; + float imu_gyro_z = 14; + float imu_accel_x = 15; + float imu_accel_y = 16; + float imu_accel_z = 17; + + // Vision pose estimate — vision_pose[3]. + float vision_pose_x = 18; + float vision_pose_y = 19; + float vision_pose_theta = 20; + + // Trajectory setpoints — body_traj_pos[3] and body_traj_vel[3]. + float traj_pos_x = 21; + float traj_pos_y = 22; + float traj_pos_theta = 23; + float traj_vel_x = 24; + float traj_vel_y = 25; + float traj_vel_omega = 26; + + // Kalman filter predictions — kf_body_pos_prediction[3] and kf_body_vel_prediction[3]. + float kf_pred_pos_x = 27; + float kf_pred_pos_y = 28; + float kf_pred_pos_theta = 29; + float kf_pred_vel_x = 30; + float kf_pred_vel_y = 31; + float kf_pred_vel_omega = 32; + + // Kalman filter estimates — kf_body_pos_estimate[3] and kf_body_vel_estimate[3]. + float kf_est_pos_x = 33; + float kf_est_pos_y = 34; + float kf_est_pos_theta = 35; + float kf_est_vel_x = 36; + float kf_est_vel_y = 37; + float kf_est_vel_omega = 38; + + // Body velocity control outputs — body_vel_u[3]. + float body_vel_u_x = 39; + float body_vel_u_y = 40; + float body_vel_u_omega = 41; + + // Body acceleration control outputs — body_accel_u[3]. + float body_accel_u_x = 42; + float body_accel_u_y = 43; + float body_accel_u_omega = 44; + + // Friction-compensated acceleration — body_accel_u_fric_comp[3]. + float body_accel_u_fric_x = 45; + float body_accel_u_fric_y = 46; + float body_accel_u_fric_omega = 47; +} diff --git a/ateam-common-packets/proto/control.proto b/ateam-common-packets/proto/control.proto new file mode 100644 index 0000000..2ca1864 --- /dev/null +++ b/ateam-common-packets/proto/control.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; +package ateam; + +import "maneuvers.proto"; + +// Robot command packet sent from AI software to robot over the radio link. +// Maps to BasicControl in basic_control.h (raw: always 84 bytes). +// +// Body control mode is implied by the oneof arm that is set: +// no cmd arm, estop_brake = false → BCM_OFF +// no cmd arm, estop_brake = true → BCM_ESTOP_BRAKE +// global_pos set → BCM_GLOBAL_POSITION +// ... etc. +message BasicControl { + // Maneuver command — fields 1-9 use 1-byte wire tags. + oneof cmd { + GlobalPositionCommand global_pos = 1; + GlobalVelocityCommand global_vel = 2; + LocalVelocityCommand local_vel = 3; + GlobalAccelerationCommand global_acc = 4; + LocalAccelerationCommand local_acc = 5; + HeadingPivotCommand heading_pivot = 6; + PointPivotCommand point_pivot = 7; + HeadingLineCommand heading_line = 8; + PointLineCommand point_line = 9; + } + + // Frequently non-zero fields — fields 10-15 use 1-byte wire tags. + bool estop_brake = 10; + KickRequest kick_request = 11; + float kick_vel = 12; + DribblerMode dribbler_mode = 13; + float dribbler_setpoint = 14; + + // Infrequent control flags — fields 16+ use 2-byte wire tags. + bool request_shutdown = 16; + bool reboot_robot = 17; + bool game_state_in_stop = 18; + bool game_state_in_halt = 19; + bool emergency_stop = 20; + bool wheel_vel_control_enabled = 21; + bool wheel_torque_control_enabled = 22; + bool reset_controller = 23; + + // Vision pose update — valid only when vision_update_valid = true. + bool vision_update_valid = 24; + float vision_x = 25; + float vision_y = 26; + float vision_theta = 27; + + uint32 play_song = 28; +} diff --git a/ateam-common-packets/proto/diagnostics.proto b/ateam-common-packets/proto/diagnostics.proto new file mode 100644 index 0000000..e13eb39 --- /dev/null +++ b/ateam-common-packets/proto/diagnostics.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; +package ateam; + +// Error report sent from robot to AI software over the radio link. +// Maps to ErrorTelemetry in error_telemetry.h (raw: always 64 bytes). +// error_message is a null-terminated C string of at most 60 bytes. +message ErrorTelemetry { + uint32 timestamp = 1; + string error_message = 2; +} diff --git a/ateam-common-packets/proto/discovery.proto b/ateam-common-packets/proto/discovery.proto new file mode 100644 index 0000000..97aa0d7 --- /dev/null +++ b/ateam-common-packets/proto/discovery.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +package ateam; + +enum TeamColor { + TC_YELLOW = 0; + TC_BLUE = 1; +} + +// Sent by the AI software to identify and connect to a robot. +// Git hashes provide exact version identification at handshake time, +// replacing the need for a version field in individual packets. +// Maps to HelloRequest in discovery.h (raw: always 16 bytes). +message HelloRequest { + uint32 robot_id = 1; + TeamColor color = 2; + bool coms_repo_dirty = 3; + bool controls_repo_dirty = 4; + bool firmware_repo_dirty = 5; + // 4-byte git hashes; fixed32 preserves all bit patterns without varint distortion. + fixed32 coms_hash = 6; + fixed32 controls_hash = 7; + fixed32 firmware_hash = 8; +} + +// Sent by the robot in response to a HelloRequest. +// Maps to HelloResponse in discovery.h (raw: always 6 bytes). +message HelloResponse { + // Robot's IPv4 address, packed as 4 bytes big-endian. + fixed32 ipv4 = 1; + // UDP port. Valid range: 1–65535 (uint16 in C); values above 65535 are invalid. + uint32 port = 2; +} diff --git a/ateam-common-packets/proto/kicker.proto b/ateam-common-packets/proto/kicker.proto new file mode 100644 index 0000000..822fc2e --- /dev/null +++ b/ateam-common-packets/proto/kicker.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; +package ateam; + +import "ateam_options.proto"; +import "motor.proto"; + +// Bitmask constants for KickerTelemetry.status_flags (7-bit bitfield in C). +// Test a bit: (status_flags & KickerStatusFlag.X) != 0 +enum KickerStatusFlag { + KICKER_STATUS_NONE = 0; + KICKER_STATUS_ERROR_DETECTED = 1; // bit 0 + KICKER_STATUS_DRIBBLER_ERROR = 2; // bit 1 + KICKER_STATUS_POWER_DOWN_REQUESTED = 4; // bit 2 + KICKER_STATUS_POWER_DOWN_COMPLETE = 8; // bit 3 + KICKER_STATUS_BALL_DETECTED = 16; // bit 4 + KICKER_STATUS_CHARGE_FULL = 32; // bit 5 + KICKER_STATUS_DRIBBLER_FW_LOADED = 64; // bit 6 +} + +// Kicker board telemetry. +// Maps to KickerTelemetry in kicker.h (raw: always 76 bytes). +// status_flags is a bitmask — see KickerStatusFlag. +message KickerTelemetry { + uint32 status_flags = 1 [(ateam.bitmask).flags_enum = "KickerStatusFlag"]; + uint32 charge_pct = 2; + float rail_voltage = 3; + float battery_voltage = 4; + // 4-byte firmware hash; fixed32 preserves all bit patterns without varint distortion. + fixed32 kicker_image_hash = 5; + CcmTelemetry dribbler_motor = 6; +} diff --git a/ateam-common-packets/proto/maneuvers.proto b/ateam-common-packets/proto/maneuvers.proto new file mode 100644 index 0000000..297dbfc --- /dev/null +++ b/ateam-common-packets/proto/maneuvers.proto @@ -0,0 +1,148 @@ +syntax = "proto3"; +package ateam; + +// Dribbler operating mode (maps to DribblerCommand in kicker.h). +enum DribblerMode { + DM_DISABLE = 0; + DM_HARD_RECEIVE = 1; + DM_SOFT_RECEIVE = 2; + DM_DRIBBLE = 3; + DM_VELOCITY = 4; + DM_CURRENT = 5; +} + +// Kick action requested by the AI. +enum KickRequest { + KR_ARM = 0; + KR_DISABLE = 1; + KR_KICK_NOW = 2; + KR_KICK_TOUCH = 3; + KR_KICK_CAPTURED = 4; + KR_CHIP_NOW = 5; + KR_CHIP_TOUCH = 6; + KR_CHIP_CAPTURED = 7; +} + +// Body controller operating mode (maps to BodyControlMode in basic_control.h). +// Non-contiguous values match the C enum exactly so translations are a direct cast. +enum BodyControlMode { + BCM_OFF = 0; + BCM_ESTOP_BRAKE = 1; + BCM_GLOBAL_POSITION = 10; + BCM_GLOBAL_VELOCITY = 11; + BCM_LOCAL_VELOCITY = 12; + BCM_GLOBAL_ACCEL = 13; + BCM_LOCAL_ACCEL = 14; + BCM_HEADING_PIVOT = 20; + BCM_POINT_PIVOT = 21; + BCM_HEADING_LINE = 30; + BCM_POINT_LINE = 31; +} + +// ----- Maneuver command messages ----- + +message GlobalPositionCommand { + float global_x = 1; + float global_y = 2; + float global_theta = 3; + float max_linear_vel = 4; + float max_angular_vel = 5; + float max_linear_acc = 6; + float max_angular_acc = 7; +} + +message GlobalVelocityCommand { + float global_xd = 1; + float global_yd = 2; + float global_omega = 3; + float max_linear_acc = 4; + float max_angular_acc = 5; +} + +message LocalVelocityCommand { + float local_xd = 1; + float local_yd = 2; + float local_omega = 3; + float max_linear_acc = 4; + float max_angular_acc = 5; +} + +message GlobalAccelerationCommand { + float global_xdd = 1; + float global_ydd = 2; + float global_alpha = 3; +} + +message LocalAccelerationCommand { + float local_xdd = 1; + float local_ydd = 2; + float local_alpha = 3; +} + +message HeadingPivotCommand { + float global_theta = 1; + float max_angular_vel = 2; + float max_angular_acc = 3; + float orbit_radius = 4; + float inset_angle = 5; + // 0 = forward, 1 = backward (maps to PivotDirection enum in pivot.h). + uint32 direction = 6; + bool compute_inset_angle = 7; +} + +message PointPivotCommand { + float target_x = 1; + float target_y = 2; + float max_angular_vel = 3; + float max_angular_acc = 4; + float orbit_radius = 5; + float inset_angle = 6; + uint32 direction = 7; + bool compute_inset_angle = 8; +} + +message HeadingLineCommand { + float start_x = 1; + float start_y = 2; + float dir_x = 3; + float dir_y = 4; + float line_velocity = 5; + float global_theta = 6; + float max_vel_colinear = 7; + float max_vel_perp = 8; + float max_vel_angular = 9; + float max_accel_colinear = 10; + float max_accel_perp = 11; + float max_accel_angular = 12; + float colinear_start_thresh = 13; +} + +message PointLineCommand { + float start_x = 1; + float start_y = 2; + float dir_x = 3; + float dir_y = 4; + float line_velocity = 5; + float target_x = 6; + float target_y = 7; + float max_vel_colinear = 8; + float max_vel_perp = 9; + float max_vel_angular = 10; + float max_accel_colinear = 11; + float max_accel_perp = 12; + float max_accel_angular = 13; + float colinear_start_thresh = 14; +} + +// ----- Maneuver telemetry messages ----- +// Maps to BodyControlTelemetry union in basic_telemetry.h. +// All variants are currently reserved (no payload bytes defined in C); fields +// will be added here as the firmware populates them. + +message GlobalPositionTelemetry {} +message GlobalVelocityTelemetry {} +message LocalVelocityTelemetry {} +message GlobalAccelerationTelemetry {} +message LocalAccelerationTelemetry {} +message HeadingPivotTelemetry {} +message PointPivotTelemetry {} diff --git a/ateam-common-packets/proto/motor.proto b/ateam-common-packets/proto/motor.proto new file mode 100644 index 0000000..595a1dc --- /dev/null +++ b/ateam-common-packets/proto/motor.proto @@ -0,0 +1,65 @@ +syntax = "proto3"; +package ateam; + +import "ateam_options.proto"; + +// Bitmask constants for CcmTelemetry.error_flags (16-bit bitfield in C). +// Test a bit: (error_flags & CcmErrorFlag.X) != 0 +enum CcmErrorFlag { + CCM_ERR_NONE = 0; + CCM_ERR_MASTER_ERROR = 1; // bit 0 + CCM_ERR_HALL_POWER = 2; // bit 1 + CCM_ERR_HALL_DISCONNECTED = 4; // bit 2 + CCM_ERR_BLDC_TRANSITION = 8; // bit 3 + CCM_ERR_BLDC_COMMUTATION_WATCHDOG = 16; // bit 4 + CCM_ERR_ENC_DISCONNECTED = 32; // bit 5 + CCM_ERR_OVERCURRENT = 64; // bit 6 + CCM_ERR_UNDERVOLTAGE = 128; // bit 7 + CCM_ERR_OVERVOLTAGE = 256; // bit 8 + CCM_ERR_TORQUE_LIMITED = 512; // bit 9 + CCM_ERR_CONTROL_LOOP_TIME = 1024; // bit 10 + CCM_ERR_RESET_WATCHDOG_INDEPENDENT = 2048; // bit 11 + CCM_ERR_RESET_WATCHDOG_WINDOW = 4096; // bit 12 + CCM_ERR_RESET_LOW_POWER = 8192; // bit 13 + CCM_ERR_RESET_SOFTWARE = 16384; // bit 14 + CCM_ERR_RESET_PIN = 32768; // bit 15 +} + +// Motor closed-loop control mode (maps to CcmMotionControlType in stspin_current.h). +enum CcmMotionControlType { + CCM_MCT_MOTOR_OFF = 0; + CCM_MCT_DUTY_OPENLOOP = 1; + CCM_MCT_VOLTAGE_OPENLOOP = 2; + CCM_MCT_CURRENT = 3; + CCM_MCT_VELOCITY = 4; + CCM_MCT_VELOCITY_CURRENT = 5; +} + +// Per-motor current ADC samples and closed-loop state. +// Maps to CcmCurrentTelemetry in stspin_current.h (raw: always 48 bytes). +message CcmCurrentTelemetry { + uint32 bus_voltage_mv = 1; + uint32 motor_voltage_cmd_mv = 2; + sint32 current_setpoint_ma = 3; + sint32 hall_vel_est_drads = 4; + // 20 ADC current samples (C: uint16_t[20], max_len enforced in build.rs). + repeated uint32 current_samples_ma = 5; +} + +// Per-motor velocity closed-loop state. +// Maps to CcmVelocityTelemetry in stspin_current.h (raw: always 8 bytes). +message CcmVelocityTelemetry { + float vel_setpoint_rads = 1; + float wheel_vel_rads = 2; +} + +// Full per-motor telemetry. +// Maps to CcmTelemetry in stspin_current.h (raw: always 60 bytes). +// error_flags is a bitmask — see CcmErrorFlag for bit definitions. +message CcmTelemetry { + uint32 error_flags = 1 [(ateam.bitmask).flags_enum = "CcmErrorFlag"]; + CcmMotionControlType motion_control_type = 2; + uint32 gain_stage_index = 3; + CcmCurrentTelemetry current_telem = 4; + CcmVelocityTelemetry velocity_telem = 5; +} diff --git a/ateam-common-packets/proto/power.proto b/ateam-common-packets/proto/power.proto new file mode 100644 index 0000000..41fe439 --- /dev/null +++ b/ateam-common-packets/proto/power.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; +package ateam; + +import "ateam_options.proto"; + +// Bitmask constants for PowerTelemetry.status_flags (6-bit bitfield in C). +// Test a bit: (status_flags & PowerStatusFlag.X) != 0 +enum PowerStatusFlag { + POWER_STATUS_NONE = 0; + POWER_STATUS_OK = 1; // bit 0: power_ok + POWER_STATUS_RAIL_3V3_OK = 2; // bit 1 + POWER_STATUS_RAIL_5V0_OK = 4; // bit 2 + POWER_STATUS_RAIL_12V0_OK = 8; // bit 3 + POWER_STATUS_HIGH_CURRENT_ALLOWED = 16; // bit 4 + POWER_STATUS_SHUTDOWN_REQUESTED = 32; // bit 5 +} + +// Bitmask constants for BatteryInfo.status_flags (7-bit bitfield in C). +// Test a bit: (status_flags & BatteryStatusFlag.X) != 0 +enum BatteryStatusFlag { + BATTERY_STATUS_NONE = 0; + BATTERY_STATUS_OK = 1; // bit 0 + BATTERY_STATUS_BALANCE_CONNECTED = 2; // bit 1 + BATTERY_STATUS_LOW = 4; // bit 2 + BATTERY_STATUS_CRITICAL = 8; // bit 3 + BATTERY_STATUS_CELL_LOW = 16; // bit 4 + BATTERY_STATUS_CELL_CRITICAL = 32; // bit 5 + BATTERY_STATUS_CELL_IMBALANCE_WARN = 64; // bit 6 +} + +// 6S battery cell and pack telemetry. +// Maps to BatteryInfo in power.h (raw: always 24 bytes). +// status_flags is a bitmask — see BatteryStatusFlag. +message BatteryInfo { + uint32 status_flags = 1 [(ateam.bitmask).flags_enum = "BatteryStatusFlag"]; + uint32 battery_mv = 2; + uint32 cell1_mv = 3; + uint32 cell2_mv = 4; + uint32 cell3_mv = 5; + uint32 cell4_mv = 6; + uint32 cell5_mv = 7; + uint32 cell6_mv = 8; + uint32 battery_pct = 9; + uint32 cell1_pct = 10; + uint32 cell2_pct = 11; + uint32 cell3_pct = 12; + uint32 cell4_pct = 13; + uint32 cell5_pct = 14; + uint32 cell6_pct = 15; +} + +// Power board telemetry. +// Maps to PowerTelemetry in power.h (raw: always 28 bytes). +// status_flags is a bitmask — see PowerStatusFlag. +message PowerTelemetry { + uint32 status_flags = 1 [(ateam.bitmask).flags_enum = "PowerStatusFlag"]; + BatteryInfo battery_info = 2; +} diff --git a/ateam-common-packets/proto/radio.proto b/ateam-common-packets/proto/radio.proto new file mode 100644 index 0000000..b2bd457 --- /dev/null +++ b/ateam-common-packets/proto/radio.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; +package ateam; + +import "control.proto"; +import "diagnostics.proto"; +import "discovery.proto"; +import "robot_parameters.proto"; +import "telemetry.proto"; + +// Top-level radio packet. +// +// The proto wire tag of the set oneof arm replaces the legacy CommandCode byte. +// Field numbers intentionally match the old CC_* values so migration docs +// can cross-reference directly (CC_CONTROL=61 → field 61 here, etc.). +// +// Wire framing: CRC32 || varint(len) || RadioPacket bytes. +// CRC and length are transport-layer concerns; they are NOT proto fields. +// +// Omitted CC_* values (intentional): +// CC_ACK=1, CC_NACK=2, CC_GOODBYE=3 — transport-layer signals with no +// payload; handled by the framing layer, not the proto schema. +message RadioPacket { + // Field numbers 1–3 are permanently reserved: they matched CC_ACK, CC_NACK, + // and CC_GOODBYE in the old C enum, which are transport-layer signals with no + // payload and are handled by the framing layer rather than the proto schema. + reserved 1, 2, 3; + + oneof payload { + // Session management (bidirectional) + Keepalive keepalive = 4; // CC_KEEPALIVE + + // Discovery (CC_HELLO_REQ=21, CC_HELLO_RESP=22) + HelloRequest hello_request = 21; + HelloResponse hello_response = 22; + + // Robot → software telemetry + BasicTelemetry telemetry = 41; // CC_TELEMETRY + ExtendedTelemetry extended_telemetry = 42; // CC_CONTROL_DEBUG_TELEMETRY + ParameterCommand parameter_command = 43; // CC_ROBOT_PARAMETER_COMMAND + ErrorTelemetry error_telemetry = 44; // CC_ERROR_TELEMETRY + + // Software → robot + BasicControl control = 61; // CC_CONTROL + } +} + +// No-payload heartbeat. Sent periodically to keep the radio link alive. +message Keepalive {} diff --git a/ateam-common-packets/proto/robot_parameters.proto b/ateam-common-packets/proto/robot_parameters.proto new file mode 100644 index 0000000..2b1ec2f --- /dev/null +++ b/ateam-common-packets/proto/robot_parameters.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; +package ateam; + +// Read/write protocol for runtime-tunable robot parameters (CC_ROBOT_PARAMETER_COMMAND=43). +// Commands flow software→robot; responses flow robot→software on the same channel. +// +// Protocol: +// READ: command_code=PCC_READ, parameter_name=X, data empty +// WRITE: command_code=PCC_WRITE, parameter_name=X, data= +// ACK: command_code=PCC_ACK, parameter_name=echo, data= +// NACK: command_code=PCC_NACK_*, parameter_name=echo, data empty +// +// Maps to ParameterCommand in robot_parameters.h (raw: always 28 bytes). +// The C struct's ParameterDataFormat field is omitted — data element count is +// determined by the parameter name (see ParameterName comments). +message ParameterCommand { + ParameterCommandCode command_code = 1; + ParameterName parameter_name = 2; + // float values for WRITE and ACK; empty for READ and NACK. + // Count is implied by parameter_name (1–6 floats; see ParameterName below). + repeated float data = 3; +} + +// Command/response discriminant. +// Maps to ParameterCommandCode in robot_parameters.h. +enum ParameterCommandCode { + PCC_READ = 0; + PCC_WRITE = 1; + PCC_ACK = 2; + PCC_NACK_INVALID_NAME = 3; + PCC_NACK_INVALID_TYPE_FOR_NAME = 4; +} + +// Identifies which tunable parameter is being addressed. +// Comment on each value lists the expected data elements in order. +// Maps to ParameterName in robot_parameters.h. +enum ParameterName { + PN_KF_PROCESS_STD = 0; // [POS_LINEAR, POS_ANGULAR, VEL_LINEAR, VEL_ANGULAR] + PN_KF_MEASUREMENT_STD = 1; // [VISION_STD_LINEAR, VISION_STD_ANGULAR, ENCODER_STD_ANGULAR, GYRO_STD_ANGULAR] + PN_KF_MAX_STATE = 2; // [KF_MAX_POS_LINEAR, KF_MAX_POS_ANGULAR, KF_MAX_VEL_LINEAR, KF_MAX_VEL_ANGULAR] + PN_PHYS_WHEEL = 3; // [WHEEL_ANGLE_ALPHA, WHEEL_ANGLE_BETA, WHEEL_DISTANCE, WHEEL_RADIUS] + PN_PHYS_INERTIA = 4; // [BODY_MASS, BODY_MOMENT_Z] + PN_PHYS_MOTOR_MODEL = 5; // [MOTOR_TORQUE_CONSTANT, MOTOR_EFFICIENCY_FACTOR] + PN_PHYS_FRICTION_MODEL = 6; // [COULOMB_LINEAR_X, COULOMB_LINEAR_Y, COULOMB_ANGULAR, VISCOUS_LINEAR_X, VISCOUS_LINEAR_Y, VISCOUS_ANGULAR] + PN_FRICTION_COMP_GATING = 7; // [LINEAR_VEL_THRESHOLD, LINEAR_ACCEL_THRESHOLD, ANGULAR_VEL_THRESHOLD, ANGULAR_ACCEL_THRESHOLD] + PN_POSE_CONTROL_GAIN = 8; // [FEEDFORWARD_GAIN, FEEDBACK_GAIN] + PN_TRAJ_RECOMPUTE_ERROR = 9; // [ERROR_POS_LINEAR, ERROR_POS_ANGULAR, ERROR_VEL_LINEAR, ERROR_VEL_ANGULAR] + PN_POSE_FB_PIDII_LINEAR = 10; // [P, I, D, I_MIN, I_MAX] + PN_POSE_FB_PIDII_ANGULAR = 11; // [P, I, D, I_MIN, I_MAX] + PN_TWIST_FB_PIDII_LINEAR = 12; // [P, I, D, I_MIN, I_MAX] + PN_TWIST_FB_PIDII_ANGULAR = 13; // [P, I, D, I_MIN, I_MAX] +} diff --git a/ateam-common-packets/proto/telemetry.proto b/ateam-common-packets/proto/telemetry.proto new file mode 100644 index 0000000..dfdc7ac --- /dev/null +++ b/ateam-common-packets/proto/telemetry.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; +package ateam; + +import "ateam_options.proto"; +import "maneuvers.proto"; +import "motor.proto"; +import "power.proto"; +import "kicker.proto"; +import "body_control.proto"; + +// Bitmask constants for BasicTelemetry.status_flags (27-bit bitfield in C). +// Test a bit: (status_flags & BasicTelemetryFlag.X) != 0 +// Note: bits 24-25 are availability flags (not errors) but share the same bitfield. +enum BasicTelemetryFlag { + BTEL_FLAG_NONE = 0; + BTEL_FLAG_POWER_ERROR = 1; // bit 0 + BTEL_FLAG_POWER_BOARD_ERROR = 2; // bit 1 + BTEL_FLAG_BATTERY_ERROR = 4; // bit 2 + BTEL_FLAG_BATTERY_LOW = 8; // bit 3 + BTEL_FLAG_BATTERY_CRIT = 16; // bit 4 + BTEL_FLAG_SHUTDOWN_PENDING = 32; // bit 5 + BTEL_FLAG_TIPPED_ERROR = 64; // bit 6 + BTEL_FLAG_BREAKBEAM_ERROR = 128; // bit 7 + BTEL_FLAG_BREAKBEAM_BALL_DETECTED = 256; // bit 8 + BTEL_FLAG_ACCEL_0_ERROR = 512; // bit 9 + BTEL_FLAG_ACCEL_1_ERROR = 1024; // bit 10 + BTEL_FLAG_GYRO_0_ERROR = 2048; // bit 11 + BTEL_FLAG_GYRO_1_ERROR = 4096; // bit 12 + BTEL_FLAG_MOTOR_FL_GENERAL = 8192; // bit 13 + BTEL_FLAG_MOTOR_FL_HALL = 16384; // bit 14 + BTEL_FLAG_MOTOR_BL_GENERAL = 32768; // bit 15 + BTEL_FLAG_MOTOR_BL_HALL = 65536; // bit 16 + BTEL_FLAG_MOTOR_BR_GENERAL = 131072; // bit 17 + BTEL_FLAG_MOTOR_BR_HALL = 262144; // bit 18 + BTEL_FLAG_MOTOR_FR_GENERAL = 524288; // bit 19 + BTEL_FLAG_MOTOR_FR_HALL = 1048576; // bit 20 + BTEL_FLAG_MOTOR_DRIB_GENERAL = 2097152; // bit 21 + BTEL_FLAG_MOTOR_DRIB_HALL = 4194304; // bit 22 + BTEL_FLAG_KICKER_BOARD_ERROR = 8388608; // bit 23 + BTEL_FLAG_CHIPPER_AVAILABLE = 16777216; // bit 24 + BTEL_FLAG_KICKER_AVAILABLE = 33554432; // bit 25 + BTEL_FLAG_CONTROLLER_RESET = 67108864; // bit 26 +} + +// Robot status packet sent from robot to AI software over the radio link. +// Maps to BasicTelemetry in basic_telemetry.h (raw: always 28 bytes). +// status_flags is a bitmask — see BasicTelemetryFlag for bit definitions. +message BasicTelemetry { + uint32 tx_seq_num = 1; + uint32 ctrl_seq_num = 2; + BodyControlMode body_control_mode = 3; + // 27-bit error/status bitfield; zero when all clear (common case → not encoded). + uint32 status_flags = 4 [(ateam.bitmask).flags_enum = "BasicTelemetryFlag"]; + uint32 battery_percent = 5; + uint32 kicker_charge_percent = 6; + // Quantized KF state (sint32 for efficient zigzag encoding of small signed values). + sint32 kf_pos_x = 7; // mm, 1 LSB = 1 mm + sint32 kf_pos_y = 8; // mm + sint32 kf_pos_theta = 9; // mrad, 1 LSB = 1 mrad + sint32 kf_vel_x = 10; // mm/s, 1 LSB = 1 mm/s + sint32 kf_vel_y = 11; // mm/s + sint32 kf_vel_omega = 12; // mrad/s, 1 LSB = 1 mrad/s + // Mode-specific telemetry payload (maps to BodyControlTelemetry union in basic_telemetry.h). + // Which arm is set is redundant with body_control_mode (field 3); both are present to + // match the C struct layout exactly. + oneof control_telem { + GlobalPositionTelemetry global_pos = 13; + GlobalVelocityTelemetry global_vel = 14; + LocalVelocityTelemetry local_vel = 15; + GlobalAccelerationTelemetry global_acc = 16; + LocalAccelerationTelemetry local_acc = 17; + HeadingPivotTelemetry heading_pivot = 18; + PointPivotTelemetry point_pivot = 19; + } +} + +// Full extended telemetry packet sent from robot to AI software. +// Maps to ExtendedTelemetry in extended_telemetry.h (raw: always 556 bytes). +message ExtendedTelemetry { + // 64-bit timestamp split to avoid uint64 on no_std targets. + uint32 timestamp_us_lo = 1; + uint32 timestamp_us_hi = 2; + PowerTelemetry power_status = 3; + CcmTelemetry front_left_motor = 4; + CcmTelemetry back_left_motor = 5; + CcmTelemetry back_right_motor = 6; + CcmTelemetry front_right_motor = 7; + BodyControlExtendedTelemetry body_control_telem = 8; + KickerTelemetry kicker_status = 9; +} diff --git a/ateam-common-packets/rust-lib/Cargo.lock b/ateam-common-packets/rust-lib/Cargo.lock index 55078b2..c83e964 100644 --- a/ateam-common-packets/rust-lib/Cargo.lock +++ b/ateam-common-packets/rust-lib/Cargo.lock @@ -25,6 +25,9 @@ name = "ateam-common-packets" version = "1.0.0" dependencies = [ "bindgen", + "heapless", + "micropb", + "micropb-gen", "nalgebra", "which", ] @@ -81,6 +84,12 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cexpr" version = "0.6.0" @@ -116,7 +125,7 @@ dependencies = [ "atty", "bitflags 1.3.2", "clap_lex", - "indexmap", + "indexmap 1.9.3", "strsim", "termcolor", "textwrap", @@ -131,6 +140,15 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "either" version = "1.16.0" @@ -150,6 +168,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -160,6 +184,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "glam" version = "0.14.0" @@ -328,12 +369,37 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "hermit-abi" version = "0.1.19" @@ -365,7 +431,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", ] [[package]] @@ -408,6 +484,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.30" @@ -420,6 +502,34 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micropb" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1bfc6ec8daaec9373b02386de3f58b46c578122c48324bbee23bef86afd5240" +dependencies = [ + "heapless", + "num-traits", +] + +[[package]] +name = "micropb-gen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3523f12fb45cd353092dafdce2e0c5e05b9e05bee804b5fe228d332ff3c96d" +dependencies = [ + "convert_case", + "micropb", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "serde", + "syn", + "tempfile", + "toml", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -542,6 +652,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -560,6 +680,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.12.3" @@ -604,10 +730,62 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" @@ -626,6 +804,12 @@ dependencies = [ "paste", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.10.0" @@ -643,6 +827,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -658,6 +855,45 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "typenum" version = "1.20.0" @@ -670,6 +906,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "which" version = "4.4.2" @@ -679,7 +921,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -800,3 +1042,15 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" diff --git a/ateam-common-packets/rust-lib/Cargo.toml b/ateam-common-packets/rust-lib/Cargo.toml index cc38f08..34e6ebf 100644 --- a/ateam-common-packets/rust-lib/Cargo.toml +++ b/ateam-common-packets/rust-lib/Cargo.toml @@ -12,6 +12,9 @@ path = "src/lib.rs" [build-dependencies] bindgen = "0.60.1" which = "4.4.0" +micropb-gen = "0.6" [dependencies] nalgebra = { version = "0.34.0", default-features = false, features = ["libm", "macros"] } +micropb = { version = "0.6", default-features = false, features = ["encode", "decode", "container-heapless-0-9"] } +heapless = "0.9" diff --git a/ateam-common-packets/rust-lib/build.rs b/ateam-common-packets/rust-lib/build.rs index 935b3e2..f2c44c2 100644 --- a/ateam-common-packets/rust-lib/build.rs +++ b/ateam-common-packets/rust-lib/build.rs @@ -7,6 +7,8 @@ use bindgen::EnumVariation; extern crate which; use which::which; +extern crate micropb_gen; + fn is_arm_none_eabi_sysroot_valid( sysroot: impl AsRef + std::convert::AsRef, ) -> bool { @@ -98,7 +100,7 @@ fn main() { let bindings = create_configured_builder() // The input header we would like to generate // bindings for. - .header("../include/radio.h") + .header("../include/radio/radio.h") .derive_default(true) .generate() // Unwrap the Result and panic on failure. @@ -122,4 +124,46 @@ fn main() { robot_metadata_bindings .write_to_file(Path::new(&out_dir).join("metadata_bindings.rs")) .expect("Couldn't write robot metadata bindings!"); + + // Proto compilation via micropb-gen. + println!("cargo:rerun-if-changed=../proto/"); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let proto_dir = manifest_dir.join("../proto"); + let proto_out = manifest_dir.join("src/proto_packets_gen.rs"); + + let mut gen = micropb_gen::Generator::new(); + gen.add_protoc_arg(format!("-I{}", proto_dir.to_str().expect("non-UTF8 proto dir"))); + // Use heapless containers for repeated fields (current_samples_ma in CcmCurrentTelemetry). + gen.use_container_heapless(); + // Package prefix required in path since files use `package ateam;`. + gen.configure( + ".ateam.CcmCurrentTelemetry.current_samples_ma", + micropb_gen::Config::new().max_len(20u32), + ); + gen.configure( + ".ateam.ErrorTelemetry.error_message", + micropb_gen::Config::new().max_bytes(60u32), + ); + // ParameterCommand.data: up to 6 floats (VEC6 is the largest parameter format). + gen.configure( + ".ateam.ParameterCommand.data", + micropb_gen::Config::new().max_len(6u32), + ); + gen.compile_protos( + &[ + proto_dir.join("maneuvers.proto"), + proto_dir.join("motor.proto"), + proto_dir.join("power.proto"), + proto_dir.join("kicker.proto"), + proto_dir.join("body_control.proto"), + proto_dir.join("control.proto"), + proto_dir.join("telemetry.proto"), + proto_dir.join("discovery.proto"), + proto_dir.join("diagnostics.proto"), + proto_dir.join("robot_parameters.proto"), + proto_dir.join("radio.proto"), + ], + proto_out, + ) + .expect("micropb-gen failed to compile proto"); } diff --git a/ateam-common-packets/rust-lib/examples/proto_size_comparison.rs b/ateam-common-packets/rust-lib/examples/proto_size_comparison.rs new file mode 100644 index 0000000..fb1e9ce --- /dev/null +++ b/ateam-common-packets/rust-lib/examples/proto_size_comparison.rs @@ -0,0 +1,429 @@ +// Wire-size comparison: C packed structs vs micropb-encoded protobuf. +// +// Raw sizes are always fixed (sizeof the C struct). +// Proto sizes are computed via MessageEncode::compute_size(), which gives the +// exact byte count of the encoded message without actually allocating a buffer. + +use ateam_common_packets::proto_packets::ateam_::{ + BasicControl, BasicControl_, BasicTelemetry, LocalVelocityCommand, GlobalVelocityCommand, + GlobalPositionCommand, PointLineCommand, KickRequest, DribblerMode, + ExtendedTelemetry, PowerTelemetry, BatteryInfo, CcmTelemetry, + CcmCurrentTelemetry, CcmVelocityTelemetry, BodyControlExtendedTelemetry, + BodyControlExtendedTelemetry_, KickerTelemetry, + BodyControlMode, CcmMotionControlType, +}; +use micropb::{MessageEncode, PbEncoder}; + +fn main() { + // ---- Raw struct sizes (from C headers, same on ARM and x86_64 for these fixed-width types) ---- + let raw_basic_control: usize = core::mem::size_of::(); + let raw_basic_telemetry: usize = core::mem::size_of::(); + let raw_extended_telemetry: usize = core::mem::size_of::(); + + println!("=== Wire Size Comparison ==="); + println!("Raw C struct sizes (always fixed):"); + println!(" BasicControl: {} bytes", raw_basic_control); + println!(" BasicTelemetry: {} bytes", raw_basic_telemetry); + println!(" ExtendedTelemetry:{} bytes", raw_extended_telemetry); + println!(); + + // ---- BasicControl scenarios ---- + println!("=== BasicControl (raw: {} bytes) ===", raw_basic_control); + + // Scenario 1: BCM_OFF — no cmd, no flags (robot idling). + let ctrl_off = BasicControl::default(); + print_control_row("BCM_OFF (empty/idle)", ctrl_off, raw_basic_control); + + // Scenario 2: BCM_ESTOP_BRAKE — just the brake flag. + let ctrl_estop = BasicControl { + estop_brake: true, + ..Default::default() + }; + print_control_row("BCM_ESTOP_BRAKE", ctrl_estop, raw_basic_control); + + // Scenario 3: BCM_LOCAL_VELOCITY — typical motion command, no kick/dribble. + let ctrl_local_vel = BasicControl { + cmd: Some(BasicControl_::Cmd::LocalVel(LocalVelocityCommand { + local_xd: 0.5, + local_yd: 0.0, + local_omega: 0.3, + max_linear_acc: 3.0, + max_angular_acc: 5.0, + })), + ..Default::default() + }; + print_control_row("BCM_LOCAL_VELOCITY (no kick/drib)", ctrl_local_vel, raw_basic_control); + + // Scenario 4: BCM_LOCAL_VELOCITY + active kicking + dribbler. + let ctrl_kick = BasicControl { + cmd: Some(BasicControl_::Cmd::LocalVel(LocalVelocityCommand { + local_xd: 0.5, + local_yd: 0.0, + local_omega: 0.1, + max_linear_acc: 3.0, + max_angular_acc: 5.0, + })), + kick_request: KickRequest::KrKickNow as _, + kick_vel: 4.5, + dribbler_mode: DribblerMode::DmDribble as _, + dribbler_setpoint: 100.0, + ..Default::default() + }; + print_control_row("BCM_LOCAL_VELOCITY + kick + dribbler", ctrl_kick, raw_basic_control); + + // Scenario 5: BCM_GLOBAL_VELOCITY with vision update. + let ctrl_vision = BasicControl { + cmd: Some(BasicControl_::Cmd::GlobalVel(GlobalVelocityCommand { + global_xd: 1.0, + global_yd: 0.5, + global_omega: 0.2, + max_linear_acc: 2.0, + max_angular_acc: 4.0, + })), + vision_update_valid: true, + vision_x: 3.14, + vision_y: 2.71, + vision_theta: 1.57, + ..Default::default() + }; + print_control_row("BCM_GLOBAL_VELOCITY + vision update", ctrl_vision, raw_basic_control); + + // Scenario 6: BCM_GLOBAL_POSITION (7-float maneuver, common in AI software). + let ctrl_pos = BasicControl { + cmd: Some(BasicControl_::Cmd::GlobalPos(GlobalPositionCommand { + global_x: 1.0, + global_y: 2.0, + global_theta: 0.78, + max_linear_vel: 2.0, + max_angular_vel: 6.0, + max_linear_acc: 3.0, + max_angular_acc: 8.0, + })), + ..Default::default() + }; + print_control_row("BCM_GLOBAL_POSITION", ctrl_pos, raw_basic_control); + + // Scenario 7: BCM_POINT_LINE — largest maneuver (14 floats), all fields set (worst case). + let ctrl_pointline_full = BasicControl { + cmd: Some(BasicControl_::Cmd::PointLine(PointLineCommand { + start_x: 0.1, + start_y: 0.2, + dir_x: 1.0, + dir_y: 0.0, + line_velocity: 1.5, + target_x: 3.0, + target_y: 2.0, + max_vel_colinear: 2.0, + max_vel_perp: 1.0, + max_vel_angular: 6.0, + max_accel_colinear: 3.0, + max_accel_perp: 2.0, + max_accel_angular: 8.0, + colinear_start_thresh: 0.05, + })), + kick_request: KickRequest::KrKickNow as _, + kick_vel: 5.0, + dribbler_mode: DribblerMode::DmDribble as _, + dribbler_setpoint: 100.0, + request_shutdown: false, + game_state_in_stop: true, + emergency_stop: false, + wheel_vel_control_enabled: true, + ..Default::default() + }; + print_control_row("BCM_POINT_LINE (14-float cmd, active kick, 2 flags)", ctrl_pointline_full, raw_basic_control); + + // Scenario 8: All flags set + BCM_POINT_LINE + vision = absolute worst case. + let ctrl_worst = BasicControl { + cmd: Some(BasicControl_::Cmd::PointLine(PointLineCommand { + start_x: 0.1, start_y: 0.2, dir_x: 1.0, dir_y: 0.0, + line_velocity: 1.5, target_x: 3.0, target_y: 2.0, + max_vel_colinear: 2.0, max_vel_perp: 1.0, max_vel_angular: 6.0, + max_accel_colinear: 3.0, max_accel_perp: 2.0, max_accel_angular: 8.0, + colinear_start_thresh: 0.05, + })), + estop_brake: true, + kick_request: KickRequest::KrKickNow as _, + kick_vel: 5.0, + dribbler_mode: DribblerMode::DmDribble as _, + dribbler_setpoint: 100.0, + request_shutdown: true, + reboot_robot: true, + game_state_in_stop: true, + game_state_in_halt: true, + emergency_stop: true, + wheel_vel_control_enabled: true, + wheel_torque_control_enabled: true, + reset_controller: true, + vision_update_valid: true, + vision_x: 1.0, vision_y: 2.0, vision_theta: 0.5, + play_song: 1, + }; + print_control_row("Worst case (all flags, point_line, vision)", ctrl_worst, raw_basic_control); + + println!(); + + // ---- BasicTelemetry scenarios ---- + // status_flags is a bitmask — see BasicTelemetryFlag in telemetry.proto for bit definitions. + // bits 0-22: error/status bits; bit 23: kicker_board_error; bits 24-25: availability flags; + // bit 26: controller_reset. + println!("=== BasicTelemetry (raw: {} bytes) ===", raw_basic_telemetry); + + // Scenario 1: Minimal — BCM_OFF, no state (e.g., robot just powered on). + let telem_minimal = BasicTelemetry { + tx_seq_num: 1, + ..Default::default() + }; + print_telemetry_row("Minimal (BCM_OFF, no state)", telem_minimal, raw_basic_telemetry); + + // Scenario 2: Nominal — active robot, no errors, KF state populated. + // bits 24+25 set = CHIPPER_AVAILABLE | KICKER_AVAILABLE = 0x03000000 + let telem_nominal = BasicTelemetry { + tx_seq_num: 42, + ctrl_seq_num: 41, + body_control_mode: BodyControlMode(12), // BCM_LOCAL_VELOCITY + status_flags: 0x03000000, // chipper_available | kicker_available + battery_percent: 7500, + kicker_charge_percent: 9000, + kf_pos_x: 1500, // 1.5 m + kf_pos_y: -500, // -0.5 m + kf_pos_theta: 1000, // ~1 rad + kf_vel_x: 500, // 0.5 m/s + kf_vel_y: -200, // -0.2 m/s + kf_vel_omega: 100, // 0.1 rad/s + }; + print_telemetry_row("Nominal (BCM_LOCAL_VEL, no errors, KF populated)", telem_nominal, raw_basic_telemetry); + + // Scenario 3: Error state — multiple errors set, kicker unavailable. + // POWER_ERROR(0) | BATTERY_ERROR(2) | BATTERY_LOW(3) | MOTOR_FL_GENERAL(13) | MOTOR_BL_GENERAL(15) + // = 1 | 4 | 8 | 8192 | 32768 = 40973 + let telem_errors = BasicTelemetry { + tx_seq_num: 100, + ctrl_seq_num: 98, + status_flags: 40973, + battery_percent: 1200, + ..Default::default() + }; + print_telemetry_row("Error state (5 errors, low battery, kf=0)", telem_errors, raw_basic_telemetry); + + // Scenario 4: All possible fields non-zero (worst case). + // 0x07FF_FFFF = all 27 bits set (errors 0-22 + kicker_board_error + chipper + kicker + ctrl_reset) + let telem_worst = BasicTelemetry { + tx_seq_num: 255, + ctrl_seq_num: 255, + body_control_mode: BodyControlMode(31), // BCM_POINT_LINE + status_flags: 0x07FF_FFFF, + battery_percent: 10000, + kicker_charge_percent: 10000, + kf_pos_x: 9000, + kf_pos_y: -9000, + kf_pos_theta: 3142, + kf_vel_x: 3000, + kf_vel_y: -3000, + kf_vel_omega: 6000, + }; + print_telemetry_row("Worst case (all errors, all fields set)", telem_worst, raw_basic_telemetry); + + println!(); + + // ---- ExtendedTelemetry scenarios ---- + println!("=== ExtendedTelemetry (raw: {} bytes) ===", raw_extended_telemetry); + + // Submessage fields require setter methods (not struct literals) so micropb + // sets the presence bit in _has correctly for encoding. + + // Helper: active motor (no errors, velocity control, 150mA per sample). + let nominal_motor = || { + let mut samples = heapless::Vec::::new(); + for _ in 0..20 { let _ = samples.push(150); } + let current_telem = CcmCurrentTelemetry { + bus_voltage_mv: 24000, motor_voltage_cmd_mv: 2400, + current_setpoint_ma: 500, hall_vel_est_drads: 80, + current_samples_ma: samples, + }; + let velocity_telem = CcmVelocityTelemetry { vel_setpoint_rads: 2.5, wheel_vel_rads: 2.45 }; + let mut m = CcmTelemetry::default(); + m.error_flags = 0; + m.motion_control_type = CcmMotionControlType(4); + m.gain_stage_index = 2; + m.set_current_telem(current_telem); + m.set_velocity_telem(velocity_telem); + m + }; + + // Helper: failed motor (error flag only, no current/velocity data). + let failed_motor = || { + let mut m = CcmTelemetry::default(); + m.error_flags = 0x0001; // master_error + m + }; + + // Helper: power board nominal (all rails OK, 6S battery at 80%). + let nominal_power = || { + let battery = BatteryInfo { + status_flags: 0x01, battery_mv: 22800, + cell1_mv: 3800, cell2_mv: 3800, cell3_mv: 3800, + cell4_mv: 3800, cell5_mv: 3800, cell6_mv: 3800, + battery_pct: 80, + cell1_pct: 80, cell2_pct: 80, cell3_pct: 80, + cell4_pct: 80, cell5_pct: 80, cell6_pct: 80, + }; + let mut p = PowerTelemetry::default(); + p.status_flags = 0x1F; + p.set_battery_info(battery); + p + }; + + // Helper: dribbler sub-motor (velocity/current mode). + let dribbler_motor = || { + let current_telem = CcmCurrentTelemetry { + bus_voltage_mv: 22800, current_setpoint_ma: 2000, + ..Default::default() + }; + let velocity_telem = CcmVelocityTelemetry { vel_setpoint_rads: 100.0, wheel_vel_rads: 99.5 }; + let mut m = CcmTelemetry::default(); + m.motion_control_type = CcmMotionControlType(5); + m.gain_stage_index = 1; + m.set_current_telem(current_telem); + m.set_velocity_telem(velocity_telem); + m + }; + + // Helper: kicker nominal (charged, dribbler active). + let nominal_kicker = || { + let mut k = KickerTelemetry::default(); + k.status_flags = 0x20; k.charge_pct = 9000; + k.rail_voltage = 180.0; k.battery_voltage = 22.8; + k.kicker_image_hash = 0xDEADBEEF; + k.set_dribbler_motor(dribbler_motor()); + k + }; + + // Helper: body controller nominal (BCM_LOCAL_VELOCITY, all sensor arrays populated). + let nominal_body_control = || BodyControlExtendedTelemetry { + body_control_mode: BodyControlMode(12), // BCM_LOCAL_VELOCITY + vision_update: true, + maneuver_echo: Some(BodyControlExtendedTelemetry_::ManeuverEcho::LocalVel(LocalVelocityCommand { + local_xd: 0.5, local_yd: 0.0, local_omega: 0.3, + max_linear_acc: 3.0, max_angular_acc: 5.0, + })), + imu_gyro_x: 0.01, imu_gyro_y: 0.02, imu_gyro_z: 0.1, + imu_accel_x: 0.1, imu_accel_y: 0.05, imu_accel_z: 9.81, + vision_pose_x: 1.5, vision_pose_y: -0.5, vision_pose_theta: 1.0, + traj_pos_x: 1.6, traj_pos_y: -0.4, traj_pos_theta: 1.1, + traj_vel_x: 0.5, traj_vel_y: 0.0, traj_vel_omega: 0.3, + kf_pred_pos_x: 1.5, kf_pred_pos_y: -0.5, kf_pred_pos_theta: 1.0, + kf_pred_vel_x: 0.5, kf_pred_vel_y: 0.0, kf_pred_vel_omega: 0.3, + kf_est_pos_x: 1.5, kf_est_pos_y: -0.5, kf_est_pos_theta: 1.0, + kf_est_vel_x: 0.5, kf_est_vel_y: 0.0, kf_est_vel_omega: 0.3, + body_vel_u_x: 0.5, body_vel_u_y: 0.0, body_vel_u_omega: 0.3, + body_accel_u_x: 1.0, body_accel_u_y: 0.0, body_accel_u_omega: 0.5, + body_accel_u_fric_x: 1.2, body_accel_u_fric_y: 0.1, body_accel_u_fric_omega: 0.5, + }; + + // Scenario 1: All motors failed, robot at rest (very sparse). + let ext_all_failed = { + let mut p = PowerTelemetry::default(); + p.status_flags = 0x04; // power_board_error + let mut e = ExtendedTelemetry::default(); + e.timestamp_us_lo = 1000000; + e.set_power_status(p); + e.set_front_left_motor(failed_motor()); + e.set_back_left_motor(failed_motor()); + e.set_back_right_motor(failed_motor()); + e.set_front_right_motor(failed_motor()); + e + }; + print_ext_row("All motors failed, robot at rest", ext_all_failed, raw_extended_telemetry); + + // Scenario 2: Nominal active robot (all subsystems working, 150mA motor current). + let ext_nominal = { + let mut e = ExtendedTelemetry::default(); + e.timestamp_us_lo = 1234567890; + e.set_power_status(nominal_power()); + e.set_front_left_motor(nominal_motor()); + e.set_back_left_motor(nominal_motor()); + e.set_back_right_motor(nominal_motor()); + e.set_front_right_motor(nominal_motor()); + e.set_body_control_telem(nominal_body_control()); + e.set_kicker_status(nominal_kicker()); + e + }; + print_ext_row("Nominal (active robot, all sensors, kicker charged)", ext_nominal, raw_extended_telemetry); + + // Scenario 3: Nominal but motors at rest (current samples = 0 → not encoded). + let motor_zero_current = || { + let current_telem = CcmCurrentTelemetry { + bus_voltage_mv: 24000, ..Default::default() + }; + let velocity_telem = CcmVelocityTelemetry::default(); + let mut m = CcmTelemetry::default(); + m.error_flags = 0; + m.motion_control_type = CcmMotionControlType(4); + m.gain_stage_index = 2; + m.set_current_telem(current_telem); + m.set_velocity_telem(velocity_telem); + m + }; + let ext_low_current = { + let mut e = ExtendedTelemetry::default(); + e.timestamp_us_lo = 5000000; + e.set_power_status(nominal_power()); + e.set_front_left_motor(motor_zero_current()); + e.set_back_left_motor(motor_zero_current()); + e.set_back_right_motor(motor_zero_current()); + e.set_front_right_motor(motor_zero_current()); + e.set_body_control_telem(nominal_body_control()); + e.set_kicker_status(nominal_kicker()); + e + }; + print_ext_row("Nominal, motors at rest (current samples = 0)", ext_low_current, raw_extended_telemetry); + + println!(); + println!("Note: proto size does NOT include framing overhead (e.g., the RadioHeader)."); + println!(" Raw size is always fixed regardless of field values."); + println!(" Proto fields with default values (0/false) are NOT encoded."); +} + +/// Encodes `msg` into a 2048-byte heapless buffer and asserts the byte count +/// matches `compute_size()`. Panics if they disagree (bug in our test data or micropb). +fn encode_and_validate(msg: &M) -> usize +where + M: MessageEncode + micropb::MessageDecode, +{ + let computed = msg.compute_size(); + let buf = heapless::Vec::::new(); + let mut enc = PbEncoder::new(buf); + msg.encode(&mut enc).expect("encode overflowed 2048-byte buffer"); + let actual = enc.into_writer().len(); + assert_eq!( + computed, actual, + "compute_size()={computed} != actual encoded len={actual}" + ); + actual +} + +fn print_control_row(label: &str, msg: BasicControl, raw: usize) { + let proto = encode_and_validate(&msg); + print_row(label, proto, raw); +} + +fn print_telemetry_row(label: &str, msg: BasicTelemetry, raw: usize) { + let proto = encode_and_validate(&msg); + print_row(label, proto, raw); +} + +fn print_ext_row(label: &str, msg: ExtendedTelemetry, raw: usize) { + let proto = encode_and_validate(&msg); + print_row(label, proto, raw); +} + +fn print_row(label: &str, proto: usize, raw: usize) { + let diff = proto as i64 - raw as i64; + let pct = (diff as f64 / raw as f64) * 100.0; + let sign = if diff <= 0 { "saved" } else { "overhead" }; + println!( + " {:55} raw={:3}B proto={:3}B {:+4}B ({:.1}% {})", + label, raw, proto, diff, pct.abs(), sign + ); +} diff --git a/ateam-common-packets/rust-lib/src/.gitignore b/ateam-common-packets/rust-lib/src/.gitignore index b5a10b3..91bd2cb 100644 --- a/ateam-common-packets/rust-lib/src/.gitignore +++ b/ateam-common-packets/rust-lib/src/.gitignore @@ -1 +1,2 @@ *bindings*.rs +*_gen.rs diff --git a/ateam-common-packets/rust-lib/src/lib.rs b/ateam-common-packets/rust-lib/src/lib.rs index b18dbcf..96a59c5 100644 --- a/ateam-common-packets/rust-lib/src/lib.rs +++ b/ateam-common-packets/rust-lib/src/lib.rs @@ -8,6 +8,8 @@ use crate::bindings::{BasicControl, BodyControlMode, ExtendedTelemetry, GlobalAc pub mod bindings; pub mod radio; +pub mod proto_packets; +pub mod translation; // TODO these are assuming the biggest packet doesn't change... const core::cmp::max is still unstable. We // can fix this on the next major rust version update. @@ -106,6 +108,8 @@ pub fn is_basic_control_packet_safe(basic_control: &BasicControl) -> bool { let vision_update_safe = basic_control.vision_position_update.iter().all(|vis| vis.is_finite()); let kicker_command_safe = basic_control.kick_vel.is_finite() && basic_control.dribbler_setpoint.is_finite(); + // SAFETY: body_control_mode is the discriminant written alongside the union; + // each unsafe arm only reads the union field that matches the discriminant. let body_control_command_safe = match basic_control.body_control_mode { BodyControlMode::BCM_OFF => true, BodyControlMode::BCM_ESTOP_BRAKE => true, diff --git a/ateam-common-packets/rust-lib/src/proto_packets.rs b/ateam-common-packets/rust-lib/src/proto_packets.rs new file mode 100644 index 0000000..68d47b3 --- /dev/null +++ b/ateam-common-packets/rust-lib/src/proto_packets.rs @@ -0,0 +1,3 @@ +#![allow(clippy::all)] +#![allow(nonstandard_style, unused, irrefutable_let_patterns)] +include!("proto_packets_gen.rs"); diff --git a/ateam-common-packets/rust-lib/src/radio.rs b/ateam-common-packets/rust-lib/src/radio.rs index edd25e2..d2659d3 100644 --- a/ateam-common-packets/rust-lib/src/radio.rs +++ b/ateam-common-packets/rust-lib/src/radio.rs @@ -206,7 +206,8 @@ impl LocalAccelerationCommand { impl BasicControl { pub fn get_maneuver_command(&self) -> ManeuverCommand { - // union extraction is unsafe + // SAFETY: body_control_mode is the discriminant written alongside the union; + // we only access the arm that matches the discriminant value. unsafe { match self.body_control_mode { BodyControlMode::BCM_OFF => ManeuverCommand::Off, diff --git a/ateam-common-packets/rust-lib/src/translation.rs b/ateam-common-packets/rust-lib/src/translation.rs new file mode 100644 index 0000000..9aa8e69 --- /dev/null +++ b/ateam-common-packets/rust-lib/src/translation.rs @@ -0,0 +1,895 @@ +// Translation layer between C-layout packed structs (bindgen) and +// micropb-generated proto structs. Used to convert between the SPI wire format +// and the radio proto encoding. +// +// Two macros handle the straightforward field-copy cases. Complex cases +// (bitfields, submessage setters, array↔Vec) are written as manual impls below. +// +// Macro hygiene note: $src and $out are user-provided idents so that the +// field expressions and statement blocks share the same hygiene context. + +use crate::bindings as c; +use crate::proto_packets::ateam_ as proto; + +// Compile-time checks that proto enum values match C constants. +// These are plain numeric casts — a mismatch silently sends wrong commands over radio. +const _: () = { + assert!(proto::KickRequest::KrArm.0 == c::KickRequest::KR_ARM as i32); + assert!(proto::KickRequest::KrDisable.0 == c::KickRequest::KR_DISABLE as i32); + assert!(proto::KickRequest::KrKickNow.0 == c::KickRequest::KR_KICK_NOW as i32); + assert!(proto::KickRequest::KrKickTouch.0 == c::KickRequest::KR_KICK_TOUCH as i32); + assert!(proto::KickRequest::KrKickCaptured.0 == c::KickRequest::KR_KICK_CAPTURED as i32); + assert!(proto::KickRequest::KrChipNow.0 == c::KickRequest::KR_CHIP_NOW as i32); + assert!(proto::KickRequest::KrChipTouch.0 == c::KickRequest::KR_CHIP_TOUCH as i32); + assert!(proto::KickRequest::KrChipCaptured.0 == c::KickRequest::KR_CHIP_CAPTURED as i32); + + assert!(proto::DribblerMode::DmDisable.0 == c::DribblerCommand::DC_DISABLE as i32); + assert!(proto::DribblerMode::DmHardReceive.0 == c::DribblerCommand::DC_HARD_RECEIVE as i32); + assert!(proto::DribblerMode::DmSoftReceive.0 == c::DribblerCommand::DC_SOFT_RECEIVE as i32); + assert!(proto::DribblerMode::DmDribble.0 == c::DribblerCommand::DC_DRIBBLE as i32); + assert!(proto::DribblerMode::DmVelocity.0 == c::DribblerCommand::DC_VELOCITY as i32); + assert!(proto::DribblerMode::DmCurrent.0 == c::DribblerCommand::DC_CURRENT as i32); +}; + +// --------------------------------------------------------------------------- +// Macros +// --------------------------------------------------------------------------- + +/// `impl From<&$c_type> for $p_type` via struct literal. +/// Works only for proto types with NO submessage fields (no _has). +/// $src is the parameter name — use the same ident in field expressions. +macro_rules! impl_c_to_proto { + ($c_type:path => $p_type:path, $src:ident, { $($f:ident: $e:expr),* $(,)? }) => { + impl From<&$c_type> for $p_type { + fn from($src: &$c_type) -> Self { + Self { $($f: $e,)* ..Default::default() } + } + } + }; +} + +/// `impl From<&$p_type> for $c_type` via default + sequential field assignments. +/// $src = source param name; $out = mutable output var name. +/// Use the same idents in field expressions. +macro_rules! impl_proto_to_c { + ($p_type:path => $c_type:path, $src:ident, $out:ident, { $($f:ident: $e:expr),* $(,)? }) => { + impl From<&$p_type> for $c_type { + fn from($src: &$p_type) -> Self { + let mut $out = <$c_type>::default(); + $($out.$f = $e;)* + $out + } + } + }; +} + +// --------------------------------------------------------------------------- +// Maneuver commands — all-float structs, direct copy both directions. +// Needed for BodyControlExtendedTelemetry maneuver echo translation. +// --------------------------------------------------------------------------- + +impl_c_to_proto!(c::GlobalPositionCommand => proto::GlobalPositionCommand, s, { + global_x: s.global_x, global_y: s.global_y, global_theta: s.global_theta, + max_linear_vel: s.max_linear_vel, max_angular_vel: s.max_angular_vel, + max_linear_acc: s.max_linear_acc, max_angular_acc: s.max_angular_acc, +}); +impl_proto_to_c!(proto::GlobalPositionCommand => c::GlobalPositionCommand, s, r, { + global_x: s.global_x, global_y: s.global_y, global_theta: s.global_theta, + max_linear_vel: s.max_linear_vel, max_angular_vel: s.max_angular_vel, + max_linear_acc: s.max_linear_acc, max_angular_acc: s.max_angular_acc, +}); + +impl_c_to_proto!(c::GlobalVelocityCommand => proto::GlobalVelocityCommand, s, { + global_xd: s.global_xd, global_yd: s.global_yd, global_omega: s.global_omega, + max_linear_acc: s.max_linear_acc, max_angular_acc: s.max_angular_acc, +}); +impl_proto_to_c!(proto::GlobalVelocityCommand => c::GlobalVelocityCommand, s, r, { + global_xd: s.global_xd, global_yd: s.global_yd, global_omega: s.global_omega, + max_linear_acc: s.max_linear_acc, max_angular_acc: s.max_angular_acc, +}); + +impl_c_to_proto!(c::LocalVelocityCommand => proto::LocalVelocityCommand, s, { + local_xd: s.local_xd, local_yd: s.local_yd, local_omega: s.local_omega, + max_linear_acc: s.max_linear_acc, max_angular_acc: s.max_angular_acc, +}); +impl_proto_to_c!(proto::LocalVelocityCommand => c::LocalVelocityCommand, s, r, { + local_xd: s.local_xd, local_yd: s.local_yd, local_omega: s.local_omega, + max_linear_acc: s.max_linear_acc, max_angular_acc: s.max_angular_acc, +}); + +impl_c_to_proto!(c::GlobalAccelerationCommand => proto::GlobalAccelerationCommand, s, { + global_xdd: s.global_xdd, global_ydd: s.global_ydd, global_alpha: s.global_alpha, +}); +impl_proto_to_c!(proto::GlobalAccelerationCommand => c::GlobalAccelerationCommand, s, r, { + global_xdd: s.global_xdd, global_ydd: s.global_ydd, global_alpha: s.global_alpha, +}); + +impl_c_to_proto!(c::LocalAccelerationCommand => proto::LocalAccelerationCommand, s, { + local_xdd: s.local_xdd, local_ydd: s.local_ydd, local_alpha: s.local_alpha, +}); +impl_proto_to_c!(proto::LocalAccelerationCommand => c::LocalAccelerationCommand, s, r, { + local_xdd: s.local_xdd, local_ydd: s.local_ydd, local_alpha: s.local_alpha, +}); + +impl_c_to_proto!(c::HeadingPivotCommand => proto::HeadingPivotCommand, s, { + global_theta: s.global_theta, max_angular_vel: s.max_angular_vel, + max_angular_acc: s.max_angular_acc, orbit_radius: s.orbit_radius, + inset_angle: s.inset_angle, direction: s.direction as u32, + compute_inset_angle: s.compute_inset_angle != 0, +}); +impl_proto_to_c!(proto::HeadingPivotCommand => c::HeadingPivotCommand, s, r, { + global_theta: s.global_theta, max_angular_vel: s.max_angular_vel, + max_angular_acc: s.max_angular_acc, orbit_radius: s.orbit_radius, + inset_angle: s.inset_angle, direction: s.direction as c::PivotDirection::Type, + compute_inset_angle: s.compute_inset_angle as u8, +}); + +impl_c_to_proto!(c::PointPivotCommand => proto::PointPivotCommand, s, { + target_x: s.target_x, target_y: s.target_y, + max_angular_vel: s.max_angular_vel, max_angular_acc: s.max_angular_acc, + orbit_radius: s.orbit_radius, inset_angle: s.inset_angle, + direction: s.direction as u32, compute_inset_angle: s.compute_inset_angle != 0, +}); +impl_proto_to_c!(proto::PointPivotCommand => c::PointPivotCommand, s, r, { + target_x: s.target_x, target_y: s.target_y, + max_angular_vel: s.max_angular_vel, max_angular_acc: s.max_angular_acc, + orbit_radius: s.orbit_radius, inset_angle: s.inset_angle, + direction: s.direction as c::PivotDirection::Type, + compute_inset_angle: s.compute_inset_angle as u8, +}); + +impl_c_to_proto!(c::HeadingLineCommand => proto::HeadingLineCommand, s, { + start_x: s.start_x, start_y: s.start_y, dir_x: s.dir_x, dir_y: s.dir_y, + line_velocity: s.line_velocity, global_theta: s.global_theta, + max_vel_colinear: s.max_vel_colinear, max_vel_perp: s.max_vel_perp, + max_vel_angular: s.max_vel_angular, + max_accel_colinear: s.max_accel_colinear, max_accel_perp: s.max_accel_perp, + max_accel_angular: s.max_accel_angular, colinear_start_thresh: s.colinear_start_thresh, +}); +impl_proto_to_c!(proto::HeadingLineCommand => c::HeadingLineCommand, s, r, { + start_x: s.start_x, start_y: s.start_y, dir_x: s.dir_x, dir_y: s.dir_y, + line_velocity: s.line_velocity, global_theta: s.global_theta, + max_vel_colinear: s.max_vel_colinear, max_vel_perp: s.max_vel_perp, + max_vel_angular: s.max_vel_angular, + max_accel_colinear: s.max_accel_colinear, max_accel_perp: s.max_accel_perp, + max_accel_angular: s.max_accel_angular, colinear_start_thresh: s.colinear_start_thresh, +}); + +impl_c_to_proto!(c::PointLineCommand => proto::PointLineCommand, s, { + start_x: s.start_x, start_y: s.start_y, dir_x: s.dir_x, dir_y: s.dir_y, + line_velocity: s.line_velocity, target_x: s.target_x, target_y: s.target_y, + max_vel_colinear: s.max_vel_colinear, max_vel_perp: s.max_vel_perp, + max_vel_angular: s.max_vel_angular, + max_accel_colinear: s.max_accel_colinear, max_accel_perp: s.max_accel_perp, + max_accel_angular: s.max_accel_angular, colinear_start_thresh: s.colinear_start_thresh, +}); +impl_proto_to_c!(proto::PointLineCommand => c::PointLineCommand, s, r, { + start_x: s.start_x, start_y: s.start_y, dir_x: s.dir_x, dir_y: s.dir_y, + line_velocity: s.line_velocity, target_x: s.target_x, target_y: s.target_y, + max_vel_colinear: s.max_vel_colinear, max_vel_perp: s.max_vel_perp, + max_vel_angular: s.max_vel_angular, + max_accel_colinear: s.max_accel_colinear, max_accel_perp: s.max_accel_perp, + max_accel_angular: s.max_accel_angular, colinear_start_thresh: s.colinear_start_thresh, +}); + +// --------------------------------------------------------------------------- +// Motor types +// --------------------------------------------------------------------------- + +impl_c_to_proto!(c::CcmVelocityTelemetry => proto::CcmVelocityTelemetry, s, { + vel_setpoint_rads: s.vel_setpoint_rads, + wheel_vel_rads: s.wheel_vel_rads, +}); +impl_proto_to_c!(proto::CcmVelocityTelemetry => c::CcmVelocityTelemetry, s, r, { + vel_setpoint_rads: s.vel_setpoint_rads, + wheel_vel_rads: s.wheel_vel_rads, +}); + +// CcmCurrentTelemetry: uint16_t[20] array ↔ heapless::Vec. +// Too complex for the macro — written manually. +impl From<&c::CcmCurrentTelemetry> for proto::CcmCurrentTelemetry { + fn from(src: &c::CcmCurrentTelemetry) -> Self { + let mut samples = heapless::Vec::::new(); + for &s in src.current_samples_ma.iter() { + let _ = samples.push(s as u32); + } + Self { + bus_voltage_mv: src.bus_voltage_mv as u32, + motor_voltage_cmd_mv: src.motor_voltage_cmd_mv as u32, + current_setpoint_ma: src.current_setpoint_ma as i32, + hall_vel_est_drads: src.hall_vel_est_drads as i32, + current_samples_ma: samples, + } + } +} + +impl From<&proto::CcmCurrentTelemetry> for c::CcmCurrentTelemetry { + fn from(src: &proto::CcmCurrentTelemetry) -> Self { + let mut arr = [0u16; 20]; + for (i, &s) in src.current_samples_ma.iter().enumerate().take(20) { + arr[i] = s as u16; + } + Self { + bus_voltage_mv: src.bus_voltage_mv as u16, + motor_voltage_cmd_mv: src.motor_voltage_cmd_mv as u16, + current_setpoint_ma: src.current_setpoint_ma as i16, + hall_vel_est_drads: src.hall_vel_est_drads as i16, + current_samples_ma: arr, + } + } +} + +// CcmTelemetry: 16-bit bitfield (_bitfield_1) + submessage fields (require setters). +// Bulk-reads/writes all 16 error bits in a single get(0,16)/set(0,16) call. +impl From<&c::CcmTelemetry> for proto::CcmTelemetry { + fn from(src: &c::CcmTelemetry) -> Self { + let mut out = proto::CcmTelemetry::default(); + out.error_flags = src._bitfield_1.get(0, 16) as u32; + out.motion_control_type = proto::CcmMotionControlType(src.motion_control_type as i32); + out.gain_stage_index = src.gain_stage_index() as u32; + out.set_current_telem((&src.current_telemetry).into()); + out.set_velocity_telem((&src.velocity_telemetry).into()); + out + } +} + +impl From<&proto::CcmTelemetry> for c::CcmTelemetry { + fn from(src: &proto::CcmTelemetry) -> Self { + let mut out = c::CcmTelemetry::default(); + out._bitfield_1.set(0, 16, src.error_flags as u64); + out.motion_control_type = src.motion_control_type.0 as c::CcmMotionControlType::Type; + out.set_gain_stage_index(src.gain_stage_index as u8); + out.current_telemetry = (&src.current_telem).into(); + out.velocity_telemetry = (&src.velocity_telem).into(); + out + } +} + +// --------------------------------------------------------------------------- +// Power types +// --------------------------------------------------------------------------- + +// BatteryInfo: _bitfield_1 holds 7 status flags; all other fields are direct scalars. +impl From<&c::BatteryInfo> for proto::BatteryInfo { + fn from(src: &c::BatteryInfo) -> Self { + Self { + status_flags: src._bitfield_1.get(0, 7) as u32, + battery_mv: src.battery_mv as u32, + cell1_mv: src.cell1_mv as u32, + cell2_mv: src.cell2_mv as u32, + cell3_mv: src.cell3_mv as u32, + cell4_mv: src.cell4_mv as u32, + cell5_mv: src.cell5_mv as u32, + cell6_mv: src.cell6_mv as u32, + battery_pct: src.battery_pct as u32, + cell1_pct: src.cell1_pct as u32, + cell2_pct: src.cell2_pct as u32, + cell3_pct: src.cell3_pct as u32, + cell4_pct: src.cell4_pct as u32, + cell5_pct: src.cell5_pct as u32, + cell6_pct: src.cell6_pct as u32, + } + } +} + +impl From<&proto::BatteryInfo> for c::BatteryInfo { + fn from(src: &proto::BatteryInfo) -> Self { + let mut out = c::BatteryInfo::default(); + out._bitfield_1.set(0, 7, src.status_flags as u64); + out.battery_mv = src.battery_mv as u16; + out.cell1_mv = src.cell1_mv as u16; + out.cell2_mv = src.cell2_mv as u16; + out.cell3_mv = src.cell3_mv as u16; + out.cell4_mv = src.cell4_mv as u16; + out.cell5_mv = src.cell5_mv as u16; + out.cell6_mv = src.cell6_mv as u16; + out.battery_pct = src.battery_pct as u8; + out.cell1_pct = src.cell1_pct as u8; + out.cell2_pct = src.cell2_pct as u8; + out.cell3_pct = src.cell3_pct as u8; + out.cell4_pct = src.cell4_pct as u8; + out.cell5_pct = src.cell5_pct as u8; + out.cell6_pct = src.cell6_pct as u8; + out + } +} + +// PowerTelemetry: _bitfield_1 holds 6 status flags + submessage battery_info. +impl From<&c::PowerTelemetry> for proto::PowerTelemetry { + fn from(src: &c::PowerTelemetry) -> Self { + let mut out = proto::PowerTelemetry::default(); + out.status_flags = src._bitfield_1.get(0, 6) as u32; + out.set_battery_info((&src.battery_info).into()); + out + } +} + +impl From<&proto::PowerTelemetry> for c::PowerTelemetry { + fn from(src: &proto::PowerTelemetry) -> Self { + let mut out = c::PowerTelemetry::default(); + out._bitfield_1.set(0, 6, src.status_flags as u64); + out.battery_info = (&src.battery_info).into(); + out + } +} + +// --------------------------------------------------------------------------- +// Kicker types +// --------------------------------------------------------------------------- + +// KickerTelemetry: _bitfield_1 holds 7 status flags; kicker_image_hash is [u8; 4] +// in C but fixed32 (u32, LE) in proto; dribbler_motor requires setter. +impl From<&c::KickerTelemetry> for proto::KickerTelemetry { + fn from(src: &c::KickerTelemetry) -> Self { + let mut out = proto::KickerTelemetry::default(); + out.status_flags = src._bitfield_1.get(0, 7) as u32; + out.charge_pct = src.charge_pct as u32; + out.rail_voltage = src.rail_voltage; + out.battery_voltage = src.battery_voltage; + out.kicker_image_hash = u32::from_le_bytes(src.kicker_image_hash); + out.set_dribbler_motor((&src.dribbler_motor).into()); + out + } +} + +impl From<&proto::KickerTelemetry> for c::KickerTelemetry { + fn from(src: &proto::KickerTelemetry) -> Self { + let mut out = c::KickerTelemetry::default(); + out._bitfield_1.set(0, 7, src.status_flags as u64); + out.charge_pct = src.charge_pct as u16; + out.rail_voltage = src.rail_voltage; + out.battery_voltage = src.battery_voltage; + out.kicker_image_hash = src.kicker_image_hash.to_le_bytes(); + out.dribbler_motor = (&src.dribbler_motor).into(); + out + } +} + +// --------------------------------------------------------------------------- +// Body control extended telemetry +// --------------------------------------------------------------------------- + +// BodyControlExtendedTelemetry: body_control_mode drives unsafe union access for +// maneuver_echo; vision_update is in _bitfield_1 bit 0; all float arrays → flat fields. +impl From<&c::BodyControlExtendedTelemetry> for proto::BodyControlExtendedTelemetry { + fn from(src: &c::BodyControlExtendedTelemetry) -> Self { + use c::BodyControlMode as BCM; + // SAFETY: body_control_mode is the discriminant written alongside the union; + // we only access the maneuver arm that matches the discriminant value. + let maneuver_echo = unsafe { + match src.body_control_mode { + BCM::BCM_GLOBAL_POSITION => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::GlobalPos( + (&src.maneuver.global_pos.cmd_echo).into() + )), + BCM::BCM_GLOBAL_VELOCITY => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::GlobalVel( + (&src.maneuver.global_vel.cmd_echo).into() + )), + BCM::BCM_LOCAL_VELOCITY => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::LocalVel( + (&src.maneuver.local_vel.cmd_echo).into() + )), + BCM::BCM_GLOBAL_ACCEL => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::GlobalAcc( + (&src.maneuver.global_acc.cmd_echo).into() + )), + BCM::BCM_LOCAL_ACCEL => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::LocalAcc( + (&src.maneuver.local_acc.cmd_echo).into() + )), + BCM::BCM_HEADING_PIVOT => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::HeadingPivot( + (&src.maneuver.heading_pivot.cmd_echo).into() + )), + BCM::BCM_POINT_PIVOT => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::PointPivot( + (&src.maneuver.point_pivot.cmd_echo).into() + )), + BCM::BCM_HEADING_LINE => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::HeadingLine( + (&src.maneuver.heading_line.cmd_echo).into() + )), + BCM::BCM_POINT_LINE => + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::PointLine( + (&src.maneuver.point_line.cmd_echo).into() + )), + BCM::BCM_OFF | BCM::BCM_ESTOP_BRAKE => None, + _ => { + debug_assert!(false, "unknown body_control_mode: {}", src.body_control_mode); + None + } + } + }; + Self { + body_control_mode: proto::BodyControlMode(src.body_control_mode as i32), + vision_update: src.vision_update() != 0, + maneuver_echo, + imu_gyro_x: src.imu_gyro[0], + imu_gyro_y: src.imu_gyro[1], + imu_gyro_z: src.imu_gyro[2], + imu_accel_x: src.imu_accel[0], + imu_accel_y: src.imu_accel[1], + imu_accel_z: src.imu_accel[2], + vision_pose_x: src.vision_pose[0], + vision_pose_y: src.vision_pose[1], + vision_pose_theta: src.vision_pose[2], + traj_pos_x: src.body_traj_pos[0], + traj_pos_y: src.body_traj_pos[1], + traj_pos_theta: src.body_traj_pos[2], + traj_vel_x: src.body_traj_vel[0], + traj_vel_y: src.body_traj_vel[1], + traj_vel_omega: src.body_traj_vel[2], + kf_pred_pos_x: src.kf_body_pos_prediction[0], + kf_pred_pos_y: src.kf_body_pos_prediction[1], + kf_pred_pos_theta: src.kf_body_pos_prediction[2], + kf_pred_vel_x: src.kf_body_vel_prediction[0], + kf_pred_vel_y: src.kf_body_vel_prediction[1], + kf_pred_vel_omega: src.kf_body_vel_prediction[2], + kf_est_pos_x: src.kf_body_pos_estimate[0], + kf_est_pos_y: src.kf_body_pos_estimate[1], + kf_est_pos_theta: src.kf_body_pos_estimate[2], + kf_est_vel_x: src.kf_body_vel_estimate[0], + kf_est_vel_y: src.kf_body_vel_estimate[1], + kf_est_vel_omega: src.kf_body_vel_estimate[2], + body_vel_u_x: src.body_vel_u[0], + body_vel_u_y: src.body_vel_u[1], + body_vel_u_omega: src.body_vel_u[2], + body_accel_u_x: src.body_accel_u[0], + body_accel_u_y: src.body_accel_u[1], + body_accel_u_omega: src.body_accel_u[2], + body_accel_u_fric_x: src.body_accel_u_fric_comp[0], + body_accel_u_fric_y: src.body_accel_u_fric_comp[1], + body_accel_u_fric_omega: src.body_accel_u_fric_comp[2], + } + } +} + +impl From<&proto::BodyControlExtendedTelemetry> for c::BodyControlExtendedTelemetry { + fn from(src: &proto::BodyControlExtendedTelemetry) -> Self { + use c::BodyControlMode as BCM; + let mut out = c::BodyControlExtendedTelemetry::default(); + out.body_control_mode = src.body_control_mode.0 as c::BodyControlMode::Type; + out.set_vision_update(src.vision_update as u8); + match &src.maneuver_echo { + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::GlobalPos(cmd)) => { + out.maneuver.global_pos.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_GLOBAL_POSITION; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::GlobalVel(cmd)) => { + out.maneuver.global_vel.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_GLOBAL_VELOCITY; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::LocalVel(cmd)) => { + out.maneuver.local_vel.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_LOCAL_VELOCITY; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::GlobalAcc(cmd)) => { + out.maneuver.global_acc.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_GLOBAL_ACCEL; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::LocalAcc(cmd)) => { + out.maneuver.local_acc.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_LOCAL_ACCEL; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::HeadingPivot(cmd)) => { + out.maneuver.heading_pivot.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_HEADING_PIVOT; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::PointPivot(cmd)) => { + out.maneuver.point_pivot.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_POINT_PIVOT; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::HeadingLine(cmd)) => { + out.maneuver.heading_line.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_HEADING_LINE; + } + Some(proto::BodyControlExtendedTelemetry_::ManeuverEcho::PointLine(cmd)) => { + out.maneuver.point_line.cmd_echo = cmd.into(); + out.body_control_mode = BCM::BCM_POINT_LINE; + } + None => {} + } + out.imu_gyro[0] = src.imu_gyro_x; + out.imu_gyro[1] = src.imu_gyro_y; + out.imu_gyro[2] = src.imu_gyro_z; + out.imu_accel[0] = src.imu_accel_x; + out.imu_accel[1] = src.imu_accel_y; + out.imu_accel[2] = src.imu_accel_z; + out.vision_pose[0] = src.vision_pose_x; + out.vision_pose[1] = src.vision_pose_y; + out.vision_pose[2] = src.vision_pose_theta; + out.body_traj_pos[0] = src.traj_pos_x; + out.body_traj_pos[1] = src.traj_pos_y; + out.body_traj_pos[2] = src.traj_pos_theta; + out.body_traj_vel[0] = src.traj_vel_x; + out.body_traj_vel[1] = src.traj_vel_y; + out.body_traj_vel[2] = src.traj_vel_omega; + out.kf_body_pos_prediction[0] = src.kf_pred_pos_x; + out.kf_body_pos_prediction[1] = src.kf_pred_pos_y; + out.kf_body_pos_prediction[2] = src.kf_pred_pos_theta; + out.kf_body_vel_prediction[0] = src.kf_pred_vel_x; + out.kf_body_vel_prediction[1] = src.kf_pred_vel_y; + out.kf_body_vel_prediction[2] = src.kf_pred_vel_omega; + out.kf_body_pos_estimate[0] = src.kf_est_pos_x; + out.kf_body_pos_estimate[1] = src.kf_est_pos_y; + out.kf_body_pos_estimate[2] = src.kf_est_pos_theta; + out.kf_body_vel_estimate[0] = src.kf_est_vel_x; + out.kf_body_vel_estimate[1] = src.kf_est_vel_y; + out.kf_body_vel_estimate[2] = src.kf_est_vel_omega; + out.body_vel_u[0] = src.body_vel_u_x; + out.body_vel_u[1] = src.body_vel_u_y; + out.body_vel_u[2] = src.body_vel_u_omega; + out.body_accel_u[0] = src.body_accel_u_x; + out.body_accel_u[1] = src.body_accel_u_y; + out.body_accel_u[2] = src.body_accel_u_omega; + out.body_accel_u_fric_comp[0] = src.body_accel_u_fric_x; + out.body_accel_u_fric_comp[1] = src.body_accel_u_fric_y; + out.body_accel_u_fric_comp[2] = src.body_accel_u_fric_omega; + out + } +} + +// --------------------------------------------------------------------------- +// Top-level packets +// --------------------------------------------------------------------------- + +// ExtendedTelemetry: all submessage fields; C uses body_control_telemetry, proto uses +// body_control_telem. The `_has` bits are set via set_X() setters. +impl From<&c::ExtendedTelemetry> for proto::ExtendedTelemetry { + fn from(src: &c::ExtendedTelemetry) -> Self { + let mut out = proto::ExtendedTelemetry::default(); + out.timestamp_us_lo = src.timestamp_us_lo; + out.timestamp_us_hi = src.timestamp_us_hi; + out.set_power_status((&src.power_status).into()); + out.set_front_left_motor((&src.front_left_motor).into()); + out.set_back_left_motor((&src.back_left_motor).into()); + out.set_back_right_motor((&src.back_right_motor).into()); + out.set_front_right_motor((&src.front_right_motor).into()); + out.set_body_control_telem((&src.body_control_telemetry).into()); + out.set_kicker_status((&src.kicker_status).into()); + out + } +} + +impl From<&proto::ExtendedTelemetry> for c::ExtendedTelemetry { + fn from(src: &proto::ExtendedTelemetry) -> Self { + c::ExtendedTelemetry { + timestamp_us_lo: src.timestamp_us_lo, + timestamp_us_hi: src.timestamp_us_hi, + power_status: (&src.power_status).into(), + front_left_motor: (&src.front_left_motor).into(), + back_left_motor: (&src.back_left_motor).into(), + back_right_motor: (&src.back_right_motor).into(), + front_right_motor: (&src.front_right_motor).into(), + body_control_telemetry: (&src.body_control_telem).into(), + kicker_status: (&src.kicker_status).into(), + } + } +} + +// BasicControl: bitfield holds 9 control flags; vision_position_update[3] → vision_x/y/theta; +// body_control_mode + cmd union with unsafe; BCM_ESTOP_BRAKE ↔ estop_brake bool. +impl From<&c::BasicControl> for proto::BasicControl { + fn from(src: &c::BasicControl) -> Self { + use c::BodyControlMode as BCM; + let mut out = proto::BasicControl::default(); + out.request_shutdown = src.request_shutdown() != 0; + out.reboot_robot = src.reboot_robot() != 0; + out.game_state_in_stop = src.game_state_in_stop() != 0; + out.game_state_in_halt = src.game_state_in_halt() != 0; + out.emergency_stop = src.emergency_stop() != 0; + out.wheel_vel_control_enabled = src.wheel_vel_control_enabled() != 0; + out.wheel_torque_control_enabled = src.wheel_torque_control_enabled() != 0; + out.vision_update_valid = src.vision_update() != 0; + out.reset_controller = src.reset_controller() != 0; + if src.vision_update() != 0 { + out.vision_x = src.vision_position_update[0]; + out.vision_y = src.vision_position_update[1]; + out.vision_theta = src.vision_position_update[2]; + } + out.kick_request = proto::KickRequest(src.kick_request as i32); + out.kick_vel = src.kick_vel; + out.dribbler_mode = proto::DribblerMode(src.dribbler_mode as i32); + out.dribbler_setpoint = src.dribbler_setpoint; + out.play_song = src.play_song as u32; + // SAFETY: body_control_mode is the discriminant written alongside the union; + // we only access the cmd arm that matches the discriminant value. + out.cmd = unsafe { + match src.body_control_mode { + BCM::BCM_ESTOP_BRAKE => { out.estop_brake = true; None } + BCM::BCM_GLOBAL_POSITION => Some(proto::BasicControl_::Cmd::GlobalPos((&src.cmd.global_pos).into())), + BCM::BCM_GLOBAL_VELOCITY => Some(proto::BasicControl_::Cmd::GlobalVel((&src.cmd.global_vel).into())), + BCM::BCM_LOCAL_VELOCITY => Some(proto::BasicControl_::Cmd::LocalVel((&src.cmd.local_vel).into())), + BCM::BCM_GLOBAL_ACCEL => Some(proto::BasicControl_::Cmd::GlobalAcc((&src.cmd.global_acc).into())), + BCM::BCM_LOCAL_ACCEL => Some(proto::BasicControl_::Cmd::LocalAcc((&src.cmd.local_acc).into())), + BCM::BCM_HEADING_PIVOT => Some(proto::BasicControl_::Cmd::HeadingPivot((&src.cmd.heading_pivot).into())), + BCM::BCM_POINT_PIVOT => Some(proto::BasicControl_::Cmd::PointPivot((&src.cmd.point_pivot).into())), + BCM::BCM_HEADING_LINE => Some(proto::BasicControl_::Cmd::HeadingLine((&src.cmd.heading_line).into())), + BCM::BCM_POINT_LINE => Some(proto::BasicControl_::Cmd::PointLine((&src.cmd.point_line).into())), + BCM::BCM_OFF => None, + _ => { + debug_assert!(false, "unknown body_control_mode: {}", src.body_control_mode); + None + } + } + }; + out + } +} + +impl From<&proto::BasicControl> for c::BasicControl { + fn from(src: &proto::BasicControl) -> Self { + use c::BodyControlMode as BCM; + let mut out = c::BasicControl::default(); + out.set_request_shutdown(src.request_shutdown as u32); + out.set_reboot_robot(src.reboot_robot as u32); + out.set_game_state_in_stop(src.game_state_in_stop as u32); + out.set_game_state_in_halt(src.game_state_in_halt as u32); + out.set_emergency_stop(src.emergency_stop as u32); + out.set_wheel_vel_control_enabled(src.wheel_vel_control_enabled as u32); + out.set_wheel_torque_control_enabled(src.wheel_torque_control_enabled as u32); + out.set_vision_update(src.vision_update_valid as u32); + out.set_reset_controller(src.reset_controller as u32); + if src.vision_update_valid { + out.vision_position_update[0] = src.vision_x; + out.vision_position_update[1] = src.vision_y; + out.vision_position_update[2] = src.vision_theta; + } + out.kick_request = src.kick_request.0 as c::KickRequest::Type; + out.kick_vel = src.kick_vel; + out.dribbler_mode = src.dribbler_mode.0 as c::DribblerCommand::Type; + out.dribbler_setpoint = src.dribbler_setpoint; + out.play_song = src.play_song as u8; + if src.estop_brake { + out.body_control_mode = BCM::BCM_ESTOP_BRAKE; + } else { + match &src.cmd { + None => out.body_control_mode = BCM::BCM_OFF, + Some(proto::BasicControl_::Cmd::GlobalPos(cmd)) => { + out.body_control_mode = BCM::BCM_GLOBAL_POSITION; + out.cmd.global_pos = cmd.into(); + } + Some(proto::BasicControl_::Cmd::GlobalVel(cmd)) => { + out.body_control_mode = BCM::BCM_GLOBAL_VELOCITY; + out.cmd.global_vel = cmd.into(); + } + Some(proto::BasicControl_::Cmd::LocalVel(cmd)) => { + out.body_control_mode = BCM::BCM_LOCAL_VELOCITY; + out.cmd.local_vel = cmd.into(); + } + Some(proto::BasicControl_::Cmd::GlobalAcc(cmd)) => { + out.body_control_mode = BCM::BCM_GLOBAL_ACCEL; + out.cmd.global_acc = cmd.into(); + } + Some(proto::BasicControl_::Cmd::LocalAcc(cmd)) => { + out.body_control_mode = BCM::BCM_LOCAL_ACCEL; + out.cmd.local_acc = cmd.into(); + } + Some(proto::BasicControl_::Cmd::HeadingPivot(cmd)) => { + out.body_control_mode = BCM::BCM_HEADING_PIVOT; + out.cmd.heading_pivot = cmd.into(); + } + Some(proto::BasicControl_::Cmd::PointPivot(cmd)) => { + out.body_control_mode = BCM::BCM_POINT_PIVOT; + out.cmd.point_pivot = cmd.into(); + } + Some(proto::BasicControl_::Cmd::HeadingLine(cmd)) => { + out.body_control_mode = BCM::BCM_HEADING_LINE; + out.cmd.heading_line = cmd.into(); + } + Some(proto::BasicControl_::Cmd::PointLine(cmd)) => { + out.body_control_mode = BCM::BCM_POINT_LINE; + out.cmd.point_line = cmd.into(); + } + + } + } + out + } +} + +// BasicTelemetry: _bitfield_1 is 27-bit status bitfield; kf arrays are i16[3] → sint32 fields; +// C uses byte sequence numbers (u8) while proto uses u32. +// Note: C's control_telem (BodyControlTelemetry) is not in the proto BasicTelemetry. +impl From<&c::BasicTelemetry> for proto::BasicTelemetry { + fn from(src: &c::BasicTelemetry) -> Self { + Self { + tx_seq_num: src.transmission_sequence_number as u32, + ctrl_seq_num: src.control_data_sequence_number as u32, + body_control_mode: proto::BodyControlMode(src.body_control_mode as i32), + status_flags: src._bitfield_1.get(0, 27) as u32, + battery_percent: src.battery_percent as u32, + kicker_charge_percent: src.kicker_charge_percent as u32, + kf_pos_x: src.kf_body_pos_estimate[0] as i32, + kf_pos_y: src.kf_body_pos_estimate[1] as i32, + kf_pos_theta: src.kf_body_pos_estimate[2] as i32, + kf_vel_x: src.kf_body_vel_estimate[0] as i32, + kf_vel_y: src.kf_body_vel_estimate[1] as i32, + kf_vel_omega: src.kf_body_vel_estimate[2] as i32, + } + } +} + +impl From<&proto::BasicTelemetry> for c::BasicTelemetry { + fn from(src: &proto::BasicTelemetry) -> Self { + let mut out = c::BasicTelemetry::default(); + out.transmission_sequence_number = src.tx_seq_num as u8; + out.control_data_sequence_number = src.ctrl_seq_num as u8; + out.body_control_mode = src.body_control_mode.0 as c::BodyControlMode::Type; + out._bitfield_1.set(0, 27, src.status_flags as u64); + out.battery_percent = src.battery_percent as u16; + out.kicker_charge_percent = src.kicker_charge_percent as u16; + out.kf_body_pos_estimate[0] = src.kf_pos_x as i16; + out.kf_body_pos_estimate[1] = src.kf_pos_y as i16; + out.kf_body_pos_estimate[2] = src.kf_pos_theta as i16; + out.kf_body_vel_estimate[0] = src.kf_vel_x as i16; + out.kf_body_vel_estimate[1] = src.kf_vel_y as i16; + out.kf_body_vel_estimate[2] = src.kf_vel_omega as i16; + out + } +} + +// --------------------------------------------------------------------------- +// Bitfield position test — asserts that _bitfield_1.get(0,16) matches the +// individual accessor positions that bindgen generates. Catches layout changes. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::c; + + // Failure modes per invariant: + // • Setter renamed/removed → compile error (method not found) + // • New bit added to C header → test failure (OR sum < expected mask) + // • $width wrong → test failure (mask mismatch) + // • Bit reordered near top → last-bit assertion fails + // Only case NOT caught at compile time: new bit added without updating this list. + // That produces a test failure, which is the best achievable without proc macros. + macro_rules! assert_bitfield_exhaustive { + ( + $T:ty, + last: $last_setter:ident @ $last_bit:expr, + width: $width:expr, + [$($setter:ident),+ $(,)?] + ) => {{ + let mut v = <$T>::default(); + v.$last_setter(1); + assert_eq!( + v._bitfield_1.get(0, $width), + 1u64 << $last_bit, + concat!(stringify!($last_setter), " must be bit ", stringify!($last_bit)), + ); + + let mut v = <$T>::default(); + $(v.$setter(1);)+ + assert_eq!( + v._bitfield_1.get(0, $width), + (1u64 << $width) - 1, + concat!(stringify!($T), ": setter list doesn't cover all ", stringify!($width), " bits — update list or width"), + ); + }}; + } + + #[test] + fn ccm_error_bitfield_positions() { + assert_bitfield_exhaustive!( + c::CcmTelemetry, + last: set_reset_pin @ 15, + width: 16, + [ + set_master_error, + set_hall_power_error, + set_hall_disconnected_error, + set_bldc_transition_error, + set_bldc_commutation_watchdog_error, + set_enc_disconnected_error, + set_overcurrent_error, + set_undervoltage_error, + set_overvoltage_error, + set_torque_limited, + set_control_loop_time_error, + set_reset_watchdog_independent, + set_reset_watchdog_window, + set_reset_low_power, + set_reset_software, + set_reset_pin, + ] + ); + } + + #[test] + fn battery_info_bitfield_positions() { + assert_bitfield_exhaustive!( + c::BatteryInfo, + last: set_battery_cell_imbalance_warn @ 6, + width: 7, + [ + set_battery_ok, + set_battery_balance_connected, + set_battery_low, + set_battery_critical, + set_battery_cell_low, + set_battery_cell_critical, + set_battery_cell_imbalance_warn, + ] + ); + } + + #[test] + fn power_telemetry_bitfield_positions() { + assert_bitfield_exhaustive!( + c::PowerTelemetry, + last: set_shutdown_requested @ 5, + width: 6, + [ + set_power_ok, + set_power_rail_3v3_ok, + set_power_rail_5v0_ok, + set_power_rail_12v0_ok, + set_high_current_operations_allowed, + set_shutdown_requested, + ] + ); + } + + #[test] + fn kicker_telemetry_bitfield_positions() { + assert_bitfield_exhaustive!( + c::KickerTelemetry, + last: set_dribbler_fw_loaded @ 6, + width: 7, + [ + set_error_detected, + set_dribbler_error, + set_power_down_requested, + set_power_down_complete, + set_ball_detected, + set_charge_full, + set_dribbler_fw_loaded, + ] + ); + } + + #[test] + fn basic_telemetry_bitfield_positions() { + assert_bitfield_exhaustive!( + c::BasicTelemetry, + last: set_controller_reset @ 26, + width: 27, + [ + set_power_error, + set_power_board_error, + set_battery_error, + set_battery_low, + set_battery_crit, + set_shutdown_pending, + set_tipped_error, + set_breakbeam_error, + set_breakbeam_ball_detected, + set_accelerometer_0_error, + set_accelerometer_1_error, + set_gyroscope_0_error, + set_gyroscope_1_error, + set_motor_fl_general_error, + set_motor_fl_hall_error, + set_motor_bl_general_error, + set_motor_bl_hall_error, + set_motor_br_general_error, + set_motor_br_hall_error, + set_motor_fr_general_error, + set_motor_fr_hall_error, + set_motor_drib_general_error, + set_motor_drib_hall_error, + set_kicker_board_error, + set_chipper_available, + set_kicker_available, + set_controller_reset, + ] + ); + // Reserved bits above position 26 must not bleed when all status bits set. + let mut t = c::BasicTelemetry::default(); + t._bitfield_1.set(0, 27, (1u64 << 27) - 1); + assert_eq!(t._bitfield_1.get(27, 5), 0, "bits 27-31 must not bleed into reserved region"); + } +} diff --git a/flake.lock b/flake.lock index 5f067fa..4915cac 100644 --- a/flake.lock +++ b/flake.lock @@ -49,11 +49,81 @@ "type": "github" } }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1782093830, + "narHash": "sha256-6gmEVe69+KlRkZD4PEEV5xAlB9CB0Y9TiuEgQjDrKTQ=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "430680a19bc85a3bda55f12e4cc1a1aadcf2e478", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782905613, + "narHash": "sha256-SvXJcAemihifkTn4BGvyE5K1FJX9bl4U8DQ5pqKvD0s=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "7af23cfe91064865ecf2e835da28b45b3c6f49fd", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_2": { + "inputs": { + "nixpkgs": [ + "uv2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782905613, + "narHash": "sha256-SvXJcAemihifkTn4BGvyE5K1FJX9bl4U8DQ5pqKvD0s=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "7af23cfe91064865ecf2e835da28b45b3c6f49fd", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "rust-overlay": "rust-overlay", + "uv2nix": "uv2nix" } }, "rust-overlay": { @@ -88,6 +158,27 @@ "repo": "default", "type": "github" } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": "pyproject-nix_2" + }, + "locked": { + "lastModified": 1783511944, + "narHash": "sha256-Z/Ss9rWw9QYcRK+Qqkmty7PB1pIik5XGbrtit+ad2qs=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "83995ef5e4ece3c9c704aa645bbff439e15a0ac3", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 8f92d64..4337bc7 100644 --- a/flake.nix +++ b/flake.nix @@ -5,16 +5,27 @@ nixpkgs.url = "github:nixos/nixpkgs"; rust-overlay.url = "github:oxalica/rust-overlay"; flake-utils.url = "github:numtide/flake-utils"; + # uv2nix: build Python env from uv.lock (run `uv lock` after editing pyproject.toml) + uv2nix.url = "github:pyproject-nix/uv2nix"; + uv2nix.inputs.nixpkgs.follows = "nixpkgs"; + pyproject-nix.url = "github:pyproject-nix/pyproject.nix"; + pyproject-nix.inputs.nixpkgs.follows = "nixpkgs"; + pyproject-build-systems.url = "github:pyproject-nix/build-system-pkgs"; + pyproject-build-systems.inputs.pyproject-nix.follows = "pyproject-nix"; + pyproject-build-systems.inputs.uv2nix.follows = "uv2nix"; + pyproject-build-systems.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { self, nixpkgs, rust-overlay, flake-utils }: + outputs = { self, nixpkgs, rust-overlay, flake-utils, uv2nix, pyproject-nix, pyproject-build-systems, ... }: flake-utils.lib.eachSystem [ "aarch64-linux" "aarch64-darwin" "x86_64-darwin" "x86_64-linux" ] - (system: - let + (system: + let + inherit (nixpkgs) lib; + overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { @@ -23,9 +34,27 @@ packageName = "ateam-firmware"; + # uv2nix: Python env derived from pyproject.toml + uv.lock. + # Run `uv lock` after editing pyproject.toml to regenerate uv.lock. + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; + + # Third-party packages resolved from uv.lock + build-system backends. + pythonOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + + # pythonSet: all packages from uv.lock + build-system backends (hatchling etc). + # The root project (ateam-common-packets-tools) is virtual (tool.uv.package=false) + # so uv2nix skips building it and only installs its declared dependencies. + pythonSet = (pkgs.callPackage pyproject-nix.build.packages { + python = pkgs.python3; + }).overrideScope (lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + pythonOverlay + ]); + + pythonEnv = pythonSet.mkVirtualEnv "ateam-tools-env" workspace.deps.default; + in { devShell = pkgs.mkShell { - # needed by bindgen to invoke clang shellHook = '' export LIBCLANG_PATH="${pkgs.libclang.lib}/lib" ''; @@ -40,6 +69,13 @@ # needed by bindgen clang + # needed by micropb-gen (build-time proto compiler) + protobuf + + # Python env from uv.lock (protobuf + pytest for protoc_gen_ros2msg.py) + pythonEnv + uv + # Rust Embedded (rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override { extensions = [ "rust-src" ]; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d420578 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "ateam-common-packets-tools" +version = "0.1.0" +description = "Build tools for ateam-common-packets proto plugin and ROS2 msg generation" +requires-python = ">=3.11" +dependencies = [ + "protobuf>=5.0", + "pytest>=8.0", +] + +[tool.uv] +package = false diff --git a/ssl-league-protobufs/proto/ssl_gc_api.proto b/ssl-league-protobufs/proto/ssl_gc_api.proto new file mode 100644 index 0000000..1ef2c6d --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_api.proto @@ -0,0 +1,56 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/api"; + +import "ssl_gc_state.proto"; +import "ssl_gc_change.proto"; +import "ssl_gc_engine.proto"; +import "ssl_gc_engine_config.proto"; + +import "google/protobuf/duration.proto"; + +// Message format that is pushed from the GC to the client +message Output { + // The current match state + optional State match_state = 1; + // The current GC state + optional GcState gc_state = 2; + // The protocol + optional Protocol protocol = 3; + // The engine config + optional Config config = 4; +} + +// The game protocol +message Protocol { + // Is this a delta only? + // Entries that were already sent are not sent again, because the protocol is immutable anyway. + // But if the game is reset, the whole protocol must be replaced. That's what this flag is for. + optional bool delta = 1; + // The (delta) list of entries + repeated ProtocolEntry entry = 2; +} + +// A protocol entry of a change +message ProtocolEntry { + // Id of the entry + optional int32 id = 1; + // The change that was made + optional Change change = 2; + // The match time elapsed when this change was made + optional google.protobuf.Duration match_time_elapsed = 3; + // The stage time elapsed when this change was made + optional google.protobuf.Duration stage_time_elapsed = 4; +} + +// Message format that can be send from the client to the GC +message Input { + // A change to be enqueued into the GC engine + optional Change change = 1; + // Reset the match + optional bool reset_match = 2; + // An updated config delta + optional Config config_delta = 3; + // Continue with action + optional ContinueAction continue_action = 4; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_change.proto b/ssl-league-protobufs/proto/ssl_gc_change.proto new file mode 100644 index 0000000..35c4c30 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_change.proto @@ -0,0 +1,200 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/statemachine"; + +import "ssl_gc_state.proto"; +import "ssl_gc_common.proto"; +import "ssl_gc_geometry.proto"; +import "ssl_gc_game_event.proto"; +import "ssl_gc_referee_message.proto"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +// A state change +message StateChange { + // A unique increasing id + optional int32 id = 1; + // The previous state + optional State state_pre = 2; + // The state after the change was applied + optional State state = 3; + // The change itself + optional Change change = 4; + // The timestamp when the change was triggered + optional google.protobuf.Timestamp timestamp = 5; +} + +// A certain change +message Change { + // An identifier of the origin that triggered the change + optional string origin = 1; + // Is this change revertible? + optional bool revertible = 16; + + oneof change { + NewCommand new_command_change = 2; + ChangeStage change_stage_change = 3; + SetBallPlacementPos set_ball_placement_pos_change = 4; + AddYellowCard add_yellow_card_change = 5; + AddRedCard add_red_card_change = 6; + YellowCardOver yellow_card_over_change = 7; + AddGameEvent add_game_event_change = 8; + AddPassiveGameEvent add_passive_game_event_change = 19; + AddProposal add_proposal_change = 9; + UpdateConfig update_config_change = 12; + UpdateTeamState update_team_state_change = 13; + SwitchColors switch_colors_change = 14; + Revert revert_change = 15; + NewGameState new_game_state_change = 17; + AcceptProposalGroup accept_proposal_group_change = 18; + SetStatusMessage set_status_message_change = 20; + } + + // New referee command + message NewCommand { + // The command + optional Command command = 1; + } + + // Switch to a new stage + message ChangeStage { + // The new stage + optional Referee.Stage new_stage = 1; + } + + // Set the ball placement pos + message SetBallPlacementPos { + // The position in [m] + optional Vector2 pos = 1; + } + + // Add a new yellow card + message AddYellowCard { + // The team that the card is for + optional Team for_team = 1; + // The game event that caused the card + optional GameEvent caused_by_game_event = 2; + } + + // Add a new red card + message AddRedCard { + // The team that the card is for + optional Team for_team = 1; + // The game event that caused the card + optional GameEvent caused_by_game_event = 2; + } + + // Trigger when a yellow card timed out + message YellowCardOver { + // The team that the card was for + optional Team for_team = 1; + } + + // Add a new game event + message AddGameEvent { + // The game event + optional GameEvent game_event = 1; + } + + // Add a new passive game event (that is only logged, but does not automatically trigger anything) + message AddPassiveGameEvent { + // The game event + optional GameEvent game_event = 1; + } + + // Add a new proposal (i.e. from an auto referee for majority voting) + message AddProposal { + // The proposal + optional Proposal proposal = 1; + } + + // Accept a proposal group (that contain one or more proposals of the same type) + message AcceptProposalGroup { + // The id of the group + optional string group_id = 3; + // An identifier of the acceptor + optional string accepted_by = 2; + } + + // Update some configuration + message UpdateConfig { + // The division to play with + optional Division division = 1; + // the team that does/did the first kick off + optional Team first_kickoff_team = 2; + reserved 3; // auto_continue moved to gcState + // The match type + optional MatchType match_type = 4; + // The number of robots per team + optional google.protobuf.Int32Value max_robots_per_team = 5; + } + + // Update the current state of a team (all fields that should be updated are set) + message UpdateTeamState { + // The team + optional Team for_team = 1; + + // Change the name of the team + optional google.protobuf.StringValue team_name = 2; + // Change the number of goals that the teams has at the moment + optional google.protobuf.Int32Value goals = 3; + // The id of the goal keeper + optional google.protobuf.Int32Value goalkeeper = 4; + // The number of timeouts that the team has left + optional google.protobuf.Int32Value timeouts_left = 5; + // The timeout time that the team has left + optional google.protobuf.StringValue timeout_time_left = 6; + // Does the team play on the positive or the negative half (in ssl-vision coordinates)? + optional google.protobuf.BoolValue on_positive_half = 7; + // The number of ball placement failures + optional google.protobuf.Int32Value ball_placement_failures = 8; + // Can the team place the ball, or is ball placement for this team disabled and should be skipped? + optional google.protobuf.BoolValue can_place_ball = 9; + // The number of challenge flags that the team has left + optional google.protobuf.Int32Value challenge_flags_left = 21; + // The number of bot substitutions left by the team in this halftime + optional google.protobuf.Int32Value bot_substitutions_left = 22; + // Does the team want to substitute a robot in the next possible situation? + optional google.protobuf.BoolValue requests_bot_substitution = 10; + // Does the team want to take a timeout in the next possible situation? + optional google.protobuf.BoolValue requests_timeout = 17; + // Does the team want to challenge a recent decision of the referee? + optional google.protobuf.BoolValue requests_challenge = 18; + // Does the team want to request an emergency stop? + optional google.protobuf.BoolValue requests_emergency_stop = 19; + // Update a certain yellow card of the team + optional YellowCard yellow_card = 20; + // Update a certain red card of the team + optional RedCard red_card = 12; + // Update a certain foul of the team + optional Foul foul = 13; + // Remove the yellow card with this id + optional google.protobuf.UInt32Value remove_yellow_card = 14; + // Remove the red card with this id + optional google.protobuf.UInt32Value remove_red_card = 15; + // Remove the foul with this id + optional google.protobuf.UInt32Value remove_foul = 16; + } + + // Switch the team colors + message SwitchColors { + } + + // Revert a certain change + message Revert { + // The id of the change + optional int32 change_id = 1; + } + + // Change the current game state + message NewGameState { + // The new game state + optional GameState game_state = 1; + } + + message SetStatusMessage { + // The new status message + optional string status_message = 1; + } +} diff --git a/ssl-league-protobufs/proto/ssl_gc_ci.proto b/ssl-league-protobufs/proto/ssl_gc_ci.proto new file mode 100644 index 0000000..0eddc73 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_ci.proto @@ -0,0 +1,26 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/ci"; + +import "ssl_vision_wrapper_tracked.proto"; +import "ssl_gc_api.proto"; +import "ssl_gc_referee_message.proto"; +import "ssl_vision_geometry.proto"; + +// The input format to the GC +message CiInput { + // New unix timestamp in [ns] for the GC + optional int64 timestamp = 1; + // New tracker packet with ball and robot data + optional TrackerWrapperPacket tracker_packet = 2; + // (UI) API input + repeated Input api_inputs = 3; + // Update geometry + optional SSL_GeometryData geometry = 4; +} + +// The output format of the GC response +message CiOutput { + // Latest referee message + optional Referee referee_msg = 1; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_common.proto b/ssl-league-protobufs/proto/ssl_gc_common.proto new file mode 100644 index 0000000..796e2d3 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_common.proto @@ -0,0 +1,28 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/state"; + +// Team is either blue or yellow +enum Team { + // team not set + UNKNOWN = 0; + // yellow team + YELLOW = 1; + // blue team + BLUE = 2; +} + +// RobotId is the combination of a team and a robot id +message RobotId { + // the robot number + optional uint32 id = 1; + // the team that the robot belongs to + optional Team team = 2; +} + +// Division denotes the current division, which influences some rules +enum Division { + DIV_UNKNOWN = 0; + DIV_A = 1; + DIV_B = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_engine.proto b/ssl-league-protobufs/proto/ssl_gc_engine.proto new file mode 100644 index 0000000..6e58206 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_engine.proto @@ -0,0 +1,150 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/engine"; + +import "ssl_gc_geometry.proto"; +import "ssl_gc_common.proto"; + +import "google/protobuf/timestamp.proto"; + +// The GC state contains settings and state independent of the match state +message GcState { + // the state of each team + map team_state = 1; + + // the states of the auto referees + map auto_ref_state = 2; + + // the attached trackers (uuid -> source_name) + map trackers = 3; + + // the next actions that can be executed when continuing + repeated ContinueAction continue_actions = 4; + + // the next actions that can be executed when continuing + repeated ContinueHint continue_hints = 5; +} + +// The GC state for a single team +message GcStateTeam { + // true: The team is connected + optional bool connected = 1; + + // true: The team connected via TLS with a verified certificate + optional bool connection_verified = 2; + + // true: The remote control for the team is connected + optional bool remote_control_connected = 3; + + // true: The remote control for the team connected via TLS with a verified certificate + optional bool remote_control_connection_verified = 4; + + // the advantage choice of the team + optional TeamAdvantageChoice advantage_choice = 5; +} + +// The choice from a team regarding the advantage rule +message TeamAdvantageChoice { + // the choice of the team + optional AdvantageChoice choice = 1; + + // possible advantage choices + enum AdvantageChoice { + // stop the game + STOP = 0; + // keep the match running + CONTINUE = 1; + } +} + +// The GC state of an auto referee +message GcStateAutoRef { + // true: The autoRef connected via TLS with a verified certificate + optional bool connection_verified = 1; +} + +// GC state of a tracker +message GcStateTracker { + // Name of the source + optional string source_name = 1; + + // UUID of the source + optional string uuid = 4; + + // Current ball + optional Ball ball = 2; + + // Current robots + repeated Robot robots = 3; +} + +// The ball state +message Ball { + // ball position [m] + optional Vector3 pos = 1; + + // ball velocity [m/s] + optional Vector3 vel = 2; +} + +// The robot state +message Robot { + // robot id and team + optional RobotId id = 1; + + // robot position [m] + optional Vector2 pos = 2; +} + +message ContinueAction { + // type of action that will be performed next + required Type type = 1; + + // for which team (if team specific) + required Team for_team = 2; + + // list of issues that hinders the game from continuing + repeated string continuation_issues = 3; + + // timestamp at which the action will be ready (to give some preparation time) + optional google.protobuf.Timestamp ready_at = 4; + + // state of the action + optional State state = 5; + + enum Type { + TYPE_UNKNOWN = 0; + HALT = 1; + RESUME_FROM_HALT = 10; + STOP_GAME = 2; + FORCE_START = 11; + FREE_KICK = 17; + NEXT_COMMAND = 3; + BALL_PLACEMENT_START = 4; + BALL_PLACEMENT_CANCEL = 9; + BALL_PLACEMENT_COMPLETE = 14; + BALL_PLACEMENT_FAIL = 15; + TIMEOUT_START = 5; + TIMEOUT_STOP = 6; + BOT_SUBSTITUTION = 7; + NEXT_STAGE = 8; + END_GAME = 16; + ACCEPT_GOAL = 12; + NORMAL_START = 13; + CHALLENGE_ACCEPT = 18; + CHALLENGE_REJECT = 19; + } + + enum State { + STATE_UNKNOWN = 0; + BLOCKED = 1; + WAITING = 2; + READY_AUTO = 3; + READY_MANUAL = 4; + DISABLED = 5; + } +} + +message ContinueHint { + required string message = 1; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_engine_config.proto b/ssl-league-protobufs/proto/ssl_gc_engine_config.proto new file mode 100644 index 0000000..b523840 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_engine_config.proto @@ -0,0 +1,55 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/engine"; + +// The engine config +message Config { + // The behavior for each game event + map game_event_behavior = 1; + + // The config for each auto referee + map auto_ref_configs = 2; + + // The selected tracker source + optional string active_tracker_source = 3; + + // The list of available teams + repeated string teams = 4; + + // Enable or disable auto continuation + optional bool auto_continue = 5; + + // Behaviors for each game event + enum Behavior { + // Not set or unknown + BEHAVIOR_UNKNOWN = 0; + // Always accept the game event + BEHAVIOR_ACCEPT = 1; + // Accept the game event if was reported by a majority + BEHAVIOR_ACCEPT_MAJORITY = 2; + // Only propose the game event (can be accepted in the UI by a human) + BEHAVIOR_PROPOSE_ONLY = 3; + // Only log the game event to the protocol + BEHAVIOR_LOG = 4; + // Silently ignore the game event + BEHAVIOR_IGNORE = 5; + } +} + +// The config for an auto referee +message AutoRefConfig { + // The game event behaviors for this auto referee + map game_event_behavior = 1; + + // Behaviors for the game events reported by this auto referee + enum Behavior { + // Not set or unknown + BEHAVIOR_UNKNOWN = 0; + // Accept the game event + BEHAVIOR_ACCEPT = 1; + // Log the game event + BEHAVIOR_LOG = 2; + // Silently ignore the game event + BEHAVIOR_IGNORE = 3; + } +} diff --git a/ssl-league-protobufs/proto/ssl_gc_game_event.proto b/ssl-league-protobufs/proto/ssl_gc_game_event.proto new file mode 100644 index 0000000..78b68cc --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_game_event.proto @@ -0,0 +1,596 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/state"; + +import "ssl_gc_common.proto"; +import "ssl_gc_geometry.proto"; + +// GameEvent contains exactly one game event +// Each game event has optional and required fields. The required fields are mandatory to process the event. +// Some optional fields are only used for visualization, others are required to determine the ball placement position. +// If fields are missing that are required for the ball placement position, no ball placement command will be issued. +// Fields are marked optional to make testing and extending of the protocol easier. +// An autoRef should ideally set all fields, except if there are good reasons to not do so. +message GameEvent { + + // A globally unique id of the game event. + optional string id = 50; + + // The type of the game event. + optional Type type = 40; + + // The origins of this game event. + // Empty, if it originates from game controller. + // Contains autoRef name(s), if it originates from one or more autoRefs. + // Ignored if sent by autoRef to game controller. + repeated string origin = 41; + + // Unix timestamp in microseconds when the event was created. + optional uint64 created_timestamp = 49; + + // the event that occurred + oneof event { + + // Ball out of field events (stopping) + + BallLeftField ball_left_field_touch_line = 6; + BallLeftField ball_left_field_goal_line = 7; + AimlessKick aimless_kick = 11; + + // Stopping Fouls + + AttackerTooCloseToDefenseArea attacker_too_close_to_defense_area = 19; + DefenderInDefenseArea defender_in_defense_area = 31; + BoundaryCrossing boundary_crossing = 43; + KeeperHeldBall keeper_held_ball = 13; + BotDribbledBallTooFar bot_dribbled_ball_too_far = 17; + + BotPushedBot bot_pushed_bot = 24; + BotHeldBallDeliberately bot_held_ball_deliberately = 26; + BotTippedOver bot_tipped_over = 27; + BotDroppedParts bot_dropped_parts = 51; + + // Non-Stopping Fouls + + AttackerTouchedBallInDefenseArea attacker_touched_ball_in_defense_area = 15; + BotKickedBallTooFast bot_kicked_ball_too_fast = 18; + BotCrashUnique bot_crash_unique = 22; + BotCrashDrawn bot_crash_drawn = 21; + + // Fouls while ball out of play + + DefenderTooCloseToKickPoint defender_too_close_to_kick_point = 29; + BotTooFastInStop bot_too_fast_in_stop = 28; + BotInterferedPlacement bot_interfered_placement = 20; + + // Scoring goals + + Goal possible_goal = 39; + Goal goal = 8; + Goal invalid_goal = 44; + + // Other events + + AttackerDoubleTouchedBall attacker_double_touched_ball = 14; + PlacementSucceeded placement_succeeded = 5; + PenaltyKickFailed penalty_kick_failed = 45; + + NoProgressInGame no_progress_in_game = 2; + PlacementFailed placement_failed = 3; + MultipleCards multiple_cards = 32; + MultipleFouls multiple_fouls = 34; + BotSubstitution bot_substitution = 37; + ExcessiveBotSubstitution excessive_bot_substitution = 52; + TooManyRobots too_many_robots = 38; + ChallengeFlag challenge_flag = 46; + ChallengeFlagHandled challenge_flag_handled = 48; + EmergencyStop emergency_stop = 47; + + UnsportingBehaviorMinor unsporting_behavior_minor = 35; + UnsportingBehaviorMajor unsporting_behavior_major = 36; + + // Deprecated events + + // replaced by ready_to_continue flag + Prepared prepared = 1 [deprecated = true]; + // obsolete + IndirectGoal indirect_goal = 9 [deprecated = true]; + // replaced by the meta-information in the possible_goal event + ChippedGoal chipped_goal = 10 [deprecated = true]; + // obsolete + KickTimeout kick_timeout = 12 [deprecated = true]; + // rule removed + AttackerTouchedOpponentInDefenseArea attacker_touched_opponent_in_defense_area = 16 [deprecated = true]; + // obsolete + AttackerTouchedOpponentInDefenseArea attacker_touched_opponent_in_defense_area_skipped = 42 [deprecated = true]; + // obsolete + BotCrashUnique bot_crash_unique_skipped = 23 [deprecated = true]; + // can not be used as long as autoRefs do not judge pushing + BotPushedBot bot_pushed_bot_skipped = 25 [deprecated = true]; + // rule removed + DefenderInDefenseAreaPartially defender_in_defense_area_partially = 30 [deprecated = true]; + // the referee msg already indicates this + MultiplePlacementFailures multiple_placement_failures = 33 [deprecated = true]; + } + + // the ball left the field normally + message BallLeftField { + // the team that last touched the ball + required Team by_team = 1; + // the bot that last touched the ball + optional uint32 by_bot = 2; + // the location where the ball left the field [m] + optional Vector2 location = 3; + } + // the ball left the field via goal line and a team committed an aimless kick + message AimlessKick { + // the team that last touched the ball + required Team by_team = 1; + // the bot that last touched the ball + optional uint32 by_bot = 2; + // the location where the ball left the field [m] + optional Vector2 location = 3; + // the location where the ball was last touched [m] + optional Vector2 kick_location = 4; + } + // a team shot a goal + message Goal { + // the team that scored the goal + required Team by_team = 1; + // the team that shot the goal (different from by_team for own goals) + optional Team kicking_team = 6; + // the bot that shot the goal + optional uint32 kicking_bot = 2; + // the location where the ball entered the goal [m] + optional Vector2 location = 3; + // the location where the ball was kicked (for deciding if this was a valid goal) [m] + optional Vector2 kick_location = 4; + // the maximum height the ball reached during the goal kick (for deciding if this was a valid goal) [m] + optional float max_ball_height = 5; + // number of robots of scoring team when the ball entered the goal (for deciding if this was a valid goal) + optional uint32 num_robots_by_team = 7; + // The UNIX timestamp [μs] when the scoring team last touched the ball + optional uint64 last_touch_by_team = 8; + // An additional message with e.g. a reason for invalid goals + optional string message = 9; + } + // the ball entered the goal directly during an indirect free kick + message IndirectGoal { + // the team that tried to shoot the goal + required Team by_team = 1; + // the bot that kicked the ball - at least the team must be set + optional uint32 by_bot = 2; + // the location where the ball entered the goal [m] + optional Vector2 location = 3; + // the location where the ball was kicked [m] + optional Vector2 kick_location = 4; + } + // the ball entered the goal, but was initially chipped + message ChippedGoal { + // the team that tried to shoot the goal + required Team by_team = 1; + // the bot that kicked the ball + optional uint32 by_bot = 2; + // the location where the ball entered the goal [m] + optional Vector2 location = 3; + // the location where the ball was kicked [m] + optional Vector2 kick_location = 4; + // the maximum height [m] of the ball, before it entered the goal and since the last kick [m] + optional float max_ball_height = 5; + } + // a bot moved too fast while the game was stopped + message BotTooFastInStop { + // the team that found guilty + required Team by_team = 1; + // the bot that was too fast + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + // the bot speed [m/s] + optional float speed = 4; + } + // a bot of the defending team got too close to the kick point during a free kick + message DefenderTooCloseToKickPoint { + // the team that was found guilty + required Team by_team = 1; + // the bot that violates the distance to the kick point + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + // the distance [m] from bot to the kick point (including the minimum radius) + optional float distance = 4; + } + // two robots crashed into each other with similar speeds + message BotCrashDrawn { + // the bot of the yellow team + optional uint32 bot_yellow = 1; + // the bot of the blue team + optional uint32 bot_blue = 2; + // the location of the crash (center between both bots) [m] + optional Vector2 location = 3; + // the calculated crash speed [m/s] of the two bots + optional float crash_speed = 4; + // the difference [m/s] of the velocity of the two bots + optional float speed_diff = 5; + // the angle [rad] in the range [0, π] of the bot velocity vectors + // an angle of 0 rad ( 0°) means, the bots barely touched each other + // an angle of π rad (180°) means, the bots crashed frontal into each other + optional float crash_angle = 6; + } + // two robots crashed into each other and one team was found guilty to due significant speed difference + message BotCrashUnique { + // the team that caused the crash + required Team by_team = 1; + // the bot that caused the crash + optional uint32 violator = 2; + // the bot of the opposite team that was involved in the crash + optional uint32 victim = 3; + // the location of the crash (center between both bots) [m] + optional Vector2 location = 4; + // the calculated crash speed vector [m/s] of the two bots + optional float crash_speed = 5; + // the difference [m/s] of the velocity of the two bots + optional float speed_diff = 6; + // the angle [rad] in the range [0, π] of the bot velocity vectors + // an angle of 0 rad ( 0°) means, the bots barely touched each other + // an angle of π rad (180°) means, the bots crashed frontal into each other + optional float crash_angle = 7; + } + // a bot pushed another bot over a significant distance + message BotPushedBot { + // the team that pushed the other team + required Team by_team = 1; + // the bot that pushed the other bot + optional uint32 violator = 2; + // the bot of the opposite team that was pushed + optional uint32 victim = 3; + // the location of the push (center between both bots) [m] + optional Vector2 location = 4; + // the pushed distance [m] + optional float pushed_distance = 5; + } + // a bot tipped over + message BotTippedOver { + // the team that found guilty + required Team by_team = 1; + // the bot that tipped over + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + // the location of the ball at the moment when this foul occurred [m] + optional Vector2 ball_location = 4; + } + // a bot dropped parts + message BotDroppedParts { + // the team that found guilty + required Team by_team = 1; + // the bot that dropped the parts + optional uint32 by_bot = 2; + // the location where the parts were dropped [m] + optional Vector2 location = 3; + // the location of the ball at the moment when this foul occurred [m] + optional Vector2 ball_location = 4; + } + // a defender other than the keeper was fully located inside its own defense and touched the ball + message DefenderInDefenseArea { + // the team that found guilty + required Team by_team = 1; + // the bot that is inside the penalty area + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + // the distance [m] from bot case to the nearest point outside the defense area + optional float distance = 4; + } + // a defender other than the keeper was partially located inside its own defense area and touched the ball + message DefenderInDefenseAreaPartially { + // the team that found guilty + required Team by_team = 1; + // the bot that is partially inside the penalty area + optional uint32 by_bot = 2; + // the location of the bot + optional Vector2 location = 3; + // the distance [m] that the bot is inside the penalty area + optional float distance = 4; + // the location of the ball at the moment when this foul occurred [m] + optional Vector2 ball_location = 5; + } + // an attacker touched the ball inside the opponent defense area + message AttackerTouchedBallInDefenseArea { + // the team that found guilty + required Team by_team = 1; + // the bot that is inside the penalty area + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + // the distance [m] that the bot is inside the penalty area + optional float distance = 4; + } + // a bot kicked the ball too fast + message BotKickedBallTooFast { + // the team that found guilty + required Team by_team = 1; + // the bot that kicked too fast + optional uint32 by_bot = 2; + // the location of the ball at the time of the highest speed [m] + optional Vector2 location = 3; + // the absolute initial ball speed (kick speed) [m/s] + optional float initial_ball_speed = 4; + // was the ball chipped? + optional bool chipped = 5; + } + // a bot dribbled to ball too far + message BotDribbledBallTooFar { + // the team that found guilty + required Team by_team = 1; + // the bot that dribbled too far + optional uint32 by_bot = 2; + // the location where the dribbling started [m] + optional Vector2 start = 3; + // the location where the maximum dribbling distance was reached [m] + optional Vector2 end = 4; + } + // an attacker touched the opponent robot inside defense area + message AttackerTouchedOpponentInDefenseArea { + // the team that found guilty + required Team by_team = 1; + // the bot that touched the opponent robot + optional uint32 by_bot = 2; + // the bot of the opposite team that was touched + optional uint32 victim = 4; + // the location of the contact point between both bots [m] + optional Vector2 location = 3; + } + // an attacker touched the ball multiple times when it was not allowed to + message AttackerDoubleTouchedBall { + // the team that found guilty + required Team by_team = 1; + // the bot that touched the ball twice + optional uint32 by_bot = 2; + // the location of the ball when it was first touched [m] + optional Vector2 location = 3; + } + // an attacker was located too near to the opponent defense area during stop or free kick + message AttackerTooCloseToDefenseArea { + // the team that found guilty + required Team by_team = 1; + // the bot that is too close to the defense area + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + // the distance [m] of the bot to the penalty area + optional float distance = 4; + // the location of the ball at the moment when this foul occurred [m] + optional Vector2 ball_location = 5; + } + // a bot held the ball for too long + message BotHeldBallDeliberately { + // the team that found guilty + required Team by_team = 1; + // the bot that holds the ball + optional uint32 by_bot = 2; + // the location of the ball [m] + optional Vector2 location = 3; + // the duration [s] that the bot hold the ball + optional float duration = 4; + } + // a bot interfered the ball placement of the other team + message BotInterferedPlacement { + // the team that found guilty + required Team by_team = 1; + // the bot that interfered the placement + optional uint32 by_bot = 2; + // the location of the bot [m] + optional Vector2 location = 3; + } + // a team collected multiple yellow cards + message MultipleCards { + // the team that received multiple yellow cards + required Team by_team = 1; + } + // a team collected multiple fouls, which results in a yellow card + message MultipleFouls { + // the team that collected multiple fouls + required Team by_team = 1; + // the list of game events that caused the multiple fouls + repeated GameEvent caused_game_events = 2; + } + // a team failed to place the ball multiple times in a row + message MultiplePlacementFailures { + // the team that failed multiple times + required Team by_team = 1; + } + // timeout waiting for the attacking team to perform the free kick + message KickTimeout { + // the team that that should have kicked + required Team by_team = 1; + // the location of the ball [m] + optional Vector2 location = 2; + // the time [s] that was waited + optional float time = 3; + } + // game was stuck + message NoProgressInGame { + // the location of the ball + optional Vector2 location = 1; + // the time [s] that was waited + optional float time = 2; + } + // ball placement failed + message PlacementFailed { + // the team that failed + required Team by_team = 1; + // the remaining distance [m] from ball to placement position + optional float remaining_distance = 2; + // the distance [m] of the nearest own robot to the ball + optional float nearest_own_bot_distance = 3; + } + // a team was found guilty for minor unsporting behavior + message UnsportingBehaviorMinor { + // the team that found guilty + required Team by_team = 1; + // an explanation of the situation and decision + required string reason = 2; + } + // a team was found guilty for major unsporting behavior + message UnsportingBehaviorMajor { + // the team that found guilty + required Team by_team = 1; + // an explanation of the situation and decision + required string reason = 2; + } + // a keeper held the ball in its defense area for too long + message KeeperHeldBall { + // the team that found guilty + required Team by_team = 1; + // the location of the ball [m] + optional Vector2 location = 2; + // the duration [s] that the keeper hold the ball + optional float duration = 3; + } + // a team successfully placed the ball + message PlacementSucceeded { + // the team that did the placement + required Team by_team = 1; + // the time [s] taken for placing the ball + optional float time_taken = 2; + // the distance [m] between placement location and actual ball position + optional float precision = 3; + // the distance [m] between the initial ball location and the placement position + optional float distance = 4; + } + // both teams are prepared - all conditions are met to continue (with kickoff or penalty kick) + message Prepared { + // the time [s] taken for preparing + optional float time_taken = 1; + } + // bots are being substituted by a team + message BotSubstitution { + // the team that substitutes robots + required Team by_team = 1; + } + // A foul for excessive bot substitutions + message ExcessiveBotSubstitution { + // the team that substitutes robots + required Team by_team = 1; + } + // A challenge flag, requested by a team previously, is flagged + message ChallengeFlag { + // the team that requested the challenge flag + required Team by_team = 1; + } + // A challenge, flagged recently, has been handled by the referee + message ChallengeFlagHandled { + // the team that requested the challenge flag + required Team by_team = 1; + // the challenge was accepted by the referee + required bool accepted = 2; + } + // An emergency stop, requested by team previously, occurred + message EmergencyStop { + // the team that substitutes robots + required Team by_team = 1; + } + // a team has too many robots on the field + message TooManyRobots { + // the team that has too many robots + required Team by_team = 1; + // number of robots allowed at the moment + optional int32 num_robots_allowed = 2; + // number of robots currently on the field + optional int32 num_robots_on_field = 3; + // the location of the ball at the moment when this foul occurred [m] + optional Vector2 ball_location = 4; + } + // a robot chipped the ball over the field boundary out of the playing surface + message BoundaryCrossing { + // the team that has too many robots + required Team by_team = 1; + // the location of the ball [m] + optional Vector2 location = 2; + } + // the penalty kick failed (by time or by keeper) + message PenaltyKickFailed { + // the team that last touched the ball + required Team by_team = 1; + // the location of the ball at the moment of this event [m] + optional Vector2 location = 2; + // an explanation of the failure + optional string reason = 3; + } + + enum Type { + UNKNOWN_GAME_EVENT_TYPE = 0; + + // Ball out of field events (stopping) + + BALL_LEFT_FIELD_TOUCH_LINE = 6; // triggered by autoRef + BALL_LEFT_FIELD_GOAL_LINE = 7; // triggered by autoRef + AIMLESS_KICK = 11; // triggered by autoRef + + // Stopping Fouls + + ATTACKER_TOO_CLOSE_TO_DEFENSE_AREA = 19; // triggered by autoRef + DEFENDER_IN_DEFENSE_AREA = 31; // triggered by autoRef + BOUNDARY_CROSSING = 41; // triggered by autoRef + KEEPER_HELD_BALL = 13; // triggered by GC + BOT_DRIBBLED_BALL_TOO_FAR = 17; // triggered by autoRef + + BOT_PUSHED_BOT = 24; // triggered by human ref + BOT_HELD_BALL_DELIBERATELY = 26; // triggered by human ref + BOT_TIPPED_OVER = 27; // triggered by human ref + BOT_DROPPED_PARTS = 47; // triggered by human ref + + // Non-Stopping Fouls + + ATTACKER_TOUCHED_BALL_IN_DEFENSE_AREA = 15; // triggered by autoRef + BOT_KICKED_BALL_TOO_FAST = 18; // triggered by autoRef + BOT_CRASH_UNIQUE = 22; // triggered by autoRef + BOT_CRASH_DRAWN = 21; // triggered by autoRef + + // Fouls while ball out of play + + DEFENDER_TOO_CLOSE_TO_KICK_POINT = 29; // triggered by autoRef + BOT_TOO_FAST_IN_STOP = 28; // triggered by autoRef + BOT_INTERFERED_PLACEMENT = 20; // triggered by autoRef + EXCESSIVE_BOT_SUBSTITUTION = 48; // triggered by GC + + // Scoring goals + + POSSIBLE_GOAL = 39; // triggered by autoRef + GOAL = 8; // triggered by GC + INVALID_GOAL = 42; // triggered by GC + + // Other events + + ATTACKER_DOUBLE_TOUCHED_BALL = 14; // triggered by autoRef + PLACEMENT_SUCCEEDED = 5; // triggered by autoRef + PENALTY_KICK_FAILED = 43; // triggered by GC and autoRef + + NO_PROGRESS_IN_GAME = 2; // triggered by GC + PLACEMENT_FAILED = 3; // triggered by GC + MULTIPLE_CARDS = 32; // triggered by GC + MULTIPLE_FOULS = 34; // triggered by GC + BOT_SUBSTITUTION = 37; // triggered by GC + TOO_MANY_ROBOTS = 38; // triggered by GC + CHALLENGE_FLAG = 44; // triggered by GC + CHALLENGE_FLAG_HANDLED = 46; // triggered by GC + EMERGENCY_STOP = 45; // triggered by GC + + UNSPORTING_BEHAVIOR_MINOR = 35; // triggered by human ref + UNSPORTING_BEHAVIOR_MAJOR = 36; // triggered by human ref + + // Deprecated events + + PREPARED = 1 [deprecated = true]; + INDIRECT_GOAL = 9 [deprecated = true]; + CHIPPED_GOAL = 10 [deprecated = true]; + KICK_TIMEOUT = 12 [deprecated = true]; + ATTACKER_TOUCHED_OPPONENT_IN_DEFENSE_AREA = 16 [deprecated = true]; + ATTACKER_TOUCHED_OPPONENT_IN_DEFENSE_AREA_SKIPPED = 40 [deprecated = true]; + BOT_CRASH_UNIQUE_SKIPPED = 23 [deprecated = true]; + BOT_PUSHED_BOT_SKIPPED = 25 [deprecated = true]; + DEFENDER_IN_DEFENSE_AREA_PARTIALLY = 30 [deprecated = true]; + MULTIPLE_PLACEMENT_FAILURES = 33 [deprecated = true]; + } +} diff --git a/ssl-league-protobufs/proto/ssl_gc_geometry.proto b/ssl-league-protobufs/proto/ssl_gc_geometry.proto new file mode 100644 index 0000000..47f04f6 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_geometry.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/geom"; + +// A vector with two dimensions +message Vector2 { + required float x = 1; + required float y = 2; +} + +// A vector with three dimensions +message Vector3 { + required float x = 1; + required float y = 2; + required float z = 3; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_rcon.proto b/ssl-league-protobufs/proto/ssl_gc_rcon.proto new file mode 100644 index 0000000..91d9b5c --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_rcon.proto @@ -0,0 +1,38 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/rcon"; + +// a reply that is sent by the controller for each request from teams or autoRefs +message ControllerReply { + // status_code is an optional code that indicates the result of the last request + optional StatusCode status_code = 1; + // reason is an optional explanation of the status code + optional string reason = 2; + // next_token must be send with the next request, if secure communication is used + // the token is used to avoid replay attacks + // the token is always present in the very first message before the registration starts + // the token is not present, if secure communication is not used + optional string next_token = 3; + // verification indicates if the last request could be verified (secure communication) + optional Verification verification = 4; + + enum StatusCode { + UNKNOWN_STATUS_CODE = 0; + OK = 1; + REJECTED = 2; + } + + enum Verification { + UNKNOWN_VERIFICATION = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } +} + +// Signature can be added to a request to let it be verfied by the controller +message Signature { + // the token that was received with the last controller reply + required string token = 1; + // the PKCS1v15 signature of this message + required bytes pkcs1v15 = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_rcon_autoref.proto b/ssl-league-protobufs/proto/ssl_gc_rcon_autoref.proto new file mode 100644 index 0000000..56d2e7a --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_rcon_autoref.proto @@ -0,0 +1,32 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/rcon"; + +import "ssl_gc_game_event.proto"; +import "ssl_gc_rcon.proto"; + +// AutoRefRegistration is the first message that a client must send to the controller to identify itself +message AutoRefRegistration { + // identifier is a unique name of the client + required string identifier = 1; + // signature can optionally be specified to enable secure communication + optional Signature signature = 2; +} + +// AutoRefToController is the wrapper message for all subsequent messages from the autoRef to the controller +message AutoRefToController { + // reserve fields for removed fields + reserved 3, 4; + // signature can optionally be specified to enable secure communication + optional Signature signature = 1; + // game_event is an optional event that the autoRef detected during the game + optional GameEvent game_event = 2; +} + +// ControllerToAutoRef is the wrapper message for all messages from controller to autoRef +message ControllerToAutoRef { + oneof msg { + // a reply from the controller + ControllerReply controller_reply = 1; + } +} \ No newline at end of file diff --git a/ssl-league-protobufs/proto/ssl_gc_rcon_remotecontrol.proto b/ssl-league-protobufs/proto/ssl_gc_rcon_remotecontrol.proto new file mode 100644 index 0000000..4e2717e --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_rcon_remotecontrol.proto @@ -0,0 +1,117 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/rcon"; + +import "ssl_gc_common.proto"; +import "ssl_gc_rcon.proto"; + +// a registration that must be send by the remote control to the controller as the very first message +message RemoteControlRegistration { + // the team to be controlled + required Team team = 1; + // signature can optionally be specified to enable secure communication + optional Signature signature = 2; +} + +// wrapper for all messages from the remote control to the controller +message RemoteControlToController { + // signature can optionally be specified to enable secure communication + optional Signature signature = 1; + + oneof msg { + // send a ping to the GC to test if the connection is still open. + // the value is ignored and a reply is sent back + Request request = 2; + + // request a new desired keeper id + int32 desired_keeper = 3; + + // true: request to substitute a robot at the next possibility + // false: cancel request + bool request_robot_substitution = 4; + + // true: request a timeout with the next stoppage + // false: cancel the request + bool request_timeout = 5; + + // true: initiate an emergency stop + // false: cancel the request + bool request_emergency_stop = 6; + } + + enum Request { + UNKNOWN = 0; + // Ping the GC to test the connection. The GC will respond with OK and the current team state + PING = 1; + // Raise a challenge flag (this is not revocable) + CHALLENGE_FLAG = 2; + // Stop an ongoing timeout + STOP_TIMEOUT = 3; + } +} + +// wrapper for all messages from controller to a team's computer +message ControllerToRemoteControl { + // a reply from the controller + optional ControllerReply controller_reply = 1; + + // current team state + optional RemoteControlTeamState state = 2; +} + +// Current team state from Controller for remote control +message RemoteControlTeamState { + // the team that is controlled + optional Team team = 12; + + // list of all currently available request types that can be made + repeated RemoteControlRequestType available_requests = 1; + + // list of all currently active request types that are pending + repeated RemoteControlRequestType active_requests = 2; + + // currently set keeper id + optional int32 keeper_id = 3; + + // number of seconds till emergency stop is executed + // zero, if no emergency stop requested + optional float emergency_stop_in = 4; + + // number of timeouts left for the team + optional int32 timeouts_left = 5; + + // number of seconds left for timeout for the team + optional float timeout_time_left = 10; + + // number of challenge flags left for the team + optional int32 challenge_flags_left = 6; + + // max number of robots currently allowed + optional int32 max_robots = 7; + + // current number of robots visible on field + optional int32 robots_on_field = 9; + + // list of due times for each active yellow card (in seconds) + repeated float yellow_cards_due = 8; + + // if true, team is allowed to substitute robots + optional bool can_substitute_robot = 11; + + // number of bot substitutions left by the team in this halftime + optional uint32 bot_substitutions_left = 13; + + // number of seconds left for current bot substitution + optional float bot_substitution_time_left = 14; +} + +// All possible request types that the remote control can make +enum RemoteControlRequestType { + UNKNOWN_REQUEST_TYPE = 0; + EMERGENCY_STOP = 1; + ROBOT_SUBSTITUTION = 2; + TIMEOUT = 3; + CHALLENGE_FLAG = 4; + CHANGE_KEEPER_ID = 5; + STOP_TIMEOUT = 6; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_rcon_team.proto b/ssl-league-protobufs/proto/ssl_gc_rcon_team.proto new file mode 100644 index 0000000..a3f0772 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_rcon_team.proto @@ -0,0 +1,56 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/rcon"; + +import "ssl_gc_rcon.proto"; +import "ssl_gc_common.proto"; + +// a registration that must be send by teams to the controller as the very first message +message TeamRegistration { + // the exact team name as published by the game-controller + required string team_name = 1; + // signature can optionally be specified to enable secure communication + optional Signature signature = 2; + // the team (relevant only if a team plays against itself) + optional Team team = 3; +} + +// wrapper for all messages from a team's computer to the controller +message TeamToController { + // signature can optionally be specified to enable secure communication + optional Signature signature = 1; + + oneof msg { + // request a new desired keeper id + int32 desired_keeper = 2; + // response to an advantage choice request + AdvantageChoice advantage_choice = 3; + // request to substitute a robot at the next possibility + bool substitute_bot = 4; + // send a ping to the GC to test if the connection is still open. + // the value is ignored and a reply is sent back + bool ping = 5; + } +} + +// the current advantage choice of the team +// the choice is valid until another choice is received +// if the team disconnects, the choice is reset to its default (STOP) +// teams may either send their current choice continuously or only on change +enum AdvantageChoice { + // stop the game + STOP = 0; + // keep the game running + CONTINUE = 1; +} + +// wrapper for all messages from controller to a team's computer +message ControllerToTeam { + // reserve obsolete field ids + reserved 2; + + oneof msg { + // a reply from the controller + ControllerReply controller_reply = 1; + } +} diff --git a/ssl-league-protobufs/proto/ssl_gc_referee_message.proto b/ssl-league-protobufs/proto/ssl_gc_referee_message.proto new file mode 100644 index 0000000..d71a4b1 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_referee_message.proto @@ -0,0 +1,238 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/state"; + +import "ssl_gc_game_event.proto"; + +// Each UDP packet contains one of these messages. +message Referee { + // A random UUID of the source that is kept constant at the source while running + // If multiple sources are broadcasting to the same network, this id can be used to identify individual sources + optional string source_identifier = 18; + + // The match type is a meta information about the current match that helps to process the logs after a competition + optional MatchType match_type = 19 [default = UNKNOWN_MATCH]; + + // The UNIX timestamp when the packet was sent, in microseconds. + // Divide by 1,000,000 to get a time_t. + required uint64 packet_timestamp = 1; + + // These are the "coarse" stages of the game. + enum Stage { + // The first half is about to start. + // A kickoff is called within this stage. + // This stage ends with the NORMAL_START. + NORMAL_FIRST_HALF_PRE = 0; + // The first half of the normal game, before half time. + NORMAL_FIRST_HALF = 1; + // Half time between first and second halves. + NORMAL_HALF_TIME = 2; + // The second half is about to start. + // A kickoff is called within this stage. + // This stage ends with the NORMAL_START. + NORMAL_SECOND_HALF_PRE = 3; + // The second half of the normal game, after half time. + NORMAL_SECOND_HALF = 4; + // The break before extra time. + EXTRA_TIME_BREAK = 5; + // The first half of extra time is about to start. + // A kickoff is called within this stage. + // This stage ends with the NORMAL_START. + EXTRA_FIRST_HALF_PRE = 6; + // The first half of extra time. + EXTRA_FIRST_HALF = 7; + // Half time between first and second extra halves. + EXTRA_HALF_TIME = 8; + // The second half of extra time is about to start. + // A kickoff is called within this stage. + // This stage ends with the NORMAL_START. + EXTRA_SECOND_HALF_PRE = 9; + // The second half of extra time. + EXTRA_SECOND_HALF = 10; + // The break before penalty shootout. + PENALTY_SHOOTOUT_BREAK = 11; + // The penalty shootout. + PENALTY_SHOOTOUT = 12; + // The game is over. + POST_GAME = 13; + } + required Stage stage = 2; + + // The number of microseconds left in the stage. + // The following stages have this value; the rest do not: + // NORMAL_FIRST_HALF + // NORMAL_HALF_TIME + // NORMAL_SECOND_HALF + // EXTRA_TIME_BREAK + // EXTRA_FIRST_HALF + // EXTRA_HALF_TIME + // EXTRA_SECOND_HALF + // PENALTY_SHOOTOUT_BREAK + // + // If the stage runs over its specified time, this value + // becomes negative. + optional sint64 stage_time_left = 3; + + // These are the "fine" states of play on the field. + enum Command { + // All robots should completely stop moving. + HALT = 0; + // Robots must keep 50 cm from the ball. + STOP = 1; + // A prepared kickoff or penalty may now be taken. + NORMAL_START = 2; + // The ball is dropped and free for either team. + FORCE_START = 3; + // The yellow team may move into kickoff position. + PREPARE_KICKOFF_YELLOW = 4; + // The blue team may move into kickoff position. + PREPARE_KICKOFF_BLUE = 5; + // The yellow team may move into penalty position. + PREPARE_PENALTY_YELLOW = 6; + // The blue team may move into penalty position. + PREPARE_PENALTY_BLUE = 7; + // The yellow team may take a direct free kick. + DIRECT_FREE_YELLOW = 8; + // The blue team may take a direct free kick. + DIRECT_FREE_BLUE = 9; + // The yellow team may take an indirect free kick. + INDIRECT_FREE_YELLOW = 10 [deprecated = true]; + // The blue team may take an indirect free kick. + INDIRECT_FREE_BLUE = 11 [deprecated = true]; + // The yellow team is currently in a timeout. + TIMEOUT_YELLOW = 12; + // The blue team is currently in a timeout. + TIMEOUT_BLUE = 13; + // The yellow team just scored a goal. + // For information only. + // Deprecated: Use the score field from the team infos instead. That way, you can also detect revoked goals. + GOAL_YELLOW = 14 [deprecated = true]; + // The blue team just scored a goal. See also GOAL_YELLOW. + GOAL_BLUE = 15 [deprecated = true]; + // Equivalent to STOP, but the yellow team must pick up the ball and + // drop it in the Designated Position. + BALL_PLACEMENT_YELLOW = 16; + // Equivalent to STOP, but the blue team must pick up the ball and drop + // it in the Designated Position. + BALL_PLACEMENT_BLUE = 17; + } + required Command command = 4; + + // The number of commands issued since startup (mod 2^32). + required uint32 command_counter = 5; + + // The UNIX timestamp when the command was issued, in microseconds. + // This value changes only when a new command is issued, not on each packet. + required uint64 command_timestamp = 6; + + // Information about a single team. + message TeamInfo { + // The team's name (empty string if operator has not typed anything). + required string name = 1; + // The number of goals scored by the team during normal play and overtime. + required uint32 score = 2; + // The number of red cards issued to the team since the beginning of the game. + required uint32 red_cards = 3; + // The amount of time (in microseconds) left on each yellow card issued to the team. + // If no yellow cards are issued, this array has no elements. + // Otherwise, times are ordered from smallest to largest. + repeated uint32 yellow_card_times = 4 [packed = true]; + // The total number of yellow cards ever issued to the team. + required uint32 yellow_cards = 5; + // The number of timeouts this team can still call. + // If in a timeout right now, that timeout is excluded. + required uint32 timeouts = 6; + // The number of microseconds of timeout this team can use. + required uint32 timeout_time = 7; + // The pattern number of this team's goalkeeper. + required uint32 goalkeeper = 8; + // The total number of countable fouls that act towards yellow cards + optional uint32 foul_counter = 9; + // The number of consecutive ball placement failures of this team + optional uint32 ball_placement_failures = 10; + // Indicate if the team is able and allowed to place the ball + optional bool can_place_ball = 12; + // The maximum number of bots allowed on the field based on division and cards + optional uint32 max_allowed_bots = 13; + // The team has submitted an intent to substitute one or more robots at the next chance + optional bool bot_substitution_intent = 14; + // Indicate if the team reached the maximum allowed ball placement failures and is thus not allowed to place the ball anymore + optional bool ball_placement_failures_reached = 15; + // The team is allowed to substitute one or more robots currently + optional bool bot_substitution_allowed = 16; + // The number of bot substitutions left by the team in this halftime + optional uint32 bot_substitutions_left = 17; + // The number of microseconds left for current bot substitution + optional uint32 bot_substitution_time_left = 18; + } + + // Information about the two teams. + required TeamInfo yellow = 7; + required TeamInfo blue = 8; + + // The coordinates of the Designated Position. These are measured in + // millimetres and correspond to SSL-Vision coordinates. These fields are + // always either both present (in the case of a ball placement command) or + // both absent (in the case of any other command). + message Point { + required float x = 1; + required float y = 2; + } + optional Point designated_position = 9; + + // Information about the direction of play. + // True, if the blue team will have it's goal on the positive x-axis of the ssl-vision coordinate system. + // Obviously, the yellow team will play on the opposite half. + optional bool blue_team_on_positive_half = 10; + + // The game event that caused the referee command. + // deprecated in favor of game_events. + // optional Game_Event game_event = 11 [deprecated = true]; + reserved 11; + + // The command that will be issued after the current stoppage and ball placement to continue the game. + optional Command next_command = 12; + + // All game events that were detected since the last RUNNING state. + // Will be cleared as soon as the game is continued. + reserved 13; + repeated GameEvent game_events = 16; + + // All proposed game events that were detected since the last RUNNING state. + reserved 14; + repeated GameEventProposalGroup game_event_proposals = 17; + + // The time in microseconds that is remaining until the current action times out + // The time will not be reset. It can get negative. + // An autoRef would raise an appropriate event, if the time gets negative. + // Possible actions where this time is relevant: + // * free kicks + // * kickoff, penalty kick, force start + // * ball placement + optional int64 current_action_time_remaining = 15; + + // A message that can be displayed to the spectators, like a reason for a stoppage. + optional string status_message = 20; +} + +// List of matching proposals +message GameEventProposalGroup { + // Unique ID of this group + optional string id = 3; + // The proposed game events + repeated GameEvent game_events = 1; + // Whether the proposal group was accepted + optional bool accepted = 2; +} + +// MatchType is a meta information about the current match for easier log processing +enum MatchType { + // not set + UNKNOWN_MATCH = 0; + // match is part of the group phase + GROUP_PHASE = 1; + // match is part of the elimination phase + ELIMINATION_PHASE = 2; + // a friendly match, not part of a tournament + FRIENDLY = 3; +} diff --git a/ssl-league-protobufs/proto/ssl_gc_state.proto b/ssl-league-protobufs/proto/ssl_gc_state.proto new file mode 100644 index 0000000..7a2a289 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_gc_state.proto @@ -0,0 +1,133 @@ +syntax = "proto2"; + +option go_package = "github.com/RoboCup-SSL/ssl-game-controller/internal/app/state"; + +import "ssl_gc_common.proto"; +import "ssl_gc_geometry.proto"; +import "ssl_gc_game_event.proto"; +import "ssl_gc_referee_message.proto"; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +message YellowCard { + optional uint32 id = 1; + optional GameEvent caused_by_game_event = 2; + optional google.protobuf.Duration time_remaining = 3; +} + +message RedCard { + optional uint32 id = 1; + optional GameEvent caused_by_game_event = 2; +} + +message Foul { + optional uint32 id = 1; + optional GameEvent caused_by_game_event = 2; + optional google.protobuf.Timestamp timestamp = 3; +} + +message Command { + required Type type = 1; + required Team for_team = 2; + + enum Type { + UNKNOWN = 0; + HALT = 1; + STOP = 2; + NORMAL_START = 3; + FORCE_START = 4; + DIRECT = 5; + reserved 6; // INDIRECT + KICKOFF = 7; + PENALTY = 8; + TIMEOUT = 9; + BALL_PLACEMENT = 10; + } +} + +message GameState { + required Type type = 1; + optional Team for_team = 2; + + enum Type { + UNKNOWN = 0; + HALT = 1; + STOP = 2; + RUNNING = 3; + FREE_KICK = 4; + KICKOFF = 5; + PENALTY = 6; + TIMEOUT = 7; + BALL_PLACEMENT = 8; + } +} + +message Proposal { + // The timestamp when the game event proposal occurred + optional google.protobuf.Timestamp timestamp = 1; + // The proposed game event. + optional GameEvent game_event = 2; +} + +message ProposalGroup { + // Unique ID of this group + optional string id = 4; + // The proposals in this group + repeated Proposal proposals = 1; + // Whether the proposal group was accepted + optional bool accepted = 2; + reserved 3; // uint32 id +} + +message TeamInfo { + optional string name = 1; + optional int32 goals = 2; + optional int32 goalkeeper = 3; + repeated YellowCard yellow_cards = 4; + repeated RedCard red_cards = 5; + optional int32 timeouts_left = 6; + optional google.protobuf.Duration timeout_time_left = 7; + optional bool on_positive_half = 8; + repeated Foul fouls = 9; + optional int32 ball_placement_failures = 10; + optional bool ball_placement_failures_reached = 11; + optional bool can_place_ball = 12; + optional int32 max_allowed_bots = 13; + optional google.protobuf.Timestamp requests_bot_substitution_since = 14; + optional google.protobuf.Timestamp requests_timeout_since = 15; + optional google.protobuf.Timestamp requests_emergency_stop_since = 16; + optional int32 challenge_flags = 17; + optional bool bot_substitution_allowed = 18; + optional int32 bot_substitutions_left = 19; + optional google.protobuf.Duration bot_substitution_time_left = 20; +} + +message State { + optional Referee.Stage stage = 1; + optional Command command = 2; + optional GameState game_state = 19; + optional google.protobuf.Duration stage_time_elapsed = 4; + optional google.protobuf.Duration stage_time_left = 5; + optional google.protobuf.Timestamp match_time_start = 6; + map team_state = 8; + optional Vector2 placement_pos = 9; + optional Command next_command = 10; + optional google.protobuf.Duration current_action_time_remaining = 12; + repeated GameEvent game_events = 13; + repeated ProposalGroup proposal_groups = 14; + optional Division division = 15; + reserved 16; + optional Team first_kickoff_team = 17; + optional MatchType match_type = 18; + optional google.protobuf.Timestamp ready_continue_time = 20; + optional ShootoutState shootout_state = 21; + optional string status_message = 22; + // The maximum number of bots per team (overwrites the division config) + optional int32 max_bots_per_team = 23; +} + +message ShootoutState { + optional Team next_team = 1; + map number_of_attempts = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_simulation_config.proto b/ssl-league-protobufs/proto/ssl_simulation_config.proto new file mode 100644 index 0000000..da8b6ae --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_simulation_config.proto @@ -0,0 +1,77 @@ +syntax = "proto2"; +option go_package = "github.com/RoboCup-SSL/ssl-simulation-protocol/pkg/sim"; + +import "ssl_gc_common.proto"; +import "ssl_vision_geometry.proto"; +import "google/protobuf/any.proto"; + +// Movement limits for a robot +message RobotLimits { + // Max absolute speed-up acceleration [m/s^2] + optional float acc_speedup_absolute_max = 1; + // Max angular speed-up acceleration [rad/s^2] + optional float acc_speedup_angular_max = 2; + // Max absolute brake acceleration [m/s^2] + optional float acc_brake_absolute_max = 3; + // Max angular brake acceleration [rad/s^2] + optional float acc_brake_angular_max = 4; + // Max absolute velocity [m/s] + optional float vel_absolute_max = 5; + // Max angular velocity [rad/s] + optional float vel_angular_max = 6; +} + +// Robot wheel angle configuration +// all angles are relative to looking forward, +// all wheels / angles are clockwise +message RobotWheelAngles { + // Angle front right [rad] + required float front_right = 1; + // Angle back right [rad] + required float back_right = 2; + // Angle back left [rad] + required float back_left = 3; + // Angle front left [rad] + required float front_left = 4; +} + +// Specs of a robot +message RobotSpecs { + // Id of the robot + required RobotId id = 1; + // Robot radius [m] + optional float radius = 2 [default = 0.09]; + // Robot height [m] + optional float height = 3 [default = 0.15]; + // Robot mass [kg] + optional float mass = 4; + // Max linear kick speed [m/s] (unset = unlimited) + optional float max_linear_kick_speed = 7; + // Max chip kick speed [m/s] (unset = unlimited) + optional float max_chip_kick_speed = 8; + // Distance from robot center to dribbler [m] (implicitly defines the opening angle and dribbler width) + optional float center_to_dribbler = 9; + // Movement limits + optional RobotLimits limits = 10; + // Wheel angle configuration + optional RobotWheelAngles wheel_angles = 13; + // Custom robot spec for specific simulators (the protobuf files are managed by the simulators) + repeated google.protobuf.Any custom = 14; +} + +message RealismConfig { + // Custom config for specific simulators (the protobuf files are managed by the simulators) + repeated google.protobuf.Any custom = 1; +} + +// Change the simulator configuration +message SimulatorConfig { + // Update the geometry + optional SSL_GeometryData geometry = 1; + // Update the robot specs + repeated RobotSpecs robot_specs = 2; + // Update realism configuration + optional RealismConfig realism_config = 3; + // Change the vision publish port + optional uint32 vision_port = 4; +} \ No newline at end of file diff --git a/ssl-league-protobufs/proto/ssl_simulation_control.proto b/ssl-league-protobufs/proto/ssl_simulation_control.proto new file mode 100644 index 0000000..55f639e --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_simulation_control.proto @@ -0,0 +1,90 @@ +syntax = "proto2"; +option go_package = "github.com/RoboCup-SSL/ssl-simulation-protocol/pkg/sim"; + +import "ssl_gc_common.proto"; +import "ssl_simulation_config.proto"; +import "ssl_simulation_error.proto"; + +// Teleport the ball to a new location and optionally set it to some velocity +message TeleportBall { + // x-coordinate [m] + optional float x = 1; + // y-coordinate [m] + optional float y = 2; + // z-coordinate (height) [m] + optional float z = 3; + // Velocity in x-direction [m/s] + optional float vx = 4; + // Velocity in y-direction [m/s] + optional float vy = 5; + // Velocity in z-direction [m/s] + optional float vz = 6; + // Teleport the ball safely to the target, for example by + // moving robots out of the way in case of collision and set speed of robots close-by to zero + optional bool teleport_safely = 7 [default = false]; + // Adapt the angular ball velocity such that the ball is rolling + optional bool roll = 8 [default = false]; + // Instead of teleporting the ball, apply some force to make sure + // the ball reaches the required position soon (velocity is ignored if true) + // WARNING: A command with by_force stays active (the move will take some time) + // until cancled by another TeleportBall command with by_force = false. + // To avoid teleporting the ball at the end and resetting its current spin, + // do not set any of the optional fields in this message to end the force without triggering + // an additional teleportation + optional bool by_force = 9 [ default = false]; +} + +// Teleport a robot to some location and give it a velocity +message TeleportRobot { + // Robot id to teleport + required RobotId id = 1; + // x-coordinate [m] + optional float x = 2; + // y-coordinate [m] + optional float y = 3; + // Orientation [rad], measured from the x-axis counter-clockwise + optional float orientation = 4; + // Global velocity [m/s] towards x-axis + optional float v_x = 5 [default = 0]; + // Global velocity [m/s] towards y-axis + optional float v_y = 6 [default = 0]; + // Angular velocity [rad/s] + optional float v_angular = 7 [default = 0]; + // Robot should be present on the field? + // true -> robot will be added, if it does not exist yet + // false -> robot will be removed, if it is present + optional bool present = 8; + // Instead of teleporting, apply some force to make sure + // the robot reaches the required position soon (velocity is ignored if true) + // WARNING: A command with by_force stays active (the move will take some time) + // until cancled by another TeleportRobot command for the same bot with by_force = false. + // To avoid teleporting at the end, + // do not set any of the optional fields in this message + // to end the force without triggering + // an additional teleportation + optional bool by_force = 9 [ default = false]; +} + +// Control the simulation +message SimulatorControl { + // Teleport the ball + optional TeleportBall teleport_ball = 1; + // Teleport robots + repeated TeleportRobot teleport_robot = 2; + // Change the simulation speed + optional float simulation_speed = 3; +} + +// Command from the connected client to the simulator +message SimulatorCommand { + // Control the simulation + optional SimulatorControl control = 1; + // Configure the simulation + optional SimulatorConfig config = 2; +} + +// Response of the simulator to the connected client +message SimulatorResponse { + // List of errors, like using unsupported features + repeated SimulatorError errors = 1; +} diff --git a/ssl-league-protobufs/proto/ssl_simulation_error.proto b/ssl-league-protobufs/proto/ssl_simulation_error.proto new file mode 100644 index 0000000..92c732f --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_simulation_error.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +option go_package = "github.com/RoboCup-SSL/ssl-simulation-protocol/pkg/sim"; + +// Errors in the simulator +message SimulatorError { + // Unique code of the error for automatic handling on client side + optional string code = 1; + // Human readable description of the error + optional string message = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_simulation_robot_control.proto b/ssl-league-protobufs/proto/ssl_simulation_robot_control.proto new file mode 100644 index 0000000..981b843 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_simulation_robot_control.proto @@ -0,0 +1,66 @@ +syntax = "proto2"; +option go_package = "github.com/RoboCup-SSL/ssl-simulation-protocol/pkg/sim"; + +// Full command for a single robot +message RobotCommand { + // Id of the robot + required uint32 id = 1; + // Movement command + optional RobotMoveCommand move_command = 2; + // Absolute (3 dimensional) kick speed [m/s] + optional float kick_speed = 3; + // Kick angle [degree] (defaults to 0 degrees for a straight kick) + optional float kick_angle = 4 [default = 0]; + // Dribbler speed in rounds per minute [rpm] + optional float dribbler_speed = 5; +} + +// Wrapper for different kinds of movement commands +message RobotMoveCommand { + oneof command { + // Move with wheel velocities + MoveWheelVelocity wheel_velocity = 1; + // Move with local velocity + MoveLocalVelocity local_velocity = 2; + // Move with global velocity + MoveGlobalVelocity global_velocity = 3; + } +} + +// Move robot with wheel velocities +message MoveWheelVelocity { + // Velocity [m/s] of front right wheel + required float front_right = 1; + // Velocity [m/s] of back right wheel + required float back_right = 2; + // Velocity [m/s] of back left wheel + required float back_left = 3; + // Velocity [m/s] of front left wheel + required float front_left = 4; +} + +// Move robot with local velocity +message MoveLocalVelocity { + // Velocity forward [m/s] (towards the dribbler) + required float forward = 1; + // Velocity to the left [m/s] + required float left = 2; + // Angular velocity counter-clockwise [rad/s] + required float angular = 3; +} + +// Move robot with global velocity +message MoveGlobalVelocity { + // Velocity on x-axis of the field [m/s] + required float x = 1; + // Velocity on y-axis of the field [m/s] + required float y = 2; + // Angular velocity counter-clockwise [rad/s] + required float angular = 3; +} + +// Command from the connected client to the simulator +message RobotControl { + // Control the robots + repeated RobotCommand robot_commands = 1; +} diff --git a/ssl-league-protobufs/proto/ssl_simulation_robot_feedback.proto b/ssl-league-protobufs/proto/ssl_simulation_robot_feedback.proto new file mode 100644 index 0000000..ddbf214 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_simulation_robot_feedback.proto @@ -0,0 +1,23 @@ +syntax = "proto2"; +option go_package = "github.com/RoboCup-SSL/ssl-simulation-protocol/pkg/sim"; + +import "ssl_simulation_error.proto"; +import "google/protobuf/any.proto"; + +// Feedback from a robot +message RobotFeedback { + // Id of the robot + required uint32 id = 1; + // Has the dribbler contact to the ball right now + optional bool dribbler_ball_contact = 2; + // Custom robot feedback for specific simulators (the protobuf files are managed by the simulators) + optional google.protobuf.Any custom = 3; +} + +// Response to RobotControl from the simulator to the connected client +message RobotControlResponse { + // List of errors, like using unsupported features + repeated SimulatorError errors = 1; + // Feedback of the robots + repeated RobotFeedback feedback = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_simulation_synchronous.proto b/ssl-league-protobufs/proto/ssl_simulation_synchronous.proto new file mode 100644 index 0000000..b8dde5d --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_simulation_synchronous.proto @@ -0,0 +1,25 @@ +syntax = "proto2"; +option go_package = "github.com/RoboCup-SSL/ssl-simulation-protocol/pkg/sim"; + +import "ssl_vision_detection.proto"; +import "ssl_simulation_robot_feedback.proto"; +import "ssl_simulation_robot_control.proto"; +import "ssl_simulation_control.proto"; + +// Request from the team to the simulator +message SimulationSyncRequest { + // The simulation step [s] to perform + optional float sim_step = 1; + // An optional simulator command + optional SimulatorCommand simulator_command = 2; + // An optional robot control command + optional RobotControl robot_control = 3; +} + +// Response to last SimulationSyncRequest +message SimulationSyncResponse { + // List of detection frames for all cameras with the state after the simulation step in the request was performed + repeated SSL_DetectionFrame detection = 1; + // An optional robot control response + optional RobotControlResponse robot_control_response = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_vision_detection.proto b/ssl-league-protobufs/proto/ssl_vision_detection.proto new file mode 100644 index 0000000..090bb64 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_vision_detection.proto @@ -0,0 +1,57 @@ +syntax = "proto2"; + +message SSL_DetectionBall { + // Confidence in [0-1] of the detection + required float confidence = 1; + optional uint32 area = 2; + // X-coordinate in [mm] in global ssl-vision coordinate system + required float x = 3; + // Y-coordinate in [mm] in global ssl-vision coordinate system + required float y = 4; + // Z-coordinate in [mm] in global ssl-vision coordinate system + // Not supported by ssl-vision, but might be set by simulators + optional float z = 5; + // X-coordinate in [pixel] in the image + required float pixel_x = 6; + // Y-coordinate in [pixel] in the image + required float pixel_y = 7; +} + +message SSL_DetectionRobot { + // Confidence in [0-1] of the detection + required float confidence = 1; + // Id of the robot + optional uint32 robot_id = 2; + // X-coordinate in [mm] in global ssl-vision coordinate system + required float x = 3; + // Y-coordinate in [mm] in global ssl-vision coordinate system + required float y = 4; + // Orientation in [rad] + optional float orientation = 5; + // X-coordinate in [pixel] in the image + required float pixel_x = 6; + // Y-coordinate in [pixel] in the image + required float pixel_y = 7; + // Height, as configured in ssl-vision for the respective team + optional float height = 8; +} + +message SSL_DetectionFrame { + // monotonously increasing frame number + required uint32 frame_number = 1; + // Unix timestamp in [seconds] at which the image has been received by ssl-vision + required double t_capture = 2; + // Unix timestamp in [seconds] at which this message has been sent to the network + required double t_sent = 3; + // Camera timestamp in [seconds] as reported by the camera, if supported + // This is not necessarily a unix timestamp + optional double t_capture_camera = 8; + // Identifier of the camera + required uint32 camera_id = 4; + // Detected balls + repeated SSL_DetectionBall balls = 5; + // Detected yellow robots + repeated SSL_DetectionRobot robots_yellow = 6; + // Detected blue robots + repeated SSL_DetectionRobot robots_blue = 7; +} diff --git a/ssl-league-protobufs/proto/ssl_vision_detection_tracked.proto b/ssl-league-protobufs/proto/ssl_vision_detection_tracked.proto new file mode 100644 index 0000000..4d73f4d --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_vision_detection_tracked.proto @@ -0,0 +1,88 @@ +syntax = "proto2"; + +import "ssl_gc_common.proto"; +import "ssl_gc_geometry.proto"; + +// Default network address: 224.5.23.2:10010 + +// Capabilities that a source implementation can have +enum Capability { + CAPABILITY_UNKNOWN = 0; + CAPABILITY_DETECT_FLYING_BALLS = 1; + CAPABILITY_DETECT_MULTIPLE_BALLS = 2; + CAPABILITY_DETECT_KICKED_BALLS = 3; +} + +// A single tracked ball +message TrackedBall { + // The position (x, y, height) [m] in the ssl-vision coordinate system + required Vector3 pos = 1; + + // The velocity [m/s] in the ssl-vision coordinate system + optional Vector3 vel = 2; + + // The visibility of the ball + // A value between 0 (not visible) and 1 (visible) + // The exact implementation depends on the source software + optional float visibility = 3; +} + +// A ball kicked by a robot, including predictions when the ball will come to a stop +message KickedBall { + // The initial position [m] from which the ball was kicked + required Vector2 pos = 1; + // The initial velocity [m/s] with which the ball was kicked + required Vector3 vel = 2; + // The unix timestamp [s] when the kick was performed + required double start_timestamp = 3; + + // The predicted unix timestamp [s] when the ball comes to a stop + optional double stop_timestamp = 4; + // The predicted position [m] at which the ball will come to a stop + optional Vector2 stop_pos = 5; + + // The robot that kicked the ball + optional RobotId robot_id = 6; +} + +// A single tracked robot +message TrackedRobot { + required RobotId robot_id = 1; + + // The position [m] in the ssl-vision coordinate system + required Vector2 pos = 2; + // The orientation [rad] in the ssl-vision coordinate system + required float orientation = 3; + + // The velocity [m/s] in the ssl-vision coordinate system + optional Vector2 vel = 4; + // The angular velocity [rad/s] in the ssl-vision coordinate system + optional float vel_angular = 5; + + // The visibility of the robot + // A value between 0 (not visible) and 1 (visible) + // The exact implementation depends on the source software + optional float visibility = 6; +} + +// A frame that contains all currently tracked objects on the field on all cameras +message TrackedFrame { + // A monotonous increasing frame counter + required uint32 frame_number = 1; + // The unix timestamp in [s] of the data + required double timestamp = 2; + + // The list of detected balls + // The first ball is the primary one + // Sources may add additional balls based on their capabilities + repeated TrackedBall balls = 3; + // The list of detected robots of both teams + repeated TrackedRobot robots = 4; + + // Information about a kicked ball, if the ball was kicked by a robot and is still moving + // Note: This field is optional. Some source implementations might not set this at any time + optional KickedBall kicked_ball = 5; + + // List of capabilities of the source implementation + repeated Capability capabilities = 6; +} diff --git a/ssl-league-protobufs/proto/ssl_vision_geometry.proto b/ssl-league-protobufs/proto/ssl_vision_geometry.proto new file mode 100644 index 0000000..3cdc756 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_vision_geometry.proto @@ -0,0 +1,151 @@ +syntax = "proto2"; +// A 2D float vector. +message Vector2f { + // X-coordinate in mm + required float x = 1; + // Y-coordinate in mm + required float y = 2; +} + +// Represents a field marking as a line segment represented by a start point p1, +// and end point p2, and a line thickness. The start and end points are along +// the center of the line, so the thickness of the line extends by thickness / 2 +// on either side of the line. +message SSL_FieldLineSegment { + // Name of this field marking. + required string name = 1; + // Start point of the line segment. + required Vector2f p1 = 2; + // End point of the line segment. + required Vector2f p2 = 3; + // Thickness of the line segment. + required float thickness = 4; + // The type of this shape + optional SSL_FieldShapeType type = 5; +} + +// Represents a field marking as a circular arc segment represented by center point, a +// start angle, an end angle, and an arc thickness. +message SSL_FieldCircularArc { + // Name of this field marking. + required string name = 1; + // Center point of the circular arc. + required Vector2f center = 2; + // Radius of the arc. + required float radius = 3; + // Start angle in counter-clockwise order. + required float a1 = 4; + // End angle in counter-clockwise order. + required float a2 = 5; + // Thickness of the arc. + required float thickness = 6; + // The type of this shape + optional SSL_FieldShapeType type = 7; +} + +message SSL_GeometryFieldSize { + // Field length (distance between goal lines) in mm + required int32 field_length = 1; + // Field width (distance between touch lines) in mm + required int32 field_width = 2; + // Goal width (distance between inner edges of goal posts) in mm + required int32 goal_width = 3; + // Goal depth (distance from outer goal line edge to inner goal back) in mm + required int32 goal_depth = 4; + // Boundary width (distance from touch/goal line centers to boundary walls) in mm + required int32 boundary_width = 5; + // Generated line segments based on the other parameters + repeated SSL_FieldLineSegment field_lines = 6; + // Generated circular arcs based on the other parameters + repeated SSL_FieldCircularArc field_arcs = 7; + // Depth of the penalty/defense area (measured between line centers) in mm + optional int32 penalty_area_depth = 8; + // Width of the penalty/defense area (measured between line centers) in mm + optional int32 penalty_area_width = 9; + // Radius of the center circle (measured between line centers) in mm + optional int32 center_circle_radius = 10; + // Thickness/width of the lines on the field in mm + optional int32 line_thickness = 11; + // Distance between the goal center and the center of the penalty mark in mm + optional int32 goal_center_to_penalty_mark = 12; + // Goal height in mm + optional int32 goal_height = 13; + // Ball radius in mm (note that this is a float type to represent sub-mm precision) + optional float ball_radius = 14; + // Max allowed robot radius in mm (note that this is a float type to represent sub-mm precision) + optional float max_robot_radius = 15; +} + +message SSL_GeometryCameraCalibration { + required uint32 camera_id = 1; + required float focal_length = 2; + required float principal_point_x = 3; + required float principal_point_y = 4; + required float distortion = 5; + required float q0 = 6; + required float q1 = 7; + required float q2 = 8; + required float q3 = 9; + required float tx = 10; + required float ty = 11; + required float tz = 12; + optional float derived_camera_world_tx = 13; + optional float derived_camera_world_ty = 14; + optional float derived_camera_world_tz = 15; + optional uint32 pixel_image_width = 16; + optional uint32 pixel_image_height = 17; +} + +// Two-Phase model for straight-kicked balls. +// There are two phases with different accelerations during the ball kicks: +// 1. Sliding +// 2. Rolling +// The full model is described in the TDP of ER-Force from 2016, which can be found here: +// https://ssl.robocup.org/wp-content/uploads/2019/01/2016_ETDP_ER-Force.pdf +message SSL_BallModelStraightTwoPhase { + // Ball sliding acceleration [m/s^2] (should be negative) + required double acc_slide = 1; + // Ball rolling acceleration [m/s^2] (should be negative) + required double acc_roll = 2; + // Fraction of the initial velocity where the ball starts to roll + required double k_switch = 3; +} + +// Fixed-Loss model for chipped balls. +// Uses fixed damping factors for xy and z direction per hop. +message SSL_BallModelChipFixedLoss { + // Chip kick velocity damping factor in XY direction for the first hop + required double damping_xy_first_hop = 1; + // Chip kick velocity damping factor in XY direction for all following hops + required double damping_xy_other_hops = 2; + // Chip kick velocity damping factor in Z direction for all hops + required double damping_z = 3; +} + +message SSL_GeometryModels { + optional SSL_BallModelStraightTwoPhase straight_two_phase = 1; + optional SSL_BallModelChipFixedLoss chip_fixed_loss = 2; +} + +message SSL_GeometryData { + required SSL_GeometryFieldSize field = 1; + repeated SSL_GeometryCameraCalibration calib = 2; + optional SSL_GeometryModels models = 3; +} + +enum SSL_FieldShapeType { + Undefined = 0; + CenterCircle = 1; + TopTouchLine = 2; + BottomTouchLine = 3; + LeftGoalLine = 4; + RightGoalLine = 5; + HalfwayLine = 6; + CenterLine = 7; + LeftPenaltyStretch = 8; + RightPenaltyStretch = 9; + LeftFieldLeftPenaltyStretch = 10; + LeftFieldRightPenaltyStretch = 11; + RightFieldLeftPenaltyStretch = 12; + RightFieldRightPenaltyStretch = 13; +} diff --git a/ssl-league-protobufs/proto/ssl_vision_wrapper.proto b/ssl-league-protobufs/proto/ssl_vision_wrapper.proto new file mode 100644 index 0000000..e8be7fb --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_vision_wrapper.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +import "ssl_vision_detection.proto"; +import "ssl_vision_geometry.proto"; + +message SSL_WrapperPacket { + optional SSL_DetectionFrame detection = 1; + optional SSL_GeometryData geometry = 2; +} diff --git a/ssl-league-protobufs/proto/ssl_vision_wrapper_tracked.proto b/ssl-league-protobufs/proto/ssl_vision_wrapper_tracked.proto new file mode 100644 index 0000000..c30c4b1 --- /dev/null +++ b/ssl-league-protobufs/proto/ssl_vision_wrapper_tracked.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; +import "ssl_vision_detection_tracked.proto"; + +// A wrapper packet containing meta data of the source +// Also serves for the possibility to extend the protocol later +message TrackerWrapperPacket { + // A random UUID of the source that is kept constant at the source while running + // If multiple sources are broadcasting to the same network, this id can be used to identify individual sources + required string uuid = 1; + // The name of the source software that is producing this messages. + optional string source_name = 2; + // The tracked frame + optional TrackedFrame tracked_frame = 3; +} diff --git a/ssl-league-protobufs/tests/test_ssl_protos.py b/ssl-league-protobufs/tests/test_ssl_protos.py new file mode 100644 index 0000000..9050530 --- /dev/null +++ b/ssl-league-protobufs/tests/test_ssl_protos.py @@ -0,0 +1,338 @@ +""" +Integration tests: run the protoc plugins against the SSL league protobufs. + +These protos are third-party (RoboCup SSL), use proto2 syntax, and exercise +features that the ateam protos do not: nested messages/enums, map fields, +required/optional labels, and large oneof blocks. + +Run with: pytest ssl-league-protobufs/tests/test_ssl_protos.py -v +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +# --------------------------------------------------------------------------- # +# Paths +# --------------------------------------------------------------------------- # + +_THIS_DIR = Path(__file__).parent +_REPO_ROOT = _THIS_DIR.parent.parent +_PROTO_DIR = _THIS_DIR.parent / "proto" +_PLUGIN_MSG = _REPO_ROOT / "ateam-common-packets" / "cmake" / "protoc_gen_ros2msg.py" +_PLUGIN_CPP = _REPO_ROOT / "ateam-common-packets" / "cmake" / "protoc_gen_ros2cpp.py" + +_ALL_SSL_PROTOS = sorted(_PROTO_DIR.glob("*.proto")) + +_DEFAULT_CPP_OPTS = ( + "proto_include_prefix=ssl_league_protobufs," + "ros2_package=ssl_league_msgs," + "namespace=ssl_conversions" +) + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + +def _run( + plugin: Path, + proto_files: list[Path], + out_glob: str, + opts: str, + extra_proto_path: Path | None = None, +) -> tuple[int, str, dict[str, str]]: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + plugin_name = plugin.stem.removeprefix("protoc_gen_") + cmd = [ + "protoc", + f"--plugin=protoc-gen-{plugin_name}={plugin}", + f"--{plugin_name}_opt={opts}", + f"--{plugin_name}_out={tmp_path}", + f"--proto_path={_PROTO_DIR}", + ] + if extra_proto_path: + cmd.append(f"--proto_path={extra_proto_path}") + cmd.extend(str(p) for p in proto_files) + result = subprocess.run(cmd, capture_output=True, text=True) + files = {p.name: p.read_text() for p in tmp_path.glob(out_glob)} + return result.returncode, result.stderr, files + + +def run_msg_plugin( + proto_files: list[Path] | None = None, + opts: str = "optional_submsg=has_field", +) -> tuple[int, str, dict[str, str]]: + return _run(_PLUGIN_MSG, proto_files or _ALL_SSL_PROTOS, "*.msg", opts) + + +def run_cpp_plugin( + proto_files: list[Path] | None = None, + opts: str = _DEFAULT_CPP_OPTS, +) -> tuple[int, str, dict[str, str]]: + return _run(_PLUGIN_CPP, proto_files or _ALL_SSL_PROTOS, "*.hpp", opts) + + +# --------------------------------------------------------------------------- # +# .msg plugin: smoke tests +# --------------------------------------------------------------------------- # + +class TestSslMsgGeneration: + """All SSL league protos generate .msg files without error.""" + + def test_all_protos_succeed(self): + code, err, _ = run_msg_plugin() + assert code == 0, f"protoc failed:\n{err}" + + def test_generates_files_for_each_input(self): + """Each input proto contributes at least one .msg file.""" + code, err, generated = run_msg_plugin() + assert code == 0, f"protoc failed:\n{err}" + assert len(generated) > 0 + + def test_vision_detection_msgs(self): + """ssl_vision_detection.proto → SSL_DetectionFrame, SSL_DetectionBall, etc.""" + code, err, generated = run_msg_plugin([_PROTO_DIR / "ssl_vision_detection.proto"]) + assert code == 0, f"protoc failed:\n{err}" + assert "SSL_DetectionFrame.msg" in generated + assert "SSL_DetectionBall.msg" in generated + assert "SSL_DetectionRobot.msg" in generated + + def test_vision_geometry_msgs(self): + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_vision_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + assert "SSL_GeometryData.msg" in generated + assert "SSL_GeometryFieldSize.msg" in generated + + def test_simulation_robot_control_msgs(self): + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_simulation_robot_control.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + assert "RobotControl.msg" in generated + + +# --------------------------------------------------------------------------- # +# .msg plugin: nested type flattening +# --------------------------------------------------------------------------- # + +class TestSslNestedTypeFlattening: + """Nested proto messages produce flat .msg names (ParentMsg_NestedMsg).""" + + def test_game_event_nested_types_flattened(self): + """GameEvent.BallLeftField → GameEvent_BallLeftField.msg""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + assert "GameEvent_BallLeftField.msg" in generated + assert "GameEvent_Goal.msg" in generated + + def test_nested_type_reference_uses_flat_name(self): + """GameEvent.msg references nested arm types by their flattened name.""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["GameEvent.msg"] + # The oneof arms reference nested message types; these must use flat names + assert "GameEvent_BallLeftField ball_left_field_touch_line" in content + assert "GameEvent_Goal goal" in content + + def test_referee_nested_team_info_flattened(self): + """Referee.TeamInfo → Referee_TeamInfo.msg""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_referee_message.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + _PROTO_DIR / "ssl_gc_game_event.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + assert "Referee_TeamInfo.msg" in generated + + def test_nested_enum_flattened(self): + """Referee.Stage (nested enum) → Referee_Stage.msg""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_referee_message.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + _PROTO_DIR / "ssl_gc_game_event.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + assert "Referee_Stage.msg" in generated + + +# --------------------------------------------------------------------------- # +# .msg plugin: map<> field handling +# --------------------------------------------------------------------------- # + +class TestSslMapFields: + """map fields are silently skipped (no ROS2 map type).""" + + def test_config_generates_without_error(self): + """ssl_gc_engine_config.proto has map<> fields — must not crash.""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_engine_config.proto", + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + + def test_map_field_not_in_output(self): + """The map field name 'game_event_behavior' must not appear in the .msg.""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_engine_config.proto", + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + # The map field should be absent; the message file itself should exist + config_msg = generated.get("Config.msg", "") + assert config_msg != "", "Config.msg should be generated" + assert "game_event_behavior" not in config_msg + + def test_map_entry_type_not_in_output(self): + """Synthetic map-entry message types must not produce .msg files.""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_engine_config.proto", + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + # Synthetic names end in 'Entry' by protoc convention + entry_files = [f for f in generated if "Entry" in f] + assert entry_files == [], f"Unexpected map-entry .msg files: {entry_files}" + + +# --------------------------------------------------------------------------- # +# .msg plugin: proto2-specific features +# --------------------------------------------------------------------------- # + +class TestSslProto2: + """Proto2 required/optional labels do not break generation.""" + + def test_required_fields_generate_cleanly(self): + """ssl_vision_detection.proto uses 'required' — must generate without error.""" + code, err, generated = run_msg_plugin([_PROTO_DIR / "ssl_vision_detection.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["SSL_DetectionBall.msg"] + assert "float32 confidence" in content + assert "float32 x" in content + + def test_optional_scalar_generates_as_field(self): + """Proto2 'optional' scalars emit as plain fields (ROS2 has no optional scalar).""" + code, err, generated = run_msg_plugin([_PROTO_DIR / "ssl_vision_detection.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["SSL_DetectionBall.msg"] + # area is optional in proto2; should appear as plain uint32 field + assert "uint32 area" in content + + def test_optional_message_gets_has_flag(self): + """Proto2 'optional' message-type field gets bool has_ sentinel.""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_vision_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["SSL_GeometryData.msg"] + # models is optional SSL_GeometryModels field + assert "bool has_models" in content + + def test_large_oneof_generates(self): + """GameEvent oneof with 40+ arms must generate all case constants.""" + code, err, generated = run_msg_plugin([ + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["GameEvent.msg"] + assert "uint8 event_case" in content + assert "uint8 ONEOF_EVENT_NONE=0" in content + # ball_left_field_touch_line = 6 in the proto + assert "uint8 ONEOF_EVENT_BALL_LEFT_FIELD_TOUCH_LINE=6" in content + + +# --------------------------------------------------------------------------- # +# C++ plugin: smoke tests +# --------------------------------------------------------------------------- # + +class TestSslCppGeneration: + """All SSL league protos generate C++ conversion headers without error.""" + + def test_all_protos_succeed(self): + code, err, _ = run_cpp_plugin() + assert code == 0, f"protoc failed:\n{err}" + + def test_generates_header_per_proto(self): + code, err, generated = run_cpp_plugin() + assert code == 0, f"protoc failed:\n{err}" + assert "ssl_vision_detection_conversions.hpp" in generated + assert "ssl_gc_game_event_conversions.hpp" in generated + + def test_detection_frame_function_signature(self): + code, err, generated = run_cpp_plugin([_PROTO_DIR / "ssl_vision_detection.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ssl_vision_detection_conversions.hpp"] + assert ( + "inline ssl_league_msgs::msg::SSL_DetectionFrame " + "fromProto(const SSL_DetectionFrame& p)" + in content + ) + + def test_nested_type_function_signature(self): + """GameEvent_BallLeftField generates a correctly-typed fromProto function.""" + code, err, generated = run_cpp_plugin([ + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ssl_gc_game_event_conversions.hpp"] + # Proto C++ class for nested type: GameEvent_BallLeftField (no package) + assert ( + "inline ssl_league_msgs::msg::GameEvent_BallLeftField " + "fromProto(const GameEvent_BallLeftField& p)" + in content + ) + + def test_map_field_not_in_cpp_output(self): + """Map fields must be absent from C++ conversion bodies.""" + code, err, generated = run_cpp_plugin([ + _PROTO_DIR / "ssl_gc_engine_config.proto", + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ssl_gc_engine_config_conversions.hpp"] + assert "game_event_behavior" not in content + + def test_required_field_direct_assign(self): + """Proto2 'required' scalar fields emit direct assignment.""" + code, err, generated = run_cpp_plugin([_PROTO_DIR / "ssl_vision_detection.proto"]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ssl_vision_detection_conversions.hpp"] + assert "msg.confidence = p.confidence();" in content + assert "msg.x = p.x();" in content + + def test_dependency_include_in_header(self): + """ssl_gc_game_event_conversions.hpp includes ssl_gc_common_conversions.hpp.""" + code, err, generated = run_cpp_plugin([ + _PROTO_DIR / "ssl_gc_game_event.proto", + _PROTO_DIR / "ssl_gc_common.proto", + _PROTO_DIR / "ssl_gc_geometry.proto", + ]) + assert code == 0, f"protoc failed:\n{err}" + content = generated["ssl_gc_game_event_conversions.hpp"] + assert '#include "ssl_gc_common_conversions.hpp"' in content + assert '#include "ssl_gc_geometry_conversions.hpp"' in content diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ad1385d --- /dev/null +++ b/uv.lock @@ -0,0 +1,94 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[[package]] +name = "ateam-common-packets-tools" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "protobuf" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "protobuf", specifier = ">=5.0" }, + { name = "pytest", specifier = ">=8.0" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] diff --git a/wireshark/README.md b/wireshark/README.md new file mode 100644 index 0000000..a6498e6 --- /dev/null +++ b/wireshark/README.md @@ -0,0 +1,42 @@ +# Wireshark Dissector + +`ateam_radio.lua` decodes A-Team radio link traffic in Wireshark. + +**Wire format:** `CRC32 (4 bytes, little-endian) | varint(len) | RadioPacket bytes` + +The `RadioPacket` protobuf oneof field number is the packet discriminant. Wireshark reads the `.proto` files at runtime — no field names, enum values, or message types are hardcoded in the Lua. + +## Installation + +```sh +make install-wireshark-plugin # auto-detects plugin directory +make uninstall-wireshark-plugin # removes it +``` + +If `make` cannot detect the path (Wireshark not on `$PATH`), copy `ateam_radio.lua` manually to the directory shown under **Help → About Wireshark → Folders → Personal Lua Plugins**. + +After installing, reload without restarting: **Ctrl+Shift+L** (Analyze → Reload Lua Plugins). + +## Proto file setup + +For full field-name decoding, point Wireshark at the proto schemas: + +1. **Edit → Preferences → Protocols → Protobuf → Protobuf search paths** +2. Add `/ateam-common-packets/proto/` +3. Tick **Load all files in search paths on startup** +4. Restart Wireshark (required after changing proto paths) + +Without this step the dissector still shows the CRC32, length, and raw payload bytes, but field names and enum values will be missing. + +## Port configuration + +The dissector registers a UDP heuristic: any UDP packet whose byte layout matches the wire format is automatically decoded. + +To force-decode a specific port: + +- **Edit → Preferences → Protocols → A-Team Radio → UDP Port** — set to your radio link port +- Or right-click any packet → **Decode As → A-Team Radio** + +## Requires + +Wireshark 3.4 or later (built-in protobuf dissector). diff --git a/wireshark/ateam_radio.lua b/wireshark/ateam_radio.lua new file mode 100644 index 0000000..c9140d1 --- /dev/null +++ b/wireshark/ateam_radio.lua @@ -0,0 +1,139 @@ +-- ateam_radio.lua — Wireshark dissector for the A-Team radio link (proto format). +-- +-- Wire format: CRC32(4 bytes, little-endian) | varint(len) | RadioPacket bytes +-- +-- The RadioPacket oneof field number is the packet discriminant; Wireshark reads +-- the .proto files directly so no field/enum names are hardcoded here. +-- +-- ── Setup ─────────────────────────────────────────────────────────────────────── +-- +-- 1. Copy this file to your Wireshark personal Lua plugins directory: +-- Help → About Wireshark → Folders → Personal Lua Plugins +-- (Linux: ~/.local/lib/wireshark/plugins/) +-- +-- 2. Add the proto search path in Wireshark: +-- Edit → Preferences → Protocols → Protobuf → Protobuf search paths +-- Add: /ateam-common-packets/proto/ +-- Tick "Load all files in search paths on startup". +-- +-- 3. Restart Wireshark. +-- +-- 4. Set the UDP port if the radio link uses a fixed port: +-- Edit → Preferences → Protocols → A-Team Radio → UDP Port +-- (0 = heuristic detection only; right-click → Decode As to force a packet) +-- +-- Requires Wireshark 3.4+ for the built-in protobuf dissector. +-- ──────────────────────────────────────────────────────────────────────────────── + +local ateam_radio = Proto("ateam_radio", "A-Team Radio") + +-- ── Preferences ────────────────────────────────────────────────────────────────── + +ateam_radio.prefs.udp_port = Pref.uint( + "UDP Port", 0, + "UDP port to decode as A-Team Radio (0 = heuristic detection only)" +) + +-- ── Protocol fields ─────────────────────────────────────────────────────────────── + +local f_crc32 = ProtoField.uint32("ateam_radio.crc32", "CRC32", base.HEX) +local f_pb_len = ProtoField.uint32("ateam_radio.pb_len", "Payload Length", base.DEC) +local f_pb_data = ProtoField.bytes( "ateam_radio.pb_data", "RadioPacket (protobuf)") + +ateam_radio.fields = { f_crc32, f_pb_len, f_pb_data } + +-- ── Expert info ─────────────────────────────────────────────────────────────────── + +local ef_bad_varint = ProtoExpert.new( + "ateam_radio.bad_varint", "Malformed varint length field", + expert.group.MALFORMED, expert.severity.ERROR +) +local ef_truncated = ProtoExpert.new( + "ateam_radio.truncated", "Packet truncated (length exceeds captured data)", + expert.group.MALFORMED, expert.severity.WARN +) +local ef_proto_fail = ProtoExpert.new( + "ateam_radio.proto_fail", + "Protobuf decode failed — check proto search paths in Wireshark preferences", + expert.group.UNDECODED, expert.severity.WARN +) + +ateam_radio.experts = { ef_bad_varint, ef_truncated, ef_proto_fail } + +-- ── Varint parser ───────────────────────────────────────────────────────────────── + +-- Returns (value, next_offset) or (nil, offset) on error. +local function read_varint(tvb, offset) + local value, shift, b = 0, 0 + local limit = math.min(offset + 5, tvb:len()) -- 32-bit varint: at most 5 bytes + repeat + if offset >= limit then return nil, offset end + b = tvb(offset, 1):uint() + offset = offset + 1 + value = value + bit32.lshift(bit32.band(b, 0x7F), shift) + shift = shift + 7 + until bit32.band(b, 0x80) == 0 + return value, offset +end + +-- ── Dissector ───────────────────────────────────────────────────────────────────── + +function ateam_radio.dissector(tvb, pinfo, tree) + local pkt_len = tvb:len() + if pkt_len < 5 then return 0 end + + pinfo.cols.protocol:set("ATEAM") + local subtree = tree:add(ateam_radio, tvb(), "A-Team Radio Packet") + + subtree:add_le(f_crc32, tvb(0, 4)) + + local len, payload_off = read_varint(tvb, 4) + if len == nil then + subtree:add_proto_expert_info(ef_bad_varint) + return pkt_len + end + + local varint_bytes = payload_off - 4 + subtree:add(f_pb_len, tvb(4, varint_bytes)) + :set_text(string.format("Payload Length: %d bytes", len)) + + if payload_off + len > pkt_len then + subtree:add_proto_expert_info(ef_truncated) + return pkt_len + end + + local pb_item = subtree:add(f_pb_data, tvb(payload_off, len)) + pb_item:set_text(string.format("RadioPacket (%d bytes)", len)) + + pinfo.private["pb_msg_name"] = "ateam.RadioPacket" + local ok, err = pcall(function() + Dissector.get("protobuf"):call(tvb(payload_off, len):tvb(), pinfo, pb_item) + end) + if not ok then + pb_item:add_proto_expert_info(ef_proto_fail, tostring(err)) + end + + pinfo.cols.info:set(string.format("RadioPacket (%d bytes)", len)) + return pkt_len +end + +-- ── Registration ───────────────────────────────────────────────────────────────── + +local udp_table = DissectorTable.get("udp.port") + +function ateam_radio.prefs_changed() + pcall(function() udp_table:remove_all(ateam_radio) end) + local port = ateam_radio.prefs.udp_port + if port ~= 0 then udp_table:add(port, ateam_radio) end +end + +ateam_radio.prefs_changed() + +ateam_radio:register_heuristic("udp", function(tvb, pinfo, tree) + if tvb:len() < 5 then return false end + local len, payload_off = read_varint(tvb, 4) + if len == nil then return false end + if payload_off + len ~= tvb:len() then return false end + ateam_radio.dissector(tvb, pinfo, tree) + return true +end)