From 7c2ad079d3c31cc0c780da51af01d9e141817ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 26 Sep 2026 01:32:11 +0000 Subject: [PATCH 01/27] docs: update design document for Zenoh connector --- docs/design/052-verification.md | 325 ----------------------------- docs/design/053-zenoh-connector.md | 218 +++++++++++++------ 2 files changed, 151 insertions(+), 392 deletions(-) delete mode 100644 docs/design/052-verification.md diff --git a/docs/design/052-verification.md b/docs/design/052-verification.md deleted file mode 100644 index e671fe30..00000000 --- a/docs/design/052-verification.md +++ /dev/null @@ -1,325 +0,0 @@ -# 052 — Verification against the codebase, and effort assessment - -**Status:** review + prototype, 2026-09-02. Verifies -[052 — Runtime-neutral connectors](052-runtime-neutral-connectors.md) against -the tree at `58c1951`, on the pinned toolchain (rustc 1.98.0, `88d9e12ae`). -**Verdict:** the architecture is sound and the core mechanism is real — it -compile-checks exactly as claimed. Four factual errors and four open design -gaps were found; **all four gaps are now closed with working code** (§3), not -with argument, and every correction has been folded back into design 052 -itself. Effort, recalibrated against the prototype: **4–6 weeks** for one -engineer already fluent in the crates. - -Every claim below that says "verified" or "answered" was executed. The -prototype lives on `claude/design-doc-52-verify-1lsexa` and is **not** on -`main`: this document and the revised design are merged ahead of it, so the -findings are not stranded behind an unmerged change. It is ~2 300 lines across -seven crates — core's neutral I/O layer, both adapters, a unified KNX -connection task, the serial connector reduced to framing, and the tests that -settle each question. §7 lists what landed where. - ---- - -## 1. What checks out - -Every load-bearing claim in §2, §5 and §6 was checked against the source. -Line references are to `main` at `58c1951`; where the prototype later -changes a cited item, the change is called out in §3. - -| Claim | Verified | -|---|---| -| §5.1 return-type notation is experimental on 1.98 | Yes — `error[E0658]: return type notation is experimental`, issue #109417 | -| §5.1 the trait-declared `+ Send` shape works instead | Yes — a standalone crate with a `!Send` socket, a `SendFutureWrapper` newtype impl, a plain `async fn` impl, a generic `FramedConnection`, and a generic task boxed as `Send + 'static` compiles clean under `rustup run 1.98.0` | -| §5.2 `embassy-sync` pulls in no executor | Yes — `_external/embassy/embassy-sync/Cargo.toml` deps are exactly `critical-section`, `heapless`, `futures-core`, `futures-sink`, `embedded-io-async`, `cfg-if` (+ optional `defmt`/`log`) | -| §5.2 KNX Embassy already uses `StaticCell>` | Yes — [`embassy_client.rs:76-107`](../../aimdb-knx-connector/src/embassy_client.rs) | -| §5.2 Tokio's `with_command_queue_size` becomes a const generic | Yes — it exists at [`tokio_client.rs:64`](../../aimdb-knx-connector/src/tokio_client.rs); the Embassy side already notes the size must be a constant | -| §5.5 `spin::Mutex` needs no new dependency | Yes — `aimdb-core/Cargo.toml:120` already declares `spin` with `mutex`/`spin_mutex` | -| §4 `RuntimeOps::sleep` boxes | Yes — `fn sleep(&self, d) -> BoxFuture` at [`executor.rs:50`](../../aimdb-core/src/executor.rs); `now_nanos` is a plain call | -| §2 KNX is ~90 % shared behind a three-method `TunnelIo` | Yes — `send`/`forward`/`warn_ack_timeout` at [`tunnel.rs:536`](../../aimdb-knx-connector/src/tunnel.rs); `drain_actions` is the shared driver | -| §2 both KNX halves bypass `RuntimeOps` for the clock | Yes — `embassy_time::Instant::now()` at `embassy_client.rs:281`, `tokio::time::Instant` at `tokio_client.rs:221` | -| §2 the serial Embassy half is already the target shape | Yes — it contributes only `CobsFramer` and rides `EmbassyConnection`; the file carries **zero** `unsafe` | -| §5.4 `embassy-futures` is dependency-free | Yes — its `[dependencies]` is empty bar optional `defmt`/`log`, and `select_array` exists for the pooled case | -| §6 `embassy_tls.rs` already bypasses `run_with_subscriptions` | Yes — `handle_messages` at [`embassy_tls.rs:372`](../../aimdb-mqtt-connector/src/embassy_tls.rs); the plain path still uses `run_with_subscriptions` at `embassy_client.rs:575` | -| §6 `set_unix_time` has no neutral home | Yes — it is an inherent `EmbassyAdapter` method ([`runtime.rs:48`](../../aimdb-embassy-adapter/src/runtime.rs)), not on `RuntimeOps` | -| §7.2 hostname resolution is a genuine gain | Yes — the plain Embassy path rejects non-IPv4-literal hosts today ("plain mqtt:// needs an IPv4 literal") | -| §8 design 012 exists and needs a rewrite | Yes — `012-M5-connector-development-guide.md`, marked "✅ Implemented (Reference Documentation)" | -| Acceptance 1 baseline | Yes — `cargo check -p aimdb-core --no-default-features --features alloc,connector-session,remote --target thumbv7em-none-eabihf` passes today, and core's only `unsafe` is a word inside a doc comment (`transport.rs:165`) | - -One structural point the design gets right without saying so: the Embassy TCP -half documents that "`connector-io` cannot be reused directly because -`TcpSocket::split()` only yields borrowed halves, while AimDB's `Connection` -must own the socket". An unsplit `ByteStream` with `&mut self` on both -directions dissolves that blocker, and it costs nothing, because -`Connection::recv`/`send` already take `&mut self` and so already serialize. - -## 2. Factual errors - -1. **§8: "No tool or library crate constructs these connectors."** False. - [`aimdb-codegen/src/rust.rs`](../../aimdb-codegen/src/rust.rs) emits - `MqttConnector::new(&mqtt_url)` (lines 268, 1421) and - `KnxConnector::new(&knx_gateway)` (lines 269, 1424) into generated user - code. MQTT is unaffected (the `Native` backend keeps that signature), but - **KNX is not**: once `KnxConnector` is generic over `Datagram + Delay`, the - generated line must carry a transport, so `aimdb-codegen` changes and - `make codegen-drift` has to be re-baselined. -2. **§8's call-site list is incomplete.** It omits - [`aimdb-tcp-connector/examples/tcp_demo.rs`](../../aimdb-tcp-connector/examples/tcp_demo.rs) - and - [`aimdb-serial-connector/examples/serial_demo.rs`](../../aimdb-serial-connector/examples/serial_demo.rs) - — precisely the two crates whose sugar §8 says will change. It also lists - all four `weather-mesh-demo` binaries when only `weather-station-gamma` - (Embassy MQTT) is affected; hub/alpha/beta use the unchanged Tokio path. -3. **§2 cites `tokio_client.rs:691`.** That file is 632 lines. The clock is at - `tokio_client.rs:221-222`. (`embassy_client.rs:283` is off by two — the fn - is at 281.) `connectors.rs:75` in §5.1 should be `:74`. -4. **Acceptance 2: "Every `unsafe impl Send`/`Sync` in the workspace remains - inside `aimdb-embassy-adapter`."** False today and after the change: - `aimdb-wasm-adapter` carries 16 (`buffer.rs` 9, `ws_bridge.rs` 4, - `time.rs` 3) and `aimdb-bench/src/alloc.rs` one. The criterion needs - scoping to the connector crates, which is what it evidently means. - -## 3. The four gaps, closed - -Each was flagged as needing design work before step 1. Each is now answered by -code on the prototype branch, with a test that fails if the answer is wrong. - -### 3.1 `StreamListener` **can** back the Embassy N-socket accept pool - -The concern was that `accept(&mut self)` is single-shot while the pool exists -to keep `N` sockets in `accept()` at once, so routing it through core's serial -`serve` loop would drop concurrency to one — and that §6's "moves into the -adapter unchanged" therefore could not hold. - -Reading `embassy-net` settles the mechanism. `TcpSocket::accept` is a -*synchronous* `s.listen(endpoint)` followed by a bare `poll_fn` that waits for -the state to leave `Listen`/`SynSent`/`SynReceived`. So **dropping the future -does not un-listen the socket** — but *re-entering* `accept()` on a listening -socket is `InvalidState`, and the `abort()` that makes it re-enterable is what -drops the `LISTEN`. - -`EmbassyNet::listen::` therefore stores one accept future per slot and -consumes only the one that completes; the other `N-1` stay pending, untouched, -still listening. Nothing is ever cancelled and no socket leaves `LISTEN` -between calls. - -`aimdb-tcp-connector/tests/accept_pool.rs` proves it over the same two -crossover-wired `embassy-net` stacks the existing loopback smoke uses, driven -in exactly the shape `serve` accepts in: - -- `pool_keeps_every_slot_listening_between_accepts` — client A connects and - round-trips bytes; **then**, while the server sits between accepts, client B - dials and also connects and round-trips. A pool holding only one socket in - `LISTEN` would have had B's SYN refused. -- `naive_pool_loses_the_syn_that_arrives_between_accepts` — the same scenario - against a pool that re-creates its accepts each call. B's dial fails with - `TransportError::Io`, asserted. This is what makes the first test a finding - rather than a coincidence, and it is a regression guard: if embassy-net's - cancellation semantics ever change, it tells you the stored-accept design - can be simplified. - -So §6's wording needs one correction — the pool moves *reshaped*, not -unchanged — but the trait set is sufficient, and the result is strictly better -than today's: no `abort()`/`LISTEN` window at all. - -### 3.2 `Datagram` needed `local_addr` and a binder — both added, both work - -Confirmed as a real gap and closed by widening the trait, not by dropping -behaviour. `Datagram::local_addr` is now part of the contract and -`DatagramBinder` supplies sockets, so the engine's `Action::ResetSocket` can -rebind. - -Both are implementable on both runtimes. Tokio reads `UdpSocket::local_addr`; -Embassy assembles the address from the socket's bound port and the stack's -IPv4 config, and rebinds by `close()` + `bind()` on the same socket rather -than recreating it (which would strand its buffers). - -`client::tests::unified_task_advertises_the_real_local_endpoint` drives the -unified task against a real UDP gateway socket and asserts the bytes: the -CONNECT_REQUEST's control HPAI carries `127.0.0.1` and the socket's actual -bound port, not `0.0.0.0:0`. Worth noting the Embassy half never set the -explicit endpoint *at all* today, so unification fixes it rather than -regressing it. - -### 3.3 `TunnelIo::send` did block generic code — fixed, and it cost one line - -Reproduced exactly. A probe asserting `Send` on `drain_actions`' future in -generic code gave `E0277: future cannot be sent between threads safely`, with -rustc prescribing the fix verbatim: - -``` -help: `Send` can be made part of the associated future's guarantees for all - implementations of `tunnel::TunnelIo::send` -- async fn send(&mut self, frame: &[u8]) -> bool; -+ fn send(&mut self, frame: &[u8]) -> impl Future + Send; -``` - -Applied, with `tunnel::tests::drain_actions_future_is_send_in_generic_code` as -the guard. The two existing impls then split exactly along §5.1's predicted -line: the Tokio one stays a plain `async fn`, and the Embassy one — whose -`embassy_net::udp::UdpSocket` future is `!Send` — returns the adapter's -transparent `SendFutureWrapper`. That is §5.1's mechanism exercised on a real -trait in a real connector rather than on a standalone sketch. - -### 3.4 §6 and acceptance 2 really did conflict — the signature is what gives - -A type assertion on the real struct confirmed it: `TlsOptions` is `!Send` -because its RNG is a bare `&'static mut dyn CryptoRngCore`, so §5.5's -`spin::Mutex>` cannot hold it and the `unsafe impl`s cannot go. - -The resolution is one `+ Send` on the trait object — a signature change §6 -says will not happen, and the cheapest of the available options, since every -concrete CSPRNG a caller passes already satisfies it. With that: - -- core gains `session::OneShot`, §5.5's cell: `Send + Sync` for any - `T: Send`, no `unsafe`, with a compile-time assertion of that property so a - regression surfaces in core rather than in a connector; -- `TlsSlot` becomes an alias for it, and **`aimdb-mqtt-connector` now carries - zero `unsafe impl`s** (was two); -- the whole `embassy-tls` path still cross-compiles to thumbv7em, so - `embedded-tls` accepts the bound. - -§6 should be amended to say `TlsOptions` keeps its *shape* but gains a `Send` -bound on its RNG. - -## 4. The two understated consequences, measured - -### 4.1 `CriticalSectionRawMutex` on std is a link-time obligation - -§5.2 mentions `critical-section/std` only as a host-test detail. A standalone -std binary using `Channel` fails to link: - -``` -rust-lld: error: undefined symbol: _critical_section_1_0_acquire -rust-lld: error: undefined symbol: _critical_section_1_0_release -``` - -`NoopRawMutex` cannot substitute — it is `!Sync`, so it cannot back a shared -channel at all (`*mut () cannot be shared between threads safely`). -`CriticalSectionRawMutex` is therefore forced on std, and the obligation with -it. - -It is also fully mitigable in one line, which the branch does: the KNX -connector's `tokio-runtime` feature now enables `critical-section/std` itself, -so no downstream std user ever sees the error. -`client::tests::shared_embassy_channels_carry_telegrams_on_tokio` then runs -§5.2's claim rather than asserting it — the unified task on Tokio, against a -real UDP gateway, carrying a full handshake, an inbound telegram with its ACK, -and an outbound command, all through the same `embassy_sync` channel types the -MCU uses. - -### 4.2 The `embassy-sync` patch, and one feature-unification trap - -§5.4's "dependency-free" claim is exact: `embassy-futures` 0.1.2 has an empty -`[dependencies]` bar optional `defmt`/`log`, and its `select3` drives the -unified KNX loop on both runtimes. It is now an unconditional dependency of -the KNX connector, and the std build is unaffected. - -One trap found by building rather than reading: making the adapter's `net` -feature imply `embassy-time` turns on the workspace's -`defmt-timestamp-uptime`, which defines `_defmt_timestamp` and collides with -the `defmt::timestamp!` every host test binary must supply. Sockets and clock -have to stay separable features — `net` does not imply `embassy-time`, and -`EmbassyDelay` is gated separately. - -## 5. Acceptance criteria, re-reviewed against the prototype - -| # | Assessment | -|---|---| -| 1 | **Met.** Core carries the new traits, `FramedConnection`, `FramingDialer`/`FramingListener` and `OneShot`, and still cross-compiles to `thumbv7em-none-eabihf` with zero `unsafe`. | -| 2 | Needs rewording (it overlooks `aimdb-wasm-adapter`'s 16 `unsafe impl`s and `aimdb-bench`'s one), but now reachable: MQTT is at zero, serial was already at zero, and the TCP connector's remaining three live in the module this design deletes — the adapter's `net` module already replaces them. | -| 3 | **Met for serial**, and the doubt was unfounded: `tokio` for `io-util` alone suffices, with no `aimdb-tokio-adapter` dependency. See §3 of `framed.rs`. | -| 4 | Still the sharpest criterion. `two_concurrent_sessions` and the three other loopback tests pass unchanged, and `accept_pool.rs` adds the pooled-`StreamListener` equivalent. Note MQTT's `build_internal` tests are Tokio-only unit tests; there is still **no** host test for either Embassy MQTT path. | -| 5 | **Near-vacuous, unchanged.** `examples/embassy-bench-stm32h5` depends only on `aimdb-core`, `aimdb-embassy-adapter` and `aimdb-bench` — no connector — so it will show no change whatever the refactor does. Replace it with an allocation-counting test on a connector path. | -| 6 | Still the most valuable, and still unbuilt. Build it first, not last (§6.3). | - -## 6. Effort assessment, recalibrated - -### 6.1 Code in scope - -Runtime-specific connector code the design deletes or restructures: - -| Crate | Files | Lines | -|---|---|---| -| `aimdb-tcp-connector` | `embassy_transport.rs` 591 + `tokio_transport.rs` 302 | 893 | -| `aimdb-serial-connector` | `embassy_transport.rs` 237 + most of `tokio_transport.rs` 312 | 549 | -| `aimdb-knx-connector` | `embassy_client.rs` 468 + `tokio_client.rs` 632 | 1 100 | -| `aimdb-mqtt-connector` | `embassy_client.rs` 712 + `embassy_tls.rs` 415 + `tokio_client.rs` 457 | 1 584 | -| **Total** | | **~4 130** | - -Untouched and load-bearing: `tunnel.rs` (1 400), the two `framing.rs` -(93 + 139), `sntp*.rs` (281), `link_ext.rs` (68). - -The prototype is 2 324 lines added across 22 files, which is -the first hard data point on the "new code" estimate. It covers step 1 in -full, both halves of step 2, the load-bearing parts of steps 4a and 4b, and -none of steps 3, 5 or 6. - -### 6.2 Stage estimates - -| Step | Work | Days | Status | -|---|---|---|---| -| 1 | Core traits, `FramedConnection`, framing adapters, `OneShot` | 2–3 | **done on the prototype branch** | -| 2a | `aimdb-tokio-adapter` `net` feature | 1–2 | **done** | -| 2b | `EmbassyNet`/`EmbassyUart`/`Delay` + slot-pool move | 3–5 | **done**; §3.1 resolved, so the risk that inflated this is gone | -| 3 | TCP pilot: port the connector onto the traits, migrate `embassy_loopback` | 3–4 | not started; the adapter side it depends on is done | -| 4a | Serial | 1–2 | framing + streams **done**; connector sugar and test migration remain | -| 4b | KNX | 3–5 | unified task **done**; builder/channel wiring and deleting the two clients remain | -| 5 | MQTT `Native`/`Embedded` split | 5–8 | not started | -| 6 | Examples, codegen + drift re-baseline, design 012, CHANGELOGs, major bumps | 2–3 | not started | -| | **Total** | **20–32 days ≈ 4–6 weeks** | | - -The estimate holds. What changed is its shape: the week of contingency I -attached to §3.1 is no longer needed, and the four gaps that could each have -forced a rethink are closed. The remaining risk is concentrated almost -entirely in step 5. - -### 6.3 Where the risk actually sits - -- **MQTT is the long pole and the thinnest-covered.** 1 584 lines restructured - with no host integration test for either Embassy path. Moving the plain path - from `run_with_subscriptions` to `handle_messages` means re-implementing the - reconnect-and-resubscribe loop mountain-mqtt-embassy currently owns - (`embassy_client.rs:562-577` — "the manager re-subscribes these topics on - every connection, so inbound routing survives reconnects"). That is - behaviour, not plumbing. Acceptance 6 is the mitigation and should be built - **first**. -- **Everything else is now de-risked by running code.** The traits, both - adapters, the accept pool, the unified KNX task and the framed - connection all exist and are tested. -- **What is well covered:** CI cross-compiles every Embassy connector to - `thumbv7em-none-eabihf` (`make test-embedded`, 8 connector configurations), - and the TCP and serial halves have real host smokes. - -## 7. What is on the prototype branch - -New: - -- `aimdb-core/src/session/io.rs` — `ByteStream`, `StreamDialer`, - `StreamListener`, `Datagram`, `DatagramBinder`, `Delay`, `Framer`, - `FramerFactory`, `FramedConnection`, `FramingDialer`, `FramingListener`, - `OneShot`. Additive; core still has zero `unsafe`. -- `aimdb-embassy-adapter/src/net.rs` (feature `net`) — `EmbassyNet::tcp` / - `listen::` / `udp`, `EmbassyTcpStream`, the stored-accept pool, - `EmbassyUdpSocket`/`EmbassyUdpBinder`, `EmbassyDelay`. All the force-`Send` - for the TCP and UDP paths lives here. -- `aimdb-tokio-adapter/src/net.rs` (feature `net`) — the std duals, every - future a plain `async fn`, no `unsafe`. -- `aimdb-knx-connector/src/client.rs` — one `connection_task` generic over - `DatagramBinder + Delay`, plus the `embassy_sync` channel bridges. -- `aimdb-serial-connector/src/framing.rs` — the COBS framer against core's - trait, and one `ByteStream` per byte source. -- Tests: `aimdb-tcp-connector/tests/accept_pool.rs`, - `aimdb-serial-connector/tests/framed.rs`, and unit tests in - `client.rs` and `tunnel.rs`. - -Changed: `TunnelIo::send` gains `+ Send`; `TlsOptions`'s RNG gains `+ Send` -and `TlsSlot` becomes `OneShot`; the KNX `tokio-runtime` feature adopts -`embassy-sync` + `critical-section/std`; `embassy-futures` becomes -unconditional. - -Verification run: `make test-embedded` passes (all 8 Embassy connector -configurations), every connector test suite passes, and clippy is clean on -the changed crates. Two pre-existing failures are unrelated to this work and -reproduce on `main`: `aimdb-data-contracts`' `compile_fail` trybuild suite, -and `cargo clippy --target thumbv7em-none-eabihf`, which fails in this -environment for untouched crates too. diff --git a/docs/design/053-zenoh-connector.md b/docs/design/053-zenoh-connector.md index 0cba55bb..8c7fb589 100644 --- a/docs/design/053-zenoh-connector.md +++ b/docs/design/053-zenoh-connector.md @@ -52,6 +52,22 @@ section yet (§1). - Humble keys carry a placeholder hash, not none (§2). - The native publisher mirrors rmw_zenoh's congestion control (§5.1). +- **rev 7, 2026-09-26.** The domain ID and node namespace get an API, following + ROS's layering: the domain is set per session on `Ros2Connector`, and the + namespace per node on `Ros2Node` (§4.2). Q6 is answered: topics stay fully + qualified (§4.3, §10). Review findings are folded in too: + - Core's serializer setters clear the recorded wire format, so a custom + serializer after a codec verb now warns (§4.3, §4.4, §4.6). + - The `WIRE_FORMAT` const-assert becomes a `build()` check (§4.3, §9). + - `aimdb-cdr` decodes both byte orders (§4.4). + - The wildcard, `SchemaType::NAME` and `RouteMeta` duplication consequences + are written down (§4.3, §4.4, §4.6). + + - `aimdb-ros2-msgs` is postponed to a later phase. In v1 every ROS type, + standard or custom, is hand-written with the derive (§4.5). + + The changes are in the scope list, §2, §4.2–4.6, §4.10, §8, §9, §10, §11 and + §12. Nothing is implemented. **[checked]** marks a claim read from upstream source at the commits listed in §13; **[verified]** marks one confirmed by reading or @@ -64,13 +80,15 @@ answer; §10 collects them. (`RosMessage` follows the one-verb-per-contract rule; its connector-side registration follows `Streamable`'s precedent). **Scope:** -- New crates `aimdb-zenoh-connector`, `aimdb-cdr` and `aimdb-ros2-msgs`. +- New crates `aimdb-zenoh-connector` and `aimdb-cdr`. A prebuilt + `aimdb-ros2-msgs` is a later phase (§4.5). - Additive changes to `aimdb-data-contracts`: `RosMessage`, `Linkable::WIRE_FORMAT`, `LinkCodec::WIRE_FORMAT` (recorded on each link), and `link_codecs::Cdr`. - A new `#[derive(RosMessage)]` in `aimdb-derive`. - Additive changes to `aimdb-core`: two new route accessors that return a new `#[non_exhaustive]` `RouteMeta` (the record's `TypeId` and the link - config), plus a reserved `aimdb.topic_provider` config key. The existing + config), plus a reserved `aimdb.topic_provider` config key, and serializer + setters that clear the reserved `aimdb.wire_format` key. The existing `OutboundRoute`, `ConnectorLink` and `collect_*_routes` are left alone (§4.6). - `aimdb-codegen` is not touched. @@ -142,7 +160,9 @@ serves two schemes, which can share **one** Zenoh session: - Consuming the ROS graph. We announce our own entities and never build a graph cache. - A `.msg` importer or any other code generation. ROS types are written by hand - with a derive, or taken from `aimdb-ros2-msgs` (§4.5). + with a derive (§4.5). +- A prebuilt message crate (`aimdb-ros2-msgs`). It is postponed to a later phase + (§4.5). - Auto-exposing every record of a type. Topics belong to instances, not types, and writes into the plant stay explicit (§8). - DDS robots. A robot on the default `rmw_fastrtps_cpp` needs @@ -224,14 +244,9 @@ split is the precedent (`aimdb-mqtt-connector/src/connector.rs`). ### 4.2 User-facing API -Common ROS types come ready-made from `aimdb-ros2-msgs` (§4.5): - -```rust -use aimdb_ros2_msgs::sensor_msgs::Temperature; -``` - -A custom type from the robot team's package is a hand-written struct with one -derive: +In v1 every ROS type is a hand-written struct with one derive. That covers +standard interfaces such as `sensor_msgs/Temperature` and custom types from the +robot team's package alike (§4.5): ```rust #[derive(Clone, Debug, Serialize, Deserialize, RosMessage)] @@ -297,6 +312,36 @@ let mut builder = AimDbBuilder::new() .with_connector(ros2); ``` +**Domain ID and namespace.** These follow ROS's own layering. The domain belongs to +the context, which is one session here. The namespace belongs to the node. + +```rust +let ros2 = Ros2Connector::new("tcp/192.168.10.5:7447", + Ros2Node::new("cell4_gateway").namespace("/cell4")) + .domain_id(7) + .register::(); + +// Shared session: the domain goes on the ROS view, since `zenoh://` has none. +let ros2 = zenoh.ros2(Ros2Node::new("cell4_gateway").namespace("/cell4")).domain_id(7); +``` + +- **The domain resolves the way rcl resolves it.** An explicit `.domain_id(n)` wins. + Without one, the connector reads `ROS_DOMAIN_ID`, and falls back to `0` when that + is unset. **[checked]**: rcl reads the variable only when no domain was set + (`rcl/src/rcl/init.c:152`, `domain_id.c:25`), and rmw_zenoh stores one domain per + context (`rmw_init.cpp:66`). Reading the environment matters because the domain is + the first segment of every key and token. A gateway pinned to `0` while the robot + runs on `7` fails silently, like a wrong type hash (§3). A `ROS_DOMAIN_ID` that is + not an integer fails `build()`, as it fails `rcl_init`. +- **The domain is on the connector, not on `Ros2Node`.** If several nodes per + session arrive later (§4.7), they cannot disagree on a domain that rmw_zenoh keeps + per context anyway. +- **The namespace is set in code** or from the application's config. AimDB is not + started by `ros2 launch`, so `--ros-args -r __ns:=…` is not parsed. The default + is the root namespace. The namespace changes the node's identity (its tokens and + `ros2 node list`), not its topics (§4.3). +- **The enclave** is always the default (`%` in tokens) in v1. + `Ros2Connector` and `.ros2(..)` exist only with the `std` feature. On an embedded build, a `ros2://` link fails `build()` with core's unregistered-scheme error, and the connector docs point to §4.8. @@ -321,7 +366,9 @@ from a Zenoh or rmw_zenoh config work unchanged. The native backend also accepts records sharing a key would each receive every sample N times. MQTT dedupes the same way through `RouterBuilder::resource_ids()`. The route topic is the one `collect_inbound_routes` resolves, so an inbound `with_topic_resolver` is - honoured. + honoured. The cost: a record linked to `aimdb/*/state` gets the payload but not + the concrete key it arrived on, so it cannot tell `cell4` from `cell5`. A record + that needs to know links one concrete key per record. - No tokens, no attachment, no type in the key. CDR over `zenoh://` is allowed (`linked_to_with(url, Cdr::<256>)`) for non-ROS consumers that want it. It will not reach ROS, and the docs say so. @@ -329,10 +376,13 @@ from a Zenoh or rmw_zenoh config work unchanged. The native backend also accepts **`ros2://`: a ROS topic** - The resource is the fully qualified topic name without its leading slash, so - `ros2://cell4/temperature` is `/cell4/temperature`. v1 does not resolve relative - names against the node namespace. **[verified]**: `LinkAddress::parse` keeps - `/` and strips leading slashes, so `ros2:///cell4/temperature` is the same - topic. + `ros2://cell4/temperature` is `/cell4/temperature`. Topics are never resolved + against the node namespace: a node in `/cell4` linked to `ros2://temperature` + publishes `/temperature`. The URL could not express the difference anyway. + **[verified]**: `LinkAddress::parse` keeps `/` and strips leading slashes, so + `ros2:///cell4/temperature` is the same topic, and an absolute name cannot be + told from a relative one. The connector docs say this, since ROS users will + expect a namespace to prefix topics. - Direction comes from the link. `linked_to` makes AimDB a ROS publisher (`MP` token); `linked_from` makes it a subscriber (`MS` token). No method names the role. The scheme names the protocol, as `mqtt://` and `knx://` already do. @@ -347,13 +397,14 @@ from a Zenoh or rmw_zenoh config work unchanged. The native backend also accepts | Mistake | Caught | How | |---|---|---| | `.register::()` on a type that isn't a `RosMessage` | compile time | `register` is bounded on `T: RosMessage` | -| `.register::()` on a hand-implemented `RosMessage` whose `Linkable` is not CDR | `cargo build` (not `cargo check`) | `register` contains `const { assert!(matches!(T::WIRE_FORMAT, WireFormat::Cdr)) }` (§4.4). `matches!` rather than `==`, because `PartialEq` is not usable in const context. The derive always emits CDR, so derived types cannot hit this. **[verified on 1.98.0]**: a const in a generic function is evaluated at monomorphization, so the check fails `cargo build`, while `cargo check` and rust-analyzer stay silent. The binary still cannot ship with the mistake, and the docs say where it surfaces | +| `.register::()` on a hand-implemented `RosMessage` whose `Linkable` is not CDR | `build()` | `register` checks `T::WIRE_FORMAT` (§4.4), and `build()` reports the mismatch with the type's name. The derive always emits CDR, so only hand-written impls can hit this. rev 5's inline-const assert was dropped in rev 7. It fired on `cargo build` but not on `cargo check` or in rust-analyzer, and it needed a special trybuild setup, all for a rare mistake | | A malformed `type` or `hash` in `#[ros(..)]` | compile time | The derive validates `pkg/msg/Name` and `RIHS01_` + 64 hex | | A malformed type name or hash in a hand-written impl | `build()` | Checked at registration, so the error names the type | | A `ros2://` link on a type that was never registered | `build()` | The route's `TypeId` (from `RouteMeta`, §4.6) is missing from the registry | | A `ros2://` link with a non-CDR codec: `linked_to_with(url, Json)`, or `.with_link_codec(Postcard::)` on the long form | `build()` | Every codec verb records its `LinkCodec::WIRE_FORMAT` in the link config (§4.4), and the connector refuses anything other than `cdr`, read from `RouteMeta::config` | | A `ros2://` outbound link with `.with_topic_provider(..)` | `build()` | A provider can send a value to a topic that has no precomputed key, token or GID. `finish()` records `aimdb.topic_provider` in the link config (§4.6), so the connector refuses it | -| A topic that breaks ROS name rules | `build()` | The profile's name validator | +| A topic, node name or namespace that breaks ROS name rules | `build()` | The profile's name validator | +| A `ROS_DOMAIN_ID` that is not an integer | `build()` | Parsed once at build, as `rcl_init` does (§4.2) | | `ros2://` links with no `Ros2Connector` registered | `build()` | Core's existing refusal for a scheme no connector claims. **[verified]**: it is recorded when the link is finished (`typed_api.rs:930`) and reported by `build()`, so `with_connector` must come before `configure`, as every example here does | Two gaps remain that the design cannot close. @@ -363,8 +414,8 @@ Two gaps remain that the design cannot close. serializer, and a connector cannot inspect a closure. Such a link carries no recorded wire format, so the connector **warns** at `build()` rather than refusing it. That keeps it the documented escape hatch that voids the - guarantee. A `with_serializer` placed *after* a codec verb overrides the - serializer but keeps the recorded format, so the warning cannot see that case. + guarantee. The warning also covers a `with_serializer` placed *after* a codec + verb, because the serializer setters clear the recorded format (§4.4). 2. **Hand-written field layout.** CDR is positional. A struct whose fields are out of order or wrongly typed relative to the `.msg` (`f32` for `float64`) decodes garbage or fails to decode at runtime. Nothing catches that at compile time in @@ -426,7 +477,13 @@ pub enum WireFormat { Unspecified, Json, Postcard, Cdr } **`#[derive(RosMessage)]`**, in `aimdb-derive` and re-exported by `aimdb-data-contracts` under `ros2`, takes `#[ros(type = "pkg/msg/Name", hash = "RIHS01_…")]` and emits three impls, so the user writes none by hand: -- `SchemaType`, with `NAME` set to the ROS type (`cell_msgs/msg/SpindleCommand`); +- `SchemaType`, with `NAME` set to the ROS type (`cell_msgs/msg/SpindleCommand`). + Existing `NAME`s are plain identifiers such as `"temperature"`. **[verified]** + that its consumers treat it as an opaque string, so the slashes are harmless: + the WebSocket `StreamableRegistry` and the WASM schema registry key on it + (`registry.rs:35`, `schema_registry.rs:114`), and it is `Observable::SIGNAL`'s + default (`lib.rs:218`). ROS type names are globally unique, which suits the + WebSocket registry's refusal of two types under one name; - `Linkable` as CDR through `aimdb-cdr`, with `WIRE_FORMAT = Cdr`, a bounded `encode_into`, and `ENCODE_BUFFER_CAPACITY` (overridable with `#[ros(encode_capacity = N)]`); @@ -457,6 +514,15 @@ the blessed design-045 path, not an escape hatch. Without this record, and no connector in the tree rejects unknown keys, so the extra entry is inert everywhere else. +**The record follows the serializer.** Core's `with_serializer`, +`with_serializer_into` and `with_deserializer` remove any recorded +`aimdb.wire_format`. The codec verbs install their serializer through those same +setters (**[verified]** `linkable.rs:44`, `link_codec.rs:184`), then record the +format. So a link carries a format exactly when a codec verb was the last to set +its serializer. A custom serializer anywhere after it leaves no format, and the +connector warns (§4.3). Removing the entry, rather than appending a second one, +means no reader has to know which duplicate wins. + **CDR becomes the type's default format everywhere.** An MQTT link on a `RosMessage` type also sends CDR unless it names another codec (`linked_to_with(url, Json)`, design 045). For a type that exists to mirror a ROS @@ -470,7 +536,10 @@ opposite case: CDR on a link whose type's default is something else. It advertis crate behind RustDDS and `ros2-client`, is std-only **[checked]**: it imports `std::io` and `std::marker`. The new `aimdb-cdr` crate provides serde for XCDR1: - `no_std`, with `alloc` optional; -- little-endian; +- encodes little-endian, as rmw_zenoh does on every target AimDB ships (§3); +- decodes both byte orders, taken from the encapsulation header (`CDR_LE` or + `CDR_BE`), since a big-endian ROS host sends big-endian CDR under the same + type. Any other representation ID is a decode error; - the encapsulation header; - alignment measured from the end of that header; - strings as a `u32` length that includes the NUL; @@ -480,32 +549,18 @@ It is a few hundred lines, tested against golden bytes captured from `ros2 topic pub`. An upstream `no_std` PR to `cdr-encoding` is worth offering, but not worth waiting for. -### 4.5 Getting ROS types: prebuilt or hand-written, no codegen - -There are two paths, and neither runs a generator on the user's machine. +### 4.5 Getting ROS types: hand-written, no codegen -**Prebuilt: `aimdb-ros2-msgs`.** This new crate covers the interfaces plant -integrations use most: -- `builtin_interfaces` (`Time`, `Duration`); -- `std_msgs` (`Header` and the primitive wrappers); -- `sensor_msgs` (`Temperature`, `JointState`, `Imu`, `FluidPressure`, `Range`, …); -- `geometry_msgs` (`Pose`, `Twist`, `Transform` and their stamped forms). - -The crate is `no_std`, CDR-only, and each type is behind a per-package feature so -firmware links only what it uses. We produce it once per ROS distro with an -internal script run against a real ROS install. That script lives in the AimDB -repo's tooling, not in any published crate. A hash is derived from the interface -definition, so it should be stable across Jazzy, Kilted and Lyrical wherever the -interface is unchanged. CI verifies that per distro (§9) rather than assuming it. Users `cargo add` the crate and are done. - -**Hand-written: custom interfaces.** The robot team's own messages -(`cell_msgs/…`) are usually few and small. The user writes the struct with -`#[derive(RosMessage)]` (§4.4): +In v1 there is one path, and it runs no generator. A cell usually touches a +handful of interfaces, standard and custom, and each is a short struct. The user +writes it with `#[derive(RosMessage)]` (§4.4): - **Field order** follows the `.msg` exactly. CDR is positional. - **Field names** should match the `.msg` too. They do not affect the wire, but they keep the door open for the hash check below. - **Constants** in the `.msg` are not on the wire and are left out. -- **Nested types** are other `RosMessage` types, usually from `aimdb-ros2-msgs`. +- **Nested types** are other hand-written `RosMessage` types. A stamped message + therefore also needs `std_msgs/Header` and `builtin_interfaces/Time`, two + short structs written once per project (§4.10 shows them). - **The hash** is copied once from the robot. `ros2 topic info -v` prints it for any active topic as `Topic type hash: RIHS01_…` on Jazzy, Kilted, Lyrical and Rolling (**[verified]**, S4 in §10). rmw_zenoh also carries it in every data key. @@ -532,6 +587,15 @@ pasted one, a wrong field order, a wrong type or a stale hash all become compile errors. That closes both gaps from §4.3. It is deferred until usage shows it is needed. +**Later phase: a prebuilt `aimdb-ros2-msgs`.** A crate of the common interfaces +(`builtin_interfaces`, `std_msgs`, `sensor_msgs`, `geometry_msgs`) would spare +users the hand-written standard types. It is postponed because it brings its own +upkeep: an internal generation script run against a real ROS install, per-package +features, and a CI job in a ROS image that checks every shipped type's hash and +decoding per distro. That is worth doing once v1 shows which interfaces users +actually hand-write. Nothing in v1 has to change for it: the crate would contain +only `#[derive(RosMessage)]` types. + ### 4.6 Changes to core (all additive) The connector needs three facts per route that core's route accessors do not @@ -564,6 +628,10 @@ The additions instead: } ``` + For outbound routes, `topic` and `config` repeat what `OutboundRoute` already + carries. That is deliberate: one shape serves both directions, and a connector + reads route facts from `RouteMeta` alone. + 2. **Two new accessors** that pair each route with its `RouteMeta`: - `collect_outbound_routes_with_meta(scheme) -> Vec<(OutboundRoute, RouteMeta)>`; - `collect_inbound_routes_with_meta(scheme) -> Vec<(IngestFn, RouteMeta)>`. @@ -579,6 +647,11 @@ The additions instead: from §4.4 uses the same mechanism under `aimdb.wire_format`. Both keys live in the `aimdb.` namespace, which connectors must not use for their own options. +4. **Serializer setters clear the recorded wire format.** `with_serializer`, + `with_serializer_into` and `with_deserializer` remove any `aimdb.wire_format` + entry (§4.4). No signature changes, and links without the key behave as + before. + `Ros2Connector` therefore does not use core's `pump_sink` / `pump_source`, because those call the old accessors. It drives its own pumps over the `_with_meta` accessors, which is also what lets it carry a route index instead of a topic @@ -711,12 +784,24 @@ at implementation time. use aimdb_core::{buffer::BufferCfg, AimDbBuilder}; use aimdb_data_contracts::{link_codecs::Json, LinkCodecRegistrarExt, LinkableRegistrarExt, RosMessage}; use aimdb_mqtt_connector::MqttConnector; -use aimdb_ros2_msgs::{builtin_interfaces::Time, sensor_msgs::Temperature, std_msgs::Header}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; use aimdb_zenoh_connector::{Ros2Connector, Ros2Node}; use serde::{Deserialize, Serialize}; use std::sync::Arc; +// Standard interfaces, mirrored by hand like any other (§4.5). +#[derive(Clone, Debug, Serialize, Deserialize, RosMessage)] +#[ros(type = "builtin_interfaces/msg/Time", hash = "RIHS01_<…>")] +pub struct Time { pub sec: i32, pub nanosec: u32 } + +#[derive(Clone, Debug, Serialize, Deserialize, RosMessage)] +#[ros(type = "std_msgs/msg/Header", hash = "RIHS01_<…>")] +pub struct Header { pub stamp: Time, pub frame_id: String } + +#[derive(Clone, Debug, Serialize, Deserialize, RosMessage)] +#[ros(type = "sensor_msgs/msg/Temperature", hash = "RIHS01_<…>")] +pub struct Temperature { pub header: Header, pub temperature: f64, pub variance: f64 } + /// Mirrored by hand from cell_msgs/msg/SpindleCommand.msg (§4.5). #[derive(Clone, Debug, Serialize, Deserialize, RosMessage)] #[ros(type = "cell_msgs/msg/SpindleCommand", hash = "RIHS01_")] @@ -978,8 +1063,8 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. - **A `.msg` codegen importer** (rev 2). It gets field layout and hashes right mechanically, but it adds a tool, vendored inputs, provenance files and a drift-check CI job to every user's repo. That is a lot of ceremony for the - handful of custom messages a cell usually has. The prebuilt crate covers the - common types, and the derive covers the rest. The derive's future hash check + handful of messages a cell usually has. The derive covers all of them in v1, + and a prebuilt crate for the common ones can follow (§4.5). The derive's future hash check (§4.5) recovers most of codegen's safety without any of its workflow. - **`ros2://` on the MCU in v1** (rev 1–3). It would make the MCU a ROS node directly, but it put three open upstream items on the critical path: @@ -1001,7 +1086,8 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. 1. **Golden profile tests.** Data keys, node and entity tokens, QoS strings and GIDs are byte-equal to values captured from a real rmw_zenoh on Jazzy and Lyrical - (`ros2 topic info -v`, and the router's admin space for tokens). + (`ros2 topic info -v`, and the router's admin space for tokens). The captures + include a non-zero domain and a namespaced node. 2. **Interop CI (native).** In Docker (`ros:lyrical` plus rmw_zenoh), a `ros2://` outbound link arrives in `ros2 topic echo` with correct values. `ros2 topic info -v` lists the publisher with the right node, type, QoS and GID. @@ -1023,7 +1109,8 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. `cargo check -p aimdb-zenoh-connector --no-default-features --features embedded --target thumbv7em-none-eabihf` passes. Flash and RAM are recorded against the MQTT embedded build. 6. **`aimdb-cdr`.** Round-trip and golden-byte tests pass for primitives, strings, - sequences, fixed arrays and nested types. A fuzzed decode never panics. + sequences, fixed arrays and nested types, and big-endian golden bytes decode to + the same values. A fuzzed decode never panics. 7. **No `unsafe` in the connector.** The connector crate has no `unsafe impl` (052 criterion 2). Until zenoh-nostd is `Send`-clean, the only force-`Send` is the adapter's `into_box_future`. The MQTT connector does not meet that criterion @@ -1035,20 +1122,15 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. unregistered-scheme error. 9. **Refusals.** Every row of §4.3's guarantee table has a test: - The two compile-time rows are `trybuild` UI tests. - - The `WIRE_FORMAT` row is a `trybuild` test too, but its batch must also - contain a `pass` case. trybuild runs `cargo check` otherwise (1.0.121, - `cargo.rs:97`), and `cargo check` never evaluates the const (§4.3). - - The six build-time rows assert the `build()` error. - - The custom-serializer gap has a test asserting the warning. + - The eight build-time rows assert the `build()` error. + - The domain's precedence (explicit, then `ROS_DOMAIN_ID`, then `0`) has a test + per step. + - The custom-serializer gap has a test asserting the warning, both alone and + with `with_serializer` placed after a codec verb. A *wrong* but well-formed hash is covered by a test that documents it as silence, so nobody mistakes that behaviour for a connector bug. -10. **`aimdb-ros2-msgs` matches ROS.** A CI job in a ROS image checks every shipped - type, for each supported distro (Jazzy, Kilted, Lyrical), in two ways: - - its hash equals the one ROS reports; - - a message published with `ros2 topic pub` decodes into the Rust type with - the expected values, and the reverse direction round-trips. -11. **The derive.** `trybuild` tests cover malformed `type` and `hash` attributes, +10. **The derive.** `trybuild` tests cover malformed `type` and `hash` attributes, and golden tests cover the DDS-name mangling and every row of §4.5's mapping table. @@ -1082,8 +1164,9 @@ rev 6 answered S3, S4 and S5 from source and offline runs. The two-day spike *peer's* (`other_zid`). The fix is a one-line upstream accessor, or a `with_zid` constructor that lets AimDB choose the ZID it puts in its tokens. It joins the liveliness PR in §11 step 6. -- **Q6.** Should v1 resolve relative topic names against the node namespace, or - keep fully qualified names only as proposed? +- **Q6. Answered (rev 7): fully qualified only.** The namespace sets the node's + identity, not its topics (§4.2, §4.3). The link URL cannot tell an absolute + name from a relative one. - **Q7.** Publish the `aimdb-zenoh-nostd` fork now, or ship the embedded backend as unpublished (git-only) until upstream releases? - **Q8.** Should plain `zenoh://` links carry a contract fingerprint in Zenoh's @@ -1105,12 +1188,10 @@ rev 6 answered S3, S4 and S5 from source and offline runs. The two-day spike | 4 | Core `RouteMeta`, the `_with_meta` accessors and `aimdb.topic_provider` (§4.6); `Shared`, `ZenohConnector`, `Ros2Connector` and the registry on the native backend; interop CI (criteria 2, 8, 9). **This ships v1's ROS feature on its own** | 2, 3 | | 5 | Embedded backend (`zenoh://` only): gateway interop on host first (criterion 3), then STM32H5 (criteria 4, 5) | 1, 4 | | 6 | Upstream. For v1, a crates.io publish or the fork. The rest is v2 prep and off the critical path: `Send` cleanliness (all three causes in §5.2), the liveliness API PR, and an own-ZID accessor (S5) | 1 | -| 7 | `aimdb-ros2-msgs`: the internal generation script, the four packages, and the per-distro hash CI (criterion 10) | 2 | -| 8 | Docs: design 012 connector-guide section, a BYOC tutorial built on this connector, an "AimDB and ROS 2" page, and the manufacturing-cell demo | 4, 5 | +| 7 | Docs: design 012 connector-guide section, a BYOC tutorial built on this connector, an "AimDB and ROS 2" page, and the manufacturing-cell demo | 4, 5 | -Steps 2, 3 and 7 are pure and additive and can land early. Step 7 can also ship -ahead of the connector: the message types are useful on their own over `mqtt://` -with CDR. The effort estimate is unverified until the spike: roughly 4–6 weeks for +Steps 2 and 3 are pure and additive and can land early. `aimdb-ros2-msgs` is +not in the table: it is a later phase (§4.5). The effort estimate is unverified until the spike: roughly 4–6 weeks for one engineer, and the rev 4 scope cut should put it toward the low end. Most of the remaining variance is in steps 5 and 6, because both depend on zenoh-nostd being publishable. @@ -1131,7 +1212,8 @@ material. v2 (§4.9) is sequenced separately once its three preconditions hold. - Existing connectors, the Tokio and Embassy adapters, and the 052 traits are untouched. - `aimdb-core` gains one `#[non_exhaustive]` struct (`RouteMeta`), two accessors - and one reserved config key. No existing public struct gains a field. + and one reserved config key. Its serializer setters also clear the reserved + `aimdb.wire_format` key. No existing public struct gains a field. - `aimdb-data-contracts` gains two defaulted associated consts (`Linkable` and `LinkCodec`) and two features, both off by default. The codec verbs also record `aimdb.wire_format` on each link, which connectors that do not read @@ -1159,6 +1241,8 @@ material. v2 (§4.9) is sequenced separately once its three preconditions hold. - `ros2/ros2cli` (`ros2topic/verb/info.py`) and `ros2/rclpy` (`topic_endpoint_info.py`, `endpoint_info.py`, `type_hash.py`) on `jazzy`, `kilted`, `lyrical` and `rolling`. +- `ros2/rcl` `rolling` (`rcl/src/rcl/{init,domain_id}.c`) and rmw_zenoh's + `rmw_init.cpp`, for domain resolution (rev 7, §4.2). - `ros-infrastructure/rep` master @ `11ca24a` (`rep-2000.rst`) and `ros2/ros2_documentation` @ `617ded6` (`Release-Lyrical-Luth.rst` and the Lyrical changelog). From 8e182ca3c97a96871720e7a13ba930465cca710f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 26 Sep 2026 01:52:57 +0000 Subject: [PATCH 02/27] feat(core): RouteMeta route accessors and reserved link config keys --- aimdb-core/src/builder.rs | 71 ++++++++++++---- aimdb-core/src/connector.rs | 10 +++ aimdb-core/src/lib.rs | 2 +- aimdb-core/src/typed_api.rs | 161 ++++++++++++++++++++++++++++++++++-- 4 files changed, 221 insertions(+), 23 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 635e8773..9f0f7a65 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -47,6 +47,19 @@ pub struct OutboundRoute { pub config: Vec<(String, String)>, } +/// Per-route facts paired with each route by +/// [`AimDb::collect_outbound_routes_with_meta`] and +/// [`AimDb::collect_inbound_routes_with_meta`]. +#[non_exhaustive] +pub struct RouteMeta { + /// Resolved topic: the URL resource, or the inbound topic resolver's result. + pub topic: String, + /// `TypeId` of the record the route belongs to. + pub type_id: TypeId, + /// The link's configuration options. + pub config: Vec<(String, String)>, +} + /// One registered record: its key, concrete type, and type-erased storage. struct RecordEntry { key: StringKey, @@ -1175,22 +1188,33 @@ impl AimDb { &self, scheme: &str, ) -> Vec<(String, crate::connector::IngestFn)> { + self.collect_inbound_routes_with_meta(scheme) + .into_iter() + .map(|(ingest, meta)| (meta.topic, ingest)) + .collect() + } + + /// Like [`collect_inbound_routes`](Self::collect_inbound_routes), paired + /// with each route's [`RouteMeta`]. + pub fn collect_inbound_routes_with_meta( + &self, + scheme: &str, + ) -> Vec<(crate::connector::IngestFn, RouteMeta)> { let mut routes = Vec::new(); for entry in &self.inner.storages { - let inbound_links = entry.record.inbound_connectors(); - - for link in inbound_links { - // Filter by scheme + for link in entry.record.inbound_connectors() { if link.url.scheme() != scheme { continue; } // Resolve topic: dynamic (from resolver) or static (from URL) - let topic = link.resolve_topic(); - - // Create the fused ingest callback using the stored factory - routes.push((topic, link.create_ingest(self))); + let meta = RouteMeta { + topic: link.resolve_topic(), + type_id: entry.type_id, + config: link.config.clone(), + }; + routes.push((link.create_ingest(self), meta)); } } @@ -1220,23 +1244,38 @@ impl AimDb { /// # Arguments /// * `scheme` - URL scheme to filter by (e.g., "mqtt", "kafka") pub fn collect_outbound_routes(&self, scheme: &str) -> Vec { + self.collect_outbound_routes_with_meta(scheme) + .into_iter() + .map(|(route, _)| route) + .collect() + } + + /// Like [`collect_outbound_routes`](Self::collect_outbound_routes), paired + /// with each route's [`RouteMeta`]. + pub fn collect_outbound_routes_with_meta( + &self, + scheme: &str, + ) -> Vec<(OutboundRoute, RouteMeta)> { let mut routes = Vec::new(); for entry in &self.inner.storages { - let outbound_links = entry.record.outbound_connectors(); - - for link in outbound_links { - // Filter by scheme + for link in entry.record.outbound_connectors() { if link.url.scheme() != scheme { continue; } - // Create the fused source using the stored factory - routes.push(OutboundRoute { - topic: link.url.resource_id().to_string(), + let topic = link.url.resource_id().to_string(); + let meta = RouteMeta { + topic: topic.clone(), + type_id: entry.type_id, + config: link.config.clone(), + }; + let route = OutboundRoute { + topic, source: link.create_source(self), config: link.config.clone(), - }); + }; + routes.push((route, meta)); } } diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index fa4c521a..1f9eccbc 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -444,6 +444,16 @@ impl fmt::Display for ConnectorUrl { } } +/// Link config key naming the wire format of the link's codec (e.g. `"cdr"`). +/// +/// Recorded by codec verbs and removed by the serializer/deserializer setters, +/// so it is present only when a known codec installed the current serializer. +/// Keys prefixed `aimdb.` are reserved; connectors must not use them. +pub const WIRE_FORMAT_KEY: &str = "aimdb.wire_format"; + +/// Link config key set to `"true"` on outbound links with a topic provider. +pub const TOPIC_PROVIDER_KEY: &str = "aimdb.topic_provider"; + /// Configuration for a connector link /// /// Stores the parsed URL, configuration, and the fused source factory until diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index dbae34e6..ae57e88c 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -113,8 +113,8 @@ pub use executor::{BoxFuture, ExecutorError, ExecutorResult, LogLevel, RuntimeOp pub use buffer::JsonReader; pub use buffer::Reader; pub use buffer::TryProduceError; -pub use builder::OutboundRoute; pub use builder::{AimDb, AimDbBuilder}; +pub use builder::{OutboundRoute, RouteMeta}; pub use connector::ConnectorBuilder; pub use transport::{Connector, ConnectorConfig, PublishError}; pub use typed_api::{ diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 99a40476..729927ed 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -787,12 +787,22 @@ where self } + /// Drops any recorded [`WIRE_FORMAT_KEY`](crate::connector::WIRE_FORMAT_KEY): + /// a newly installed codec closure has no known format until a codec verb + /// records one. + fn clear_wire_format(mut self) -> Self { + self.config + .retain(|(key, _)| key != crate::connector::WIRE_FORMAT_KEY); + self + } + /// Sets the serialization callback /// /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) for /// platform-independent timestamps and logging, plus the typed value being /// serialized. Ignore the context parameter (`|_ctx, value| …`) when it is - /// not needed. + /// not needed. Removes any recorded + /// [`WIRE_FORMAT_KEY`](crate::connector::WIRE_FORMAT_KEY). pub fn with_serializer(mut self, f: F) -> Self where F: Fn(crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> @@ -801,7 +811,7 @@ where + 'static, { self.context_serializer = Some(Arc::new(f)); - self + self.clear_wire_format() } /// Adds an into-slice fast path beside the required owned serializer. @@ -815,6 +825,7 @@ where /// /// This method is an optimization only: callers must still install the /// owned serializer so oversized and legacy values retain their old behavior. + /// Removes any recorded [`WIRE_FORMAT_KEY`](crate::connector::WIRE_FORMAT_KEY). pub fn with_serializer_into(mut self, capacity: usize, f: F) -> Self where F: Fn( @@ -827,7 +838,7 @@ where + 'static, { self.context_serializer_into = Some((capacity, Arc::new(f))); - self + self.clear_wire_format() } /// Removes a previously installed into-slice serializer. @@ -856,7 +867,8 @@ where /// Sets a dynamic topic provider /// - /// The provider receives the value being published and returns + /// Recorded in the link config as + /// [`TOPIC_PROVIDER_KEY`](crate::connector::TOPIC_PROVIDER_KEY). The provider receives the value being published and returns /// the topic/destination to publish to. Return `None` to use the default /// static topic from the URL. /// @@ -958,6 +970,8 @@ where self.registrar.last_stage = Some((StageKind::Link, 0)); } + let has_topic_provider = self.topic_provider.is_some(); + // Fused source factory that captures type T and record key. // // Resolves the record at route-collection time (not per-message) and @@ -1009,6 +1023,12 @@ where let mut link = ConnectorLink::new(url, source_factory); link.config = self.config; + if has_topic_provider { + link.config.push(( + crate::connector::TOPIC_PROVIDER_KEY.to_string(), + "true".to_string(), + )); + } // Store the connector link - sources will be created later in build() // after connectors are actually built @@ -1050,18 +1070,28 @@ where self } + /// Drops any recorded [`WIRE_FORMAT_KEY`](crate::connector::WIRE_FORMAT_KEY): + /// a newly installed codec closure has no known format until a codec verb + /// records one. + fn clear_wire_format(mut self) -> Self { + self.config + .retain(|(key, _)| key != crate::connector::WIRE_FORMAT_KEY); + self + } + /// Sets the deserialization callback /// /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) for /// platform-independent timestamps and logging, plus the raw bytes from /// the external system. Ignore the context parameter (`|_ctx, data| …`) - /// when it is not needed. + /// when it is not needed. Removes any recorded + /// [`WIRE_FORMAT_KEY`](crate::connector::WIRE_FORMAT_KEY). pub fn with_deserializer(mut self, f: F) -> Self where F: Fn(crate::RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, { self.context_deserializer = Some(Arc::new(f)); - self + self.clear_wire_format() } /// Sets the operation timeout in milliseconds (the connector interprets @@ -2205,4 +2235,123 @@ mod tests { .expect_err("buffer closed"); assert!(matches!(closed, crate::DbError::BufferClosed { .. })); } + + #[test] + fn serializer_setters_clear_recorded_wire_format() { + use crate::connector::WIRE_FORMAT_KEY; + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + let builders: Vec> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + reg.link_to("mqtt://a") + .with_config(WIRE_FORMAT_KEY, "cdr") + .with_serializer(|_ctx, r: &TestRecord| Ok(r.value.to_le_bytes().to_vec())) + .finish(); + reg.link_to("mqtt://b") + .with_serializer(|_ctx, r: &TestRecord| Ok(r.value.to_le_bytes().to_vec())) + .with_config(WIRE_FORMAT_KEY, "cdr") + .with_serializer_into(4, |_ctx, _r: &TestRecord, _out| Ok(0)) + .finish(); + reg.link_from("mqtt://c") + .with_config(WIRE_FORMAT_KEY, "cdr") + .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 0 })) + .finish(); + reg.link_to("mqtt://d") + .with_serializer(|_ctx, r: &TestRecord| Ok(r.value.to_le_bytes().to_vec())) + .with_config(WIRE_FORMAT_KEY, "cdr") + .finish(); + + let has_format = + |config: &[(String, String)]| config.iter().any(|(k, _)| k == WIRE_FORMAT_KEY); + let outbound = rec.outbound_connectors(); + assert!(!has_format(&outbound[0].config)); + assert!(!has_format(&outbound[1].config)); + assert!(has_format(&outbound[2].config), "recorded after the setter"); + assert!(!has_format(&rec.inbound_connectors()[0].config)); + } + + #[test] + fn topic_provider_is_recorded_in_link_config() { + use crate::connector::TOPIC_PROVIDER_KEY; + + struct NoTopic; + impl TopicProvider for NoTopic { + fn topic(&self, _value: &TestRecord) -> Option { + None + } + } + + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + let builders: Vec> = + vec![Box::new(MockConnectorBuilder { + scheme: "mqtt".to_string(), + })]; + let extensions = crate::extensions::Extensions::new(); + let mut reg = make_registrar(&mut rec, &builders, &extensions); + + reg.link_to("mqtt://with") + .with_topic_provider(NoTopic) + .with_serializer(|_ctx, _r: &TestRecord| Ok(vec![])) + .finish(); + reg.link_to("mqtt://without") + .with_serializer(|_ctx, _r: &TestRecord| Ok(vec![])) + .finish(); + + let outbound = rec.outbound_connectors(); + assert!(outbound[0] + .config + .contains(&(TOPIC_PROVIDER_KEY.to_string(), "true".to_string()))); + assert!(!outbound[1] + .config + .iter() + .any(|(k, _)| k == TOPIC_PROVIDER_KEY)); + } + + #[tokio::test] + async fn route_meta_carries_type_topic_and_config() { + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.meta", |reg| { + reg.buffer_raw(Box::new(MockBuffer)); + reg.link_to("mqtt://tele/out") + .with_serializer(|_ctx, _r: &TestRecord| Ok(vec![])) + .with_config("qos", "1") + .finish(); + }); + builder.configure::("rec.meta.in", |reg| { + reg.buffer_raw(Box::new(MockBuffer)); + reg.link_from("mqtt://cmd/in") + .with_topic_resolver(|| Some("cmd/resolved".to_string())) + .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 0 })) + .with_config("qos", "2") + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + + let outbound = db.collect_outbound_routes_with_meta("mqtt"); + assert_eq!(outbound.len(), 1); + let (route, meta) = &outbound[0]; + assert_eq!(route.topic, "tele/out"); + assert_eq!(meta.topic, "tele/out"); + assert_eq!(meta.type_id, core::any::TypeId::of::()); + assert_eq!(meta.config, vec![("qos".to_string(), "1".to_string())]); + + let inbound = db.collect_inbound_routes_with_meta("mqtt"); + assert_eq!(inbound.len(), 1); + let (_ingest, meta) = &inbound[0]; + assert_eq!(meta.topic, "cmd/resolved"); + assert_eq!(meta.type_id, core::any::TypeId::of::()); + assert_eq!(meta.config, vec![("qos".to_string(), "2".to_string())]); + + assert_eq!(db.collect_inbound_routes("mqtt")[0].0, "cmd/resolved"); + assert!(db.collect_outbound_routes_with_meta("knx").is_empty()); + } } From 6fa88a86f484331c07c56697db5358e9be762263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 26 Sep 2026 01:53:11 +0000 Subject: [PATCH 03/27] =?UTF-8?q?docs(design):=20053=20rev=208=20=E2=80=94?= =?UTF-8?q?=20assess=20hiroz;=20use=20it=20as=20a=20test=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/053-zenoh-connector.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/design/053-zenoh-connector.md b/docs/design/053-zenoh-connector.md index 8c7fb589..7128b6d2 100644 --- a/docs/design/053-zenoh-connector.md +++ b/docs/design/053-zenoh-connector.md @@ -68,6 +68,10 @@ The changes are in the scope list, §2, §4.2–4.6, §4.10, §8, §9, §10, §11 and §12. +- **rev 8, 2026-09-26.** hiroz, a native Rust ROS 2 stack on Zenoh, is + assessed. Our traits stay independent of it. Its crates serve as test oracles + for CDR bytes and rmw_zenoh keys, and its prebuilt messages are a follow-up + (§4.4, §4.5, §8, §9, §13). Nothing is implemented. **[checked]** marks a claim read from upstream source at the commits listed in §13; **[verified]** marks one confirmed by reading or @@ -546,7 +550,9 @@ crate behind RustDDS and `ros2-client`, is std-only **[checked]**: it imports - sequences with a `u32` count; fixed arrays without one. It is a few hundred lines, tested against golden bytes captured from -`ros2 topic pub`. An upstream `no_std` PR to `cdr-encoding` is worth offering, but +`ros2 topic pub`, and byte for byte against `hiroz-cdr` as a dev-dependency. +`hiroz-cdr` itself does not fit: it needs std and `zenoh-buffers`, has no +bounded encode path, and rejects big-endian input. An upstream `no_std` PR to `cdr-encoding` is worth offering, but not worth waiting for. ### 4.5 Getting ROS types: hand-written, no codegen @@ -596,6 +602,11 @@ decoding per distro. That is worth doing once v1 shows which interfaces users actually hand-write. Nothing in v1 has to change for it: the crate would contain only `#[derive(RosMessage)]` types. +**Follow-up: hiroz's messages.** `hiroz-msgs` already ships the common interfaces, +generated from `.msg` files. A std-only `hiroz` feature on the connector could +wrap them in a `HirozMsg` that implements `Linkable` and `RosMessage`. That +may replace `aimdb-ros2-msgs`, and it keeps hiroz out of `aimdb-data-contracts`. + ### 4.6 Changes to core (all additive) The connector needs three facts per route that core's route accessors do not @@ -1078,6 +1089,15 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. - **Plain Zenoh only, and let users run `zenoh-bridge-ros2dds`.** This works with DDS robots, but AimDB entities stay invisible to the ROS graph and every user maps types and keys by hand. +- **Building on hiroz** (`ZettaScaleLabs/hiroz`), either by adopting its + message traits or by running the std side on its node API. Its traits live in + std crates that depend on `zenoh`, its CDR traits have no bounded encode path, + and it is at 0.2. `RosMessage` has to stay in the `no_std` contracts crate + under 2.x semver. The type identity already matches: hiroz's `type_name()` is + our `ROS_TYPE_NAME`, and both carry the RIHS01 hash. So v1 uses hiroz as a + test oracle (§9). Its `hiroz-schema` computes RIHS01 hashes and is the + starting point for §4.5's hash check. A std backend built on hiroz, which + would bring services, actions and `TRANSIENT_LOCAL`, is a v2 question. - **micro-ROS.** An XRCE agent and a C stack. Not our architecture. - **An `rmw_aimdb` plugin.** A huge C surface that would make AimDB ROS middleware instead of the typed layer beside it. @@ -1087,7 +1107,8 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. 1. **Golden profile tests.** Data keys, node and entity tokens, QoS strings and GIDs are byte-equal to values captured from a real rmw_zenoh on Jazzy and Lyrical (`ros2 topic info -v`, and the router's admin space for tokens). The captures - include a non-zero domain and a namespaced node. + include a non-zero domain and a namespaced node. Until they exist, the same + values are cross-checked against `hiroz-protocol`. 2. **Interop CI (native).** In Docker (`ros:lyrical` plus rmw_zenoh), a `ros2://` outbound link arrives in `ros2 topic echo` with correct values. `ros2 topic info -v` lists the publisher with the right node, type, QoS and GID. @@ -1110,7 +1131,8 @@ on the `#[non_exhaustive]` `RouteMeta`, with no new accessor. passes. Flash and RAM are recorded against the MQTT embedded build. 6. **`aimdb-cdr`.** Round-trip and golden-byte tests pass for primitives, strings, sequences, fixed arrays and nested types, and big-endian golden bytes decode to - the same values. A fuzzed decode never panics. + the same values. Little-endian output equals `hiroz-cdr`'s. A fuzzed decode + never panics. 7. **No `unsafe` in the connector.** The connector crate has no `unsafe impl` (052 criterion 2). Until zenoh-nostd is `Send`-clean, the only force-`Send` is the adapter's `into_box_future`. The MQTT connector does not meet that criterion @@ -1262,6 +1284,8 @@ material. v2 (§4.9) is sequenced separately once its three preconditions hold. `aimdb-embassy-adapter/src/{connectors.rs,net.rs}`, and `aimdb-mqtt-connector/src/{connector.rs,native.rs,link_ext.rs,embedded/{mod,session,tls}.rs}` and `tests/tokio_broker.rs`. +- `ZettaScaleLabs/hiroz` @ `c503843` (2026-09-23), rev 8: `hiroz-cdr`, + `hiroz-protocol`, `hiroz-schema`, `hiroz-msgs`, and `hiroz/src/{msg,ros_msg}.rs`. - rev 5 also ran two checks on the pinned rustc 1.98.0. One confirmed that a generic inline-const assert passes `cargo check` and fails `cargo build`. The other read trybuild 1.0.121's mode selection (`src/cargo.rs:97`). From efe890fd82c42368ad150aacddbe15ce69476f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 26 Sep 2026 02:01:50 +0000 Subject: [PATCH 04/27] feat(data-contracts): WireFormat and per-link aimdb.wire_format record --- aimdb-data-contracts/src/lib.rs | 6 +- aimdb-data-contracts/src/link_codec.rs | 165 +++++++++++++++++- aimdb-data-contracts/src/linkable.rs | 18 +- aimdb-data-contracts/tests/linkable_derive.rs | 11 +- aimdb-derive/src/lib.rs | 3 + 5 files changed, 184 insertions(+), 19 deletions(-) diff --git a/aimdb-data-contracts/src/lib.rs b/aimdb-data-contracts/src/lib.rs index 0139b474..c5364e2b 100644 --- a/aimdb-data-contracts/src/lib.rs +++ b/aimdb-data-contracts/src/lib.rs @@ -76,7 +76,7 @@ mod link_codec; mod linkable; #[cfg(feature = "linkable")] -pub use link_codec::{link_codecs, LinkCodec, LinkCodecBuilderExt}; +pub use link_codec::{link_codecs, LinkCodec, LinkCodecBuilderExt, WireFormat}; #[cfg(feature = "linkable")] pub use linkable::{LinkCodecRegistrarExt, LinkableRegistrarExt}; @@ -270,6 +270,10 @@ pub trait Linkable: SchemaType + Sized { /// retries that value through [`to_bytes`](Self::to_bytes). const ENCODE_BUFFER_CAPACITY: Option = None; + /// What [`to_bytes`](Self::to_bytes) / [`from_bytes`](Self::from_bytes) speak. + /// Recorded on links that use this impl as their codec. + const WIRE_FORMAT: WireFormat = WireFormat::Unspecified; + /// Deserialize from bytes (e.g., MQTT payload). /// /// Returns `Err` with error message on parse failure. diff --git a/aimdb-data-contracts/src/link_codec.rs b/aimdb-data-contracts/src/link_codec.rs index 6abd90a4..72088712 100644 --- a/aimdb-data-contracts/src/link_codec.rs +++ b/aimdb-data-contracts/src/link_codec.rs @@ -25,9 +25,53 @@ use alloc::string::ToString; use alloc::vec::Vec; use core::fmt::Debug; -use aimdb_core::connector::SerializeError; +use aimdb_core::connector::{SerializeError, WIRE_FORMAT_KEY}; use aimdb_core::typed_api::{InboundConnectorBuilder, OutboundConnectorBuilder}; +/// The encoding a [`Linkable`](crate::Linkable) or [`LinkCodec`] speaks. +/// +/// Codec verbs record it in the link config under +/// [`WIRE_FORMAT_KEY`] so connectors can check it at build time. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WireFormat { + /// Not declared; nothing is recorded. + Unspecified, + /// JSON. + Json, + /// Postcard. + Postcard, + /// OMG CDR (XCDR1) with encapsulation header, as used by ROS 2. + Cdr, +} + +impl WireFormat { + /// The value recorded under [`WIRE_FORMAT_KEY`]; `None` for `Unspecified`. + pub const fn as_str(self) -> Option<&'static str> { + match self { + Self::Unspecified => None, + Self::Json => Some("json"), + Self::Postcard => Some("postcard"), + Self::Cdr => Some("cdr"), + } + } + + /// Reads the format recorded in a link config; `Unspecified` if absent or + /// unknown. + pub fn recorded_in(config: &[(String, String)]) -> Self { + let value = config + .iter() + .find(|(key, _)| key == WIRE_FORMAT_KEY) + .map(|(_, value)| value.as_str()); + match value { + Some("json") => Self::Json, + Some("postcard") => Self::Postcard, + Some("cdr") => Self::Cdr, + _ => Self::Unspecified, + } + } +} + /// A wire codec selected for one inbound or outbound record link. /// /// The owned [`encode`](Self::encode) operation is always required so an @@ -47,6 +91,9 @@ pub trait LinkCodec: Clone + Send + Sync + 'static { /// scratch serializer alongside the mandatory owned fallback. const ENCODE_BUFFER_CAPACITY: Option = None; + /// What this codec puts on the wire, recorded on each link it is installed on. + const WIRE_FORMAT: WireFormat = WireFormat::Unspecified; + /// Decode one connector payload into a record. fn decode(&self, bytes: &[u8]) -> Result; @@ -91,6 +138,7 @@ where T: crate::Linkable, { const ENCODE_BUFFER_CAPACITY: Option = T::ENCODE_BUFFER_CAPACITY; + const WIRE_FORMAT: WireFormat = T::WIRE_FORMAT; fn decode(&self, bytes: &[u8]) -> Result { T::from_bytes(bytes) @@ -110,6 +158,8 @@ impl LinkCodec for link_codecs::Json where T: serde::Serialize + serde::de::DeserializeOwned, { + const WIRE_FORMAT: WireFormat = WireFormat::Json; + fn decode(&self, bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(|error| error.to_string()) } @@ -134,6 +184,7 @@ where T: serde::Serialize + serde::de::DeserializeOwned, { const ENCODE_BUFFER_CAPACITY: Option = Some(N); + const WIRE_FORMAT: WireFormat = WireFormat::Postcard; fn decode(&self, bytes: &[u8]) -> Result { postcard::from_bytes(bytes).map_err(|error| error.to_string()) @@ -164,7 +215,8 @@ where /// /// Calling this method again replaces the complete codec strategy. In /// particular, an owned-only codec clears any scratch serializer installed - /// by an earlier bounded codec. + /// by an earlier bounded codec. The codec's [`WireFormat`] is recorded + /// under [`WIRE_FORMAT_KEY`]. fn with_link_codec(self, codec: C) -> Self where C: LinkCodec; @@ -178,7 +230,7 @@ where where C: LinkCodec, { - match C::ENCODE_BUFFER_CAPACITY { + let builder = match C::ENCODE_BUFFER_CAPACITY { Some(capacity) => { let scratch_codec = codec.clone(); self.with_serializer(move |_ctx, value| codec.encode(value)) @@ -189,6 +241,11 @@ where None => self .with_serializer(move |_ctx, value| codec.encode(value)) .clear_serializer_into(), + }; + // After the setters, which clear any earlier record. + match C::WIRE_FORMAT.as_str() { + Some(format) => builder.with_config(WIRE_FORMAT_KEY, format), + None => builder, } } } @@ -201,7 +258,11 @@ where where C: LinkCodec, { - self.with_deserializer(move |_ctx, bytes| codec.decode(bytes)) + let builder = self.with_deserializer(move |_ctx, bytes| codec.decode(bytes)); + match C::WIRE_FORMAT.as_str() { + Some(format) => builder.with_config(WIRE_FORMAT_KEY, format), + None => builder, + } } } @@ -632,4 +693,100 @@ mod tests { ); }); } + + #[test] + fn wire_format_round_trips_through_link_config() { + use super::WireFormat; + use aimdb_core::connector::WIRE_FORMAT_KEY; + + for format in [WireFormat::Json, WireFormat::Postcard, WireFormat::Cdr] { + let config = vec![( + WIRE_FORMAT_KEY.to_string(), + format.as_str().expect("named format").to_string(), + )]; + assert_eq!(WireFormat::recorded_in(&config), format); + } + assert_eq!(WireFormat::Unspecified.as_str(), None); + assert_eq!(WireFormat::recorded_in(&[]), WireFormat::Unspecified); + let unknown = vec![(WIRE_FORMAT_KEY.to_string(), "xml".to_string())]; + assert_eq!(WireFormat::recorded_in(&unknown), WireFormat::Unspecified); + } + + #[cfg(all(feature = "linkable-json", feature = "linkable-postcard"))] + #[test] + fn codec_verbs_record_the_last_codecs_wire_format() { + use super::WireFormat; + use crate::LinkableRegistrarExt; + use aimdb_core::connector::WIRE_FORMAT_KEY; + + futures::executor::block_on(async { + let reading = Reading { + value: 1.0, + sequence: 1, + }; + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(NoopRuntimeOps)) + .with_connector(NoopConnector); + builder.configure::("reading.formats", |registrar| { + registrar.buffer_raw(Box::new(CannedBuffer { value: reading })); + registrar.linked_to_with("test://json", link_codecs::Json); + registrar.linked_to_with("test://postcard", link_codecs::Postcard::<64>); + // `Reading`'s own Linkable declares no format. + registrar.linked_to("test://default"); + registrar + .link_to("test://replaced") + .with_link_codec(link_codecs::Postcard::<64>) + .with_link_codec(link_codecs::Json) + .finish(); + registrar + .link_to("test://custom-after-codec") + .with_link_codec(link_codecs::Json) + .with_serializer(|_ctx, _value| Ok(Vec::new())) + .finish(); + }); + builder.configure::("reading.formats.in", |registrar| { + registrar.buffer_raw(Box::new(CapturingBuffer { + latest: Arc::new(std::sync::Mutex::new(None)), + })); + registrar.linked_from_with("test://json-in", link_codecs::Json); + registrar.linked_from("test://default-in"); + }); + let (db, _runner) = builder.build().await.expect("build"); + + let mut formats: Vec<(String, WireFormat, usize)> = db + .collect_outbound_routes_with_meta("test") + .into_iter() + .map(|(_, meta)| meta) + .chain( + db.collect_inbound_routes_with_meta("test") + .into_iter() + .map(|(_, meta)| meta), + ) + .map(|meta| { + let records = meta + .config + .iter() + .filter(|(key, _)| key == WIRE_FORMAT_KEY) + .count(); + (meta.topic, WireFormat::recorded_in(&meta.config), records) + }) + .collect(); + formats.sort_by(|a, b| a.0.cmp(&b.0)); + + let expected = [ + ("custom-after-codec", WireFormat::Unspecified, 0), + ("default", WireFormat::Unspecified, 0), + ("default-in", WireFormat::Unspecified, 0), + ("json", WireFormat::Json, 1), + ("json-in", WireFormat::Json, 1), + ("postcard", WireFormat::Postcard, 1), + ("replaced", WireFormat::Json, 1), + ]; + let actual: Vec<(&str, WireFormat, usize)> = formats + .iter() + .map(|(topic, format, n)| (topic.as_str(), *format, *n)) + .collect(); + assert_eq!(actual, expected); + }); + } } diff --git a/aimdb-data-contracts/src/linkable.rs b/aimdb-data-contracts/src/linkable.rs index a7e4e450..13366854 100644 --- a/aimdb-data-contracts/src/linkable.rs +++ b/aimdb-data-contracts/src/linkable.rs @@ -7,10 +7,9 @@ //! escape hatch for per-link options (QoS, topic providers/resolvers), while //! [`LinkCodecRegistrarExt`] selects a different codec for an individual link. -use aimdb_core::connector::SerializeError; use aimdb_core::typed_api::RecordRegistrar; -use crate::{LinkCodec, LinkCodecBuilderExt, Linkable}; +use crate::{link_codecs, LinkCodec, LinkCodecBuilderExt, Linkable}; /// Adds `.linked_from(url)` and `.linked_to(url)` to [`RecordRegistrar`] for /// [`Linkable`] types. @@ -36,21 +35,14 @@ where { fn linked_from(&mut self, url: &str) -> &mut RecordRegistrar<'a, T> { self.link_from(url) - .with_deserializer(|_ctx, bytes| T::from_bytes(bytes)) + .with_link_codec(link_codecs::Default) .finish() } fn linked_to(&mut self, url: &str) -> &mut RecordRegistrar<'a, T> { - let builder = self.link_to(url).with_serializer(|_ctx, value: &T| { - value.to_bytes().map_err(|_| SerializeError::InvalidData) - }); - - match T::ENCODE_BUFFER_CAPACITY { - Some(capacity) => builder - .with_serializer_into(capacity, |_ctx, value: &T, out| value.encode_into(out)) - .finish(), - None => builder.finish(), - } + self.link_to(url) + .with_link_codec(link_codecs::Default) + .finish() } } diff --git a/aimdb-data-contracts/tests/linkable_derive.rs b/aimdb-data-contracts/tests/linkable_derive.rs index 2733e293..c195c538 100644 --- a/aimdb-data-contracts/tests/linkable_derive.rs +++ b/aimdb-data-contracts/tests/linkable_derive.rs @@ -7,7 +7,7 @@ #![cfg(feature = "linkable-json")] -use aimdb_data_contracts::{Linkable, SchemaType}; +use aimdb_data_contracts::{link_codecs, LinkCodec, Linkable, SchemaType, WireFormat}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Linkable)] @@ -47,3 +47,12 @@ fn rejects_invalid_json() { let invalid = b"not valid json"; assert!(DerivedTemperature::from_bytes(invalid).is_err()); } + +#[test] +fn declares_json_wire_format() { + assert_eq!(DerivedTemperature::WIRE_FORMAT, WireFormat::Json); + assert_eq!( + >::WIRE_FORMAT, + WireFormat::Json + ); +} diff --git a/aimdb-derive/src/lib.rs b/aimdb-derive/src/lib.rs index bd9f6f4a..52d04909 100644 --- a/aimdb-derive/src/lib.rs +++ b/aimdb-derive/src/lib.rs @@ -73,6 +73,9 @@ pub fn derive_linkable(input: TokenStream) -> TokenStream { let expanded = quote! { impl ::aimdb_data_contracts::Linkable for #name { + const WIRE_FORMAT: ::aimdb_data_contracts::WireFormat = + ::aimdb_data_contracts::WireFormat::Json; + fn from_bytes( data: &[u8], ) -> Result { From e05a1e2a1f406a795d7c0942f80d3e002e3d190c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 27 Sep 2026 23:26:18 +0000 Subject: [PATCH 05/27] feat: add aimdb-cdr crate for no_std serde encoding of OMG CDR (XCDR1) - Introduced `Cargo.toml` and `README.md` for the new crate. - Implemented a deserializer for XCDR1 format in `src/de.rs`. - Created a custom error type in `src/error.rs` for handling encoding/decoding errors. - Developed a serializer for XCDR1 format in `src/ser.rs`. - Added main library functionality in `src/lib.rs` for encoding and decoding values. - Implemented tests for various encoding and decoding scenarios in `tests/`. - Ensured compatibility with hiroz-cdr for little-endian output. - Added robustness tests to handle random and malformed input gracefully. --- Cargo.lock | 59 +++++++ Cargo.toml | 1 + Makefile | 21 ++- aimdb-cdr/Cargo.toml | 25 +++ aimdb-cdr/README.md | 24 +++ aimdb-cdr/src/de.rs | 272 +++++++++++++++++++++++++++++ aimdb-cdr/src/error.rs | 79 +++++++++ aimdb-cdr/src/lib.rs | 68 ++++++++ aimdb-cdr/src/ser.rs | 292 ++++++++++++++++++++++++++++++++ aimdb-cdr/tests/common/mod.rs | 113 ++++++++++++ aimdb-cdr/tests/golden.rs | 121 +++++++++++++ aimdb-cdr/tests/hiroz_oracle.rs | 51 ++++++ aimdb-cdr/tests/no_alloc.rs | 32 ++++ aimdb-cdr/tests/robustness.rs | 61 +++++++ 14 files changed, 1216 insertions(+), 3 deletions(-) create mode 100644 aimdb-cdr/Cargo.toml create mode 100644 aimdb-cdr/README.md create mode 100644 aimdb-cdr/src/de.rs create mode 100644 aimdb-cdr/src/error.rs create mode 100644 aimdb-cdr/src/lib.rs create mode 100644 aimdb-cdr/src/ser.rs create mode 100644 aimdb-cdr/tests/common/mod.rs create mode 100644 aimdb-cdr/tests/golden.rs create mode 100644 aimdb-cdr/tests/hiroz_oracle.rs create mode 100644 aimdb-cdr/tests/no_alloc.rs create mode 100644 aimdb-cdr/tests/robustness.rs diff --git a/Cargo.lock b/Cargo.lock index acbe12ba..68b43d1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "aimdb-cdr" +version = "0.1.0" +dependencies = [ + "hiroz-cdr", + "serde", +] + [[package]] name = "aimdb-cli" version = "0.7.0" @@ -737,6 +745,26 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -2337,6 +2365,19 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hiroz-cdr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69398421bf7e64822904d8a4744186af65c56fce141756965c86ad27565c2e43" +dependencies = [ + "bytemuck", + "byteorder", + "serde", + "thiserror 2.0.17", + "zenoh-buffers", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -5716,6 +5757,24 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zenoh-buffers" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555a8169c9888fc5571f1b38d2843a63db786621e153abad1b075c40b20e83b4" +dependencies = [ + "zenoh-collections", +] + +[[package]] +name = "zenoh-collections" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c05abd8dcfd2239a8dfb5b79bd4ed85094c6bb6d6b991d3529bff364cc48e29" +dependencies = [ + "ahash", +] + [[package]] name = "zerocopy" version = "0.8.27" diff --git a/Cargo.toml b/Cargo.toml index 34ffbd3f..9736c513 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "aimdb-derive", + "aimdb-cdr", "aimdb-data-contracts", "aimdb-core", "aimdb-client", diff --git a/Makefile b/Makefile index 8e01c98e..fcb93038 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,9 @@ help: ## Core commands build: @printf "$(GREEN)Building AimDB (all valid combinations)...$(NC)\n" + @printf "$(YELLOW) → Building aimdb-cdr (alloc, no_std)$(NC)\n" + cargo build --package aimdb-cdr + cargo build --package aimdb-cdr --no-default-features @printf "$(YELLOW) → Building aimdb-data-contracts (std)$(NC)\n" cargo build --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json,linkable-postcard" @printf "$(YELLOW) → Building aimdb-data-contracts (no_std)$(NC)\n" @@ -148,6 +151,9 @@ build: test: @printf "$(GREEN)Running all tests (valid combinations)...$(NC)\n" + @printf "$(YELLOW) → Testing aimdb-cdr (alloc, no_std)$(NC)\n" + cargo test --package aimdb-cdr + cargo test --package aimdb-cdr --no-default-features @printf "$(YELLOW) → Testing aimdb-data-contracts (std)$(NC)\n" cargo test --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json,linkable-postcard" @printf "$(YELLOW) → Testing aimdb-data-contracts (no_std + alloc + format-neutral linkable)$(NC)\n" @@ -259,7 +265,7 @@ test: fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" - @for pkg in aimdb-derive aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-tcp-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest hello-single-latest-async hello-spmc-ring hello-spmc-ring-async aimdb-bench; do \ + @for pkg in aimdb-derive aimdb-cdr aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-tcp-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest hello-single-latest-async hello-spmc-ring hello-spmc-ring-async aimdb-bench; do \ printf "$(YELLOW) → Formatting $$pkg$(NC)\n"; \ cargo fmt -p $$pkg 2>/dev/null || true; \ done @@ -268,7 +274,7 @@ fmt: fmt-check: @printf "$(GREEN)Checking code formatting (workspace members only)...$(NC)\n" @FAILED=0; \ - for pkg in aimdb-derive aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-tcp-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest hello-single-latest-async hello-spmc-ring hello-spmc-ring-async aimdb-bench; do \ + for pkg in aimdb-derive aimdb-cdr aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-tcp-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest hello-single-latest-async hello-spmc-ring hello-spmc-ring-async aimdb-bench; do \ printf "$(YELLOW) → Checking $$pkg$(NC)\n"; \ if ! cargo fmt -p $$pkg -- --check 2>&1; then \ printf "$(RED)❌ Formatting check failed for $$pkg$(NC)\n"; \ @@ -285,6 +291,9 @@ clippy: @printf "$(GREEN)Running clippy (all valid combinations)...$(NC)\n" @printf "$(YELLOW) → Clippy on aimdb-derive$(NC)\n" cargo clippy --package aimdb-derive --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on aimdb-cdr (alloc, no_std)$(NC)\n" + cargo clippy --package aimdb-cdr --all-targets -- -D warnings + cargo clippy --package aimdb-cdr --no-default-features --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-data-contracts (std)$(NC)\n" cargo clippy --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json,linkable-postcard" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-data-contracts (no_std + alloc)$(NC)\n" @@ -440,9 +449,11 @@ doc: cargo doc --package aimdb-tcp-connector --no-default-features --features "std" --no-deps cargo doc --package aimdb-client --features "transport-serial,transport-tcp" --no-deps cargo doc --package aimdb-derive --no-deps + cargo doc --package aimdb-cdr --no-deps @cp -r target/doc/* target/doc-final/cloud/ @printf "$(YELLOW) → Building embedded documentation$(NC)\n" cargo doc --package aimdb-core --no-default-features --features alloc --no-deps + cargo doc --package aimdb-cdr --no-default-features --no-deps cargo doc --package aimdb-embassy-adapter --features "embassy-runtime,net" --no-deps cargo doc --package aimdb-mqtt-connector --no-default-features --features "embedded" --no-deps cargo doc --package aimdb-mqtt-connector --no-default-features --features "embedded-tls" --no-deps @@ -477,6 +488,9 @@ test-wasm: test-embedded: @printf "$(BLUE)Testing embedded/MCU cross-compilation compatibility...$(NC)\n" + @printf "$(YELLOW) → Checking aimdb-cdr (alloc, no_std) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-cdr --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) + cargo check --package aimdb-cdr --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc + format-neutral linkable) on thumbv7em-none-eabihf target$(NC)\n" @@ -665,6 +679,7 @@ publish-check: PUBLISH_ORDER := \ aimdb-codegen \ aimdb-derive \ + aimdb-cdr \ aimdb-core \ aimdb-data-contracts \ aimdb-embassy-adapter \ @@ -803,7 +818,7 @@ check-no-sim: # Applications may, so `tools/`, `examples/` and codegen's generated `main` are # not scanned. The one exception: the fork detector owns the runtime thread it # protects. A positive control keeps the guard from failing open. -GLOBALS_SCANNED := aimdb-core aimdb-data-contracts aimdb-derive aimdb-client \ +GLOBALS_SCANNED := aimdb-core aimdb-cdr aimdb-data-contracts aimdb-derive aimdb-client \ aimdb-tokio-adapter aimdb-embassy-adapter aimdb-wasm-adapter aimdb-sync \ aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector \ aimdb-knx-connector aimdb-websocket-connector aimdb-uds-connector \ diff --git a/aimdb-cdr/Cargo.toml b/aimdb-cdr/Cargo.toml new file mode 100644 index 00000000..ba682107 --- /dev/null +++ b/aimdb-cdr/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "aimdb-cdr" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "no_std serde encoding for OMG CDR (XCDR1), the ROS 2 wire format" +keywords = ["cdr", "ros2", "serde", "no-std", "aimdb"] +categories = ["encoding", "no-std", "embedded"] + +[features] +default = ["alloc"] +# `to_vec`, owned `String`/`Vec` decoding, and error messages from serde impls. +alloc = ["serde/alloc"] + +[dependencies] +serde = { version = "1.0", default-features = false } + +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } +# Reference encoder: byte-for-byte oracle for the little-endian output. +hiroz-cdr = "0.2" diff --git a/aimdb-cdr/README.md b/aimdb-cdr/README.md new file mode 100644 index 00000000..1a24bccf --- /dev/null +++ b/aimdb-cdr/README.md @@ -0,0 +1,24 @@ +# aimdb-cdr + +`no_std` serde encoding for OMG CDR (XCDR1) with the 4-byte encapsulation +header — the payload format ROS 2 puts on the wire. + +- Encodes little-endian; decodes little- and big-endian. +- `to_slice` writes into caller storage without allocating. `to_vec` and owned + `String`/`Vec` fields need the default `alloc` feature. +- Strings carry a `u32` length including the NUL; sequences a `u32` count; + fixed arrays and structs no prefix. Alignment is measured from the end of the + header. + +```rust +#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] +struct Temperature { temperature: f64, variance: f64 } + +let mut buf = [0u8; 20]; +let len = aimdb_cdr::to_slice(&Temperature { temperature: 21.5, variance: 0.0 }, &mut buf).unwrap(); +let back: Temperature = aimdb_cdr::from_bytes(&buf[..len]).unwrap(); +assert_eq!(back.temperature, 21.5); +``` + +`Option`, enums, maps and `char` have no XCDR1 mapping in ROS 2 and are +rejected with `Error::Unsupported`. diff --git a/aimdb-cdr/src/de.rs b/aimdb-cdr/src/de.rs new file mode 100644 index 00000000..a55ac97b --- /dev/null +++ b/aimdb-cdr/src/de.rs @@ -0,0 +1,272 @@ +use serde::de::{self, DeserializeSeed, Visitor}; + +use crate::{Error, Result}; + +/// XCDR1 body deserializer. `pos` counts from the end of the header. +pub(crate) struct Deserializer<'de> { + input: &'de [u8], + pos: usize, + big_endian: bool, +} + +macro_rules! read_primitive { + ($name:ident, $ty:ty) => { + fn $name(&mut self) -> Result<$ty> { + let bytes = self.primitive::<{ core::mem::size_of::<$ty>() }>()?; + Ok(if self.big_endian { + <$ty>::from_be_bytes(bytes) + } else { + <$ty>::from_le_bytes(bytes) + }) + } + }; +} + +impl<'de> Deserializer<'de> { + pub(crate) fn new(input: &'de [u8], big_endian: bool) -> Self { + Self { + input, + pos: 0, + big_endian, + } + } + + fn remaining(&self) -> usize { + self.input.len() - self.pos + } + + fn take(&mut self, len: usize) -> Result<&'de [u8]> { + let end = self.pos.checked_add(len).ok_or(Error::UnexpectedEof)?; + let bytes = self.input.get(self.pos..end).ok_or(Error::UnexpectedEof)?; + self.pos = end; + Ok(bytes) + } + + fn align(&mut self, alignment: usize) -> Result<()> { + let padding = (alignment - self.pos % alignment) % alignment; + self.take(padding).map(drop) + } + + fn primitive(&mut self) -> Result<[u8; N]> { + self.align(N)?; + let mut bytes = [0; N]; + bytes.copy_from_slice(self.take(N)?); + Ok(bytes) + } + + read_primitive!(read_u16, u16); + read_primitive!(read_u32, u32); + read_primitive!(read_u64, u64); + read_primitive!(read_i16, i16); + read_primitive!(read_i32, i32); + read_primitive!(read_i64, i64); + read_primitive!(read_f32, f32); + read_primitive!(read_f64, f64); + + fn read_u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + /// A `u32` length prefix. Every ROS element takes at least one byte, so a + /// count beyond the remaining input is rejected before anything allocates. + fn read_length(&mut self) -> Result { + let len = usize::try_from(self.read_u32()?).map_err(|_| Error::UnexpectedEof)?; + if len > self.remaining() { + return Err(Error::UnexpectedEof); + } + Ok(len) + } + + fn read_str(&mut self) -> Result<&'de str> { + let len = self.read_length()?; + if len == 0 { + return Ok(""); + } + let (text, nul) = self.take(len)?.split_at(len - 1); + if nul != [0] { + return Err(Error::MissingNul); + } + core::str::from_utf8(text).map_err(|_| Error::InvalidUtf8) + } +} + +impl<'de> de::Deserializer<'de> for &mut Deserializer<'de> { + type Error = Error; + + fn deserialize_any>(self, _visitor: V) -> Result { + Err(Error::Unsupported("self-describing deserialize")) + } + + fn deserialize_bool>(self, visitor: V) -> Result { + match self.read_u8()? { + 0 => visitor.visit_bool(false), + 1 => visitor.visit_bool(true), + other => Err(Error::InvalidBool(other)), + } + } + + fn deserialize_i8>(self, visitor: V) -> Result { + visitor.visit_i8(i8::from_le_bytes([self.read_u8()?])) + } + + fn deserialize_i16>(self, visitor: V) -> Result { + visitor.visit_i16(self.read_i16()?) + } + + fn deserialize_i32>(self, visitor: V) -> Result { + visitor.visit_i32(self.read_i32()?) + } + + fn deserialize_i64>(self, visitor: V) -> Result { + visitor.visit_i64(self.read_i64()?) + } + + fn deserialize_u8>(self, visitor: V) -> Result { + visitor.visit_u8(self.read_u8()?) + } + + fn deserialize_u16>(self, visitor: V) -> Result { + visitor.visit_u16(self.read_u16()?) + } + + fn deserialize_u32>(self, visitor: V) -> Result { + visitor.visit_u32(self.read_u32()?) + } + + fn deserialize_u64>(self, visitor: V) -> Result { + visitor.visit_u64(self.read_u64()?) + } + + fn deserialize_f32>(self, visitor: V) -> Result { + visitor.visit_f32(self.read_f32()?) + } + + fn deserialize_f64>(self, visitor: V) -> Result { + visitor.visit_f64(self.read_f64()?) + } + + fn deserialize_char>(self, _visitor: V) -> Result { + Err(Error::Unsupported("char")) + } + + fn deserialize_str>(self, visitor: V) -> Result { + visitor.visit_borrowed_str(self.read_str()?) + } + + fn deserialize_string>(self, visitor: V) -> Result { + self.deserialize_str(visitor) + } + + fn deserialize_bytes>(self, visitor: V) -> Result { + let len = self.read_length()?; + visitor.visit_borrowed_bytes(self.take(len)?) + } + + fn deserialize_byte_buf>(self, visitor: V) -> Result { + self.deserialize_bytes(visitor) + } + + fn deserialize_option>(self, _visitor: V) -> Result { + Err(Error::Unsupported("Option")) + } + + fn deserialize_unit>(self, visitor: V) -> Result { + visitor.visit_unit() + } + + fn deserialize_unit_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + visitor.visit_unit() + } + + fn deserialize_newtype_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + visitor.visit_newtype_struct(self) + } + + fn deserialize_seq>(self, visitor: V) -> Result { + let len = self.read_length()?; + visitor.visit_seq(Elements { + de: self, + left: len, + }) + } + + fn deserialize_tuple>(self, len: usize, visitor: V) -> Result { + visitor.visit_seq(Elements { + de: self, + left: len, + }) + } + + fn deserialize_tuple_struct>( + self, + _name: &'static str, + len: usize, + visitor: V, + ) -> Result { + self.deserialize_tuple(len, visitor) + } + + fn deserialize_map>(self, _visitor: V) -> Result { + Err(Error::Unsupported("map")) + } + + fn deserialize_struct>( + self, + _name: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_tuple(fields.len(), visitor) + } + + fn deserialize_enum>( + self, + _name: &'static str, + _variants: &'static [&'static str], + _visitor: V, + ) -> Result { + Err(Error::Unsupported("enum")) + } + + fn deserialize_identifier>(self, _visitor: V) -> Result { + Err(Error::Unsupported("identifier")) + } + + fn deserialize_ignored_any>(self, _visitor: V) -> Result { + Err(Error::Unsupported("ignored field")) + } + + fn is_human_readable(&self) -> bool { + false + } +} + +/// Fixed-count access for sequences, arrays and struct fields. +struct Elements<'a, 'de> { + de: &'a mut Deserializer<'de>, + left: usize, +} + +impl<'de> de::SeqAccess<'de> for Elements<'_, 'de> { + type Error = Error; + + fn next_element_seed>(&mut self, seed: T) -> Result> { + if self.left == 0 { + return Ok(None); + } + self.left -= 1; + seed.deserialize(&mut *self.de).map(Some) + } + + fn size_hint(&self) -> Option { + Some(self.left.min(self.de.remaining())) + } +} diff --git a/aimdb-cdr/src/error.rs b/aimdb-cdr/src/error.rs new file mode 100644 index 00000000..906e9027 --- /dev/null +++ b/aimdb-cdr/src/error.rs @@ -0,0 +1,79 @@ +use core::fmt; + +/// Result alias for this crate. +pub type Result = core::result::Result; + +/// Encode or decode failure. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// The output slice is too small. + BufferTooSmall, + /// The input ended early, or a length prefix exceeds the input. + UnexpectedEof, + /// The representation identifier is not `CDR_LE` or `CDR_BE`. + InvalidHeader([u8; 2]), + /// A `bool` byte other than 0 or 1. + InvalidBool(u8), + /// A string is not UTF-8. + InvalidUtf8, + /// A string is not NUL-terminated. + MissingNul, + /// A length does not fit the `u32` prefix. + LengthOverflow, + /// A sequence was serialized without a known length. + UnknownLength, + /// A serde construct with no CDR mapping. + Unsupported(&'static str), + /// A serde impl reported an error (message dropped without `alloc`). + Custom, + /// A serde impl reported an error. + #[cfg(feature = "alloc")] + Message(alloc::string::String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BufferTooSmall => f.write_str("output buffer too small"), + Self::UnexpectedEof => f.write_str("unexpected end of input"), + Self::InvalidHeader(id) => write!(f, "unsupported CDR representation {id:02x?}"), + Self::InvalidBool(b) => write!(f, "invalid bool byte {b}"), + Self::InvalidUtf8 => f.write_str("string is not UTF-8"), + Self::MissingNul => f.write_str("string is not NUL-terminated"), + Self::LengthOverflow => f.write_str("length exceeds u32"), + Self::UnknownLength => f.write_str("sequence length unknown"), + Self::Unsupported(what) => write!(f, "{what} has no CDR mapping"), + Self::Custom => f.write_str("serde error"), + #[cfg(feature = "alloc")] + Self::Message(msg) => f.write_str(msg), + } + } +} + +impl core::error::Error for Error {} + +impl Error { + fn custom(_msg: T) -> Self { + #[cfg(feature = "alloc")] + { + Self::Message(alloc::string::ToString::to_string(&_msg)) + } + #[cfg(not(feature = "alloc"))] + { + Self::Custom + } + } +} + +impl serde::ser::Error for Error { + fn custom(msg: T) -> Self { + Error::custom(msg) + } +} + +impl serde::de::Error for Error { + fn custom(msg: T) -> Self { + Error::custom(msg) + } +} diff --git a/aimdb-cdr/src/lib.rs b/aimdb-cdr/src/lib.rs new file mode 100644 index 00000000..257efd5f --- /dev/null +++ b/aimdb-cdr/src/lib.rs @@ -0,0 +1,68 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr( + not(test), + deny(clippy::unwrap_used, clippy::expect_used, clippy::panic) +)] +#![no_std] + +#[cfg(feature = "alloc")] +extern crate alloc; + +mod de; +mod error; +mod ser; + +pub use error::{Error, Result}; + +/// Encapsulation header for little-endian XCDR1 (`CDR_LE`), the only one this +/// crate writes. +pub const HEADER_LE: [u8; 4] = [0x00, 0x01, 0x00, 0x00]; + +const HEADER_LEN: usize = HEADER_LE.len(); + +/// Encodes `value` with its header into `out`, returning the bytes written. +/// +/// Fails with [`Error::BufferTooSmall`] if `out` cannot hold it; `out` is then +/// partially written. +pub fn to_slice(value: &T, out: &mut [u8]) -> Result +where + T: serde::Serialize + ?Sized, +{ + let (header, body) = out + .split_at_mut_checked(HEADER_LEN) + .ok_or(Error::BufferTooSmall)?; + header.copy_from_slice(&HEADER_LE); + let mut serializer = ser::Serializer::new(ser::SliceWriter::new(body)); + value.serialize(&mut serializer)?; + Ok(HEADER_LEN + serializer.position()) +} + +/// Encodes `value` with its header into a new `Vec`. +#[cfg(feature = "alloc")] +pub fn to_vec(value: &T) -> Result> +where + T: serde::Serialize + ?Sized, +{ + let mut out = alloc::vec::Vec::from(HEADER_LE); + value.serialize(&mut ser::Serializer::new(&mut out))?; + Ok(out) +} + +/// Decodes a value from an encapsulated payload. +/// +/// The byte order comes from the header (`CDR_LE` or `CDR_BE`); any other +/// representation is [`Error::InvalidHeader`]. Trailing bytes are ignored. +pub fn from_bytes<'de, T>(bytes: &'de [u8]) -> Result +where + T: serde::Deserialize<'de>, +{ + let (header, body) = bytes + .split_at_checked(HEADER_LEN) + .ok_or(Error::UnexpectedEof)?; + let big_endian = match [header[0], header[1]] { + [0x00, 0x01] => false, + [0x00, 0x00] => true, + other => return Err(Error::InvalidHeader(other)), + }; + T::deserialize(&mut de::Deserializer::new(body, big_endian)) +} diff --git a/aimdb-cdr/src/ser.rs b/aimdb-cdr/src/ser.rs new file mode 100644 index 00000000..56f57851 --- /dev/null +++ b/aimdb-cdr/src/ser.rs @@ -0,0 +1,292 @@ +use serde::ser::{self, Serialize}; + +use crate::{Error, Result}; + +/// Byte sink behind the serializer. +pub(crate) trait Write { + fn write_all(&mut self, bytes: &[u8]) -> Result<()>; +} + +/// Writes into a fixed slice; overflow is [`Error::BufferTooSmall`]. +pub(crate) struct SliceWriter<'a> { + buf: &'a mut [u8], + len: usize, +} + +impl<'a> SliceWriter<'a> { + pub(crate) fn new(buf: &'a mut [u8]) -> Self { + Self { buf, len: 0 } + } +} + +impl Write for SliceWriter<'_> { + fn write_all(&mut self, bytes: &[u8]) -> Result<()> { + let end = self.len + bytes.len(); + self.buf + .get_mut(self.len..end) + .ok_or(Error::BufferTooSmall)? + .copy_from_slice(bytes); + self.len = end; + Ok(()) + } +} + +#[cfg(feature = "alloc")] +impl Write for &mut alloc::vec::Vec { + fn write_all(&mut self, bytes: &[u8]) -> Result<()> { + self.extend_from_slice(bytes); + Ok(()) + } +} + +/// Little-endian XCDR1 body serializer. `pos` counts from the end of the +/// header, which is where CDR alignment is measured from. +pub(crate) struct Serializer { + out: W, + pos: usize, +} + +impl Serializer { + pub(crate) fn new(out: W) -> Self { + Self { out, pos: 0 } + } + + pub(crate) fn position(&self) -> usize { + self.pos + } + + fn write(&mut self, bytes: &[u8]) -> Result<()> { + self.out.write_all(bytes)?; + self.pos += bytes.len(); + Ok(()) + } + + fn align(&mut self, alignment: usize) -> Result<()> { + let padding = (alignment - self.pos % alignment) % alignment; + self.write(&[0; 8][..padding]) + } + + fn primitive(&mut self, bytes: [u8; N]) -> Result<()> { + self.align(N)?; + self.write(&bytes) + } + + fn length(&mut self, len: usize) -> Result<()> { + let len = u32::try_from(len).map_err(|_| Error::LengthOverflow)?; + self.primitive(len.to_le_bytes()) + } +} + +impl ser::Serializer for &mut Serializer { + type Ok = (); + type Error = Error; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = ser::Impossible<(), Error>; + type SerializeMap = ser::Impossible<(), Error>; + type SerializeStruct = Self; + type SerializeStructVariant = ser::Impossible<(), Error>; + + fn serialize_bool(self, v: bool) -> Result<()> { + self.write(&[u8::from(v)]) + } + + fn serialize_i8(self, v: i8) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_i16(self, v: i16) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_i32(self, v: i32) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_i64(self, v: i64) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_u8(self, v: u8) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_u16(self, v: u16) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_u32(self, v: u32) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_u64(self, v: u64) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_f32(self, v: f32) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_f64(self, v: f64) -> Result<()> { + self.primitive(v.to_le_bytes()) + } + + fn serialize_char(self, _v: char) -> Result<()> { + Err(Error::Unsupported("char")) + } + + fn serialize_str(self, v: &str) -> Result<()> { + self.length(v.len() + 1)?; + self.write(v.as_bytes())?; + self.write(&[0]) + } + + fn serialize_bytes(self, v: &[u8]) -> Result<()> { + self.length(v.len())?; + self.write(v) + } + + fn serialize_none(self) -> Result<()> { + Err(Error::Unsupported("Option")) + } + + fn serialize_some(self, _value: &T) -> Result<()> { + Err(Error::Unsupported("Option")) + } + + fn serialize_unit(self) -> Result<()> { + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<()> { + Ok(()) + } + + fn serialize_unit_variant(self, _: &'static str, _: u32, _: &'static str) -> Result<()> { + Err(Error::Unsupported("enum")) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result<()> { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: &T, + ) -> Result<()> { + Err(Error::Unsupported("enum")) + } + + fn serialize_seq(self, len: Option) -> Result { + self.length(len.ok_or(Error::UnknownLength)?)?; + Ok(self) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + Err(Error::Unsupported("enum")) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(Error::Unsupported("map")) + } + + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result { + Ok(self) + } + + fn serialize_struct_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + Err(Error::Unsupported("enum")) + } + + fn collect_str(self, _value: &T) -> Result<()> { + Err(Error::Unsupported("Display value")) + } + + fn is_human_readable(&self) -> bool { + false + } +} + +impl ser::SerializeSeq for &mut Serializer { + type Ok = (); + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<()> { + value.serialize(&mut **self) + } + + fn end(self) -> Result<()> { + Ok(()) + } +} + +impl ser::SerializeTuple for &mut Serializer { + type Ok = (); + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<()> { + value.serialize(&mut **self) + } + + fn end(self) -> Result<()> { + Ok(()) + } +} + +impl ser::SerializeTupleStruct for &mut Serializer { + type Ok = (); + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<()> { + value.serialize(&mut **self) + } + + fn end(self) -> Result<()> { + Ok(()) + } +} + +impl ser::SerializeStruct for &mut Serializer { + type Ok = (); + type Error = Error; + + fn serialize_field( + &mut self, + _key: &'static str, + value: &T, + ) -> Result<()> { + value.serialize(&mut **self) + } + + fn end(self) -> Result<()> { + Ok(()) + } +} diff --git a/aimdb-cdr/tests/common/mod.rs b/aimdb-cdr/tests/common/mod.rs new file mode 100644 index 00000000..c0864667 --- /dev/null +++ b/aimdb-cdr/tests/common/mod.rs @@ -0,0 +1,113 @@ +//! Shared fixtures. Golden bytes are laid out by hand from the XCDR1 rules. + +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Mixed { + pub a: u8, + pub b: u32, + pub c: u16, + pub d: f64, + pub s: String, + pub v: Vec, + pub arr: [u8; 3], + pub flag: bool, +} + +pub fn mixed() -> Mixed { + Mixed { + a: 0x11, + b: 0x2233_4455, + c: 0x6677, + d: 1.0, + s: "hi".into(), + v: vec![1, 2], + arr: [7, 8, 9], + flag: true, + } +} + +#[rustfmt::skip] +pub const MIXED_LE: [u8; 48] = [ + 0x00, 0x01, 0x00, 0x00, // CDR_LE header + 0x11, 0, 0, 0, // a, pad to 4 + 0x55, 0x44, 0x33, 0x22, // b + 0x77, 0x66, 0, 0, 0, 0, 0, 0, // c, pad to 8 + 0, 0, 0, 0, 0, 0, 0xf0, 0x3f, // d = 1.0 + 3, 0, 0, 0, b'h', b'i', 0, 0, // s: len incl. NUL, pad + 2, 0, 0, 0, 1, 0, 2, 0, // v: count, elements + 7, 8, 9, // arr: no count + 1, // flag +]; + +#[rustfmt::skip] +pub const MIXED_BE: [u8; 48] = [ + 0x00, 0x00, 0x00, 0x00, // CDR_BE header + 0x11, 0, 0, 0, + 0x22, 0x33, 0x44, 0x55, + 0x66, 0x77, 0, 0, 0, 0, 0, 0, + 0x3f, 0xf0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 3, b'h', b'i', 0, 0, + 0, 0, 0, 2, 0, 1, 0, 2, + 7, 8, 9, + 1, +]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Time { + pub sec: i32, + pub nanosec: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Header { + pub stamp: Time, + pub frame_id: String, +} + +/// `sensor_msgs/msg/Temperature`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Temperature { + pub header: Header, + pub temperature: f64, + pub variance: f64, +} + +pub fn temperature() -> Temperature { + Temperature { + header: Header { + stamp: Time { sec: 1, nanosec: 2 }, + frame_id: "a".into(), + }, + temperature: 0.5, + variance: 0.0, + } +} + +#[rustfmt::skip] +pub const TEMPERATURE_LE: [u8; 36] = [ + 0x00, 0x01, 0x00, 0x00, + 1, 0, 0, 0, // stamp.sec + 2, 0, 0, 0, // stamp.nanosec + 2, 0, 0, 0, b'a', 0, 0, 0, // frame_id, pad to 8 + 0, 0, 0, 0, 0, 0, 0xe0, 0x3f, // temperature = 0.5 + 0, 0, 0, 0, 0, 0, 0, 0, // variance +]; + +/// Tiny xorshift PRNG so the randomized tests need no extra dependency. +pub struct Rng(pub u64); + +impl Rng { + pub fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + pub fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} diff --git a/aimdb-cdr/tests/golden.rs b/aimdb-cdr/tests/golden.rs new file mode 100644 index 00000000..05a376f4 --- /dev/null +++ b/aimdb-cdr/tests/golden.rs @@ -0,0 +1,121 @@ +#![cfg(feature = "alloc")] + +mod common; + +use aimdb_cdr::{from_bytes, to_slice, to_vec, Error}; +use common::*; +use serde::{Deserialize, Serialize}; + +#[test] +fn encodes_golden_little_endian() { + assert_eq!(to_vec(&mixed()).unwrap(), MIXED_LE); + assert_eq!(to_vec(&temperature()).unwrap(), TEMPERATURE_LE); +} + +#[test] +fn decodes_both_byte_orders() { + assert_eq!(from_bytes::(&MIXED_LE).unwrap(), mixed()); + assert_eq!(from_bytes::(&MIXED_BE).unwrap(), mixed()); + assert_eq!( + from_bytes::(&TEMPERATURE_LE).unwrap(), + temperature() + ); +} + +#[test] +fn to_slice_matches_to_vec_and_reports_small_buffers() { + let mut out = [0u8; 48]; + assert_eq!(to_slice(&mixed(), &mut out), Ok(48)); + assert_eq!(out, MIXED_LE); + + let mut short = [0u8; 47]; + assert_eq!(to_slice(&mixed(), &mut short), Err(Error::BufferTooSmall)); + assert_eq!( + to_slice(&mixed(), &mut [0u8; 3]), + Err(Error::BufferTooSmall) + ); +} + +#[test] +fn empty_string_and_sequence() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Empty { + s: String, + v: Vec, + } + let value = Empty { + s: String::new(), + v: Vec::new(), + }; + let bytes = to_vec(&value).unwrap(); + assert_eq!(bytes, [0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(from_bytes::(&bytes).unwrap(), value); +} + +#[test] +fn nested_sequences_of_structs() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Path { + poses: Vec