diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index 33bdedb49..c167bdfbc 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -23,17 +23,57 @@ Adj │ └── boards: HashMap │ ├── measurements: HashMap │ └── packets: HashMap -│ ├── explicit (Data, Order, Message — declared in packets.json) +│ ├── explicit (Data, Order, Message — declared in packets.json; +│ │ a "message"-type packet's own `name` must be one of +│ │ MessageLevel::ALL's four severities, resolved via +│ │ MessageLevel::from_name — UnknownMessageSeverity if not) │ └── synthetic (Protection — one per declared protection, │ derived from each measurement's own `protections`; │ never declared directly in packets.json) └── info (AdjInfo) ├── ports: HashMap ├── addresses: HashMap - ├── message_ids: HashMap + ├── message_ids: HashMap -- name -> id, straight from the ADJ + ├── message_levels: HashMap -- id -> severity, the reverse └── protection_types: HashMap ``` +`message_ids` is the ADJ's own direction: a name (e.g. `"fault"`) to its +id. Not every entry is one of `model::MessageLevel`'s four severities — +other reserved names (e.g. `add_state_order`) share this same map. + +`message_levels` is the direction decoding actually needs: a wire id +(all a decoder ever has) straight to its severity, with no name in +between. It's the reverse of `message_ids`, restricted to whichever of +`MessageLevel::ALL`'s four names `message_ids` declares — built once in +`adj::raw::types`'s `TryFrom for AdjInfo`, right after +`message_ids` itself, so it can never drift out of sync with it: + +```rust +let message_levels: HashMap = MessageLevel::ALL + .into_iter() + .filter_map(|level| message_ids.get(level.name()).map(|&id| (id, level))) + .collect(); +``` + +Which ADJ name corresponds to which `MessageLevel` is **not** decided +here — `level.name()`/`MessageLevel::from_name` (see `model/README.md` +§4.4) is the one place that association is spelled out, so `adj` +doesn't hardcode `"fault"`/`"warning"`/`"info"`/`"panic"` itself. + +**One layer up, `protocol::Index` is what actually turns +`message_levels` into something a decoder can look up a packet by.** +`Index::new(pod_data, message_levels)` takes `message_levels` as +`Option<&HashMap>` — `None` for callers with no +`AdjInfo` at all (most tests in this crate, which only build a bare +`PodData`) — and, for each entry, synthesizes a +`PacketDef { packet_type: PacketType::Message(level), board: BoardId(0), .. }` +into the very same `AdjId -> PacketDef` table every other packet lives +in. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own +docs) because a severity isn't owned by any one board — any board's +free-text log frame can reuse the same `"fault"` id. See +`protocol/README.md`'s `index.rs` section for the full picture. + --- ## Why It Is Structured This Way @@ -49,4 +89,4 @@ The `Adj` object enforces a clear architectural boundary between **vehicle topol The raw deserialization types and the loader logic live isolated inside `raw`[cite: 2]. This enables non-station tools (such as bus sniffers, test runners, or simulators) to parse and ingest ADJ directories without dragging along application-level UI or station state logic. ### 3. Unified Packet Map (`packets`) -Protections defined under measurements are materialized during loading as first-class `PacketKind::Protection(Measurement)` packets alongside the explicit data, order and message packets declared in `packets.json`. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. `packets.json` itself never declares a `"type": "protection"` entry directly — that shape only exists synthesized, one per protection a measurement declares, never as a free-standing declaration with its own arbitrary variable list. \ No newline at end of file +Protections defined under measurements are materialized during loading as first-class `PacketType::Protection(Measurement)` packets alongside the explicit data, order and message packets declared in `packets.json`. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. `packets.json` itself never declares a `"type": "protection"` entry directly — that shape only exists synthesized, one per protection a measurement declares, never as a free-standing declaration with its own arbitrary variable list. \ No newline at end of file diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs index 2f6d31764..53fcf1bed 100644 --- a/backend/crates/core/src/adj/error.rs +++ b/backend/crates/core/src/adj/error.rs @@ -85,6 +85,16 @@ pub enum LoadError { type_name: String, }, + /// A `"type": "message"` packet's `name` isn't one of + /// [`crate::model::MessageLevel`]'s four severities. + #[error("board {board:?}, packet {name:?}: not a known message severity")] + UnknownMessageSeverity { + /// The board declaring this message packet. + board: String, + /// The packet's name — the text that failed to match a severity. + name: String, + }, + /// A packet or order references a measurement alias that doesn't exist /// among that board's own measurements. #[error("board {board:?}, packet {name:?}: unknown measurement alias {alias:?}")] diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs index 62c17fa9c..57ddc175e 100644 --- a/backend/crates/core/src/adj/info.rs +++ b/backend/crates/core/src/adj/info.rs @@ -5,7 +5,7 @@ //! addresses, message-id conventions, and how to display a triggered //! protection. -use crate::model::{AdjId, Port}; +use crate::model::{AdjId, MessageLevel, Port}; use std::collections::HashMap; use std::net::IpAddr; @@ -34,7 +34,25 @@ pub struct AdjInfo { /// Known message ids, by name (e.g. `"fault"` -> `AdjId(2)`) — these /// live in the ADJ's own special reserved id range (`[0, 511]`, see /// [`AdjId`]'s own docs), not a separate numbering of their own. + /// This is the ADJ's own direction: a severity's *name* to its id. + /// Not every entry here is one of [`MessageLevel`]'s four severities + /// — other reserved names (e.g. `add_state_order`) share this same + /// map — see [`MessageLevel::name`] for which ones are. pub message_ids: HashMap, + /// The reverse of [`AdjInfo::message_ids`], and only for the four + /// entries that actually are a [`MessageLevel`]: a wire id straight + /// to its severity. + /// + /// Decoding a free-text log frame only ever has the raw id off the + /// wire, not its name — without this, every decoder would have to + /// search `message_ids` by value (or re-derive this same reverse + /// map itself) just to answer "which severity is id 2?". Built once + /// here, when the ADJ is loaded (see `adj::raw::types`'s + /// `TryFrom for AdjInfo`), + /// from `message_ids` and [`MessageLevel::ALL`]/[`MessageLevel::name`] + /// — never constructed by hand, and never out of sync with + /// `message_ids` since it's derived from it. + pub message_levels: HashMap, /// Declared protection types, by name (e.g. `"Range"`). pub protection_types: HashMap, } diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 6d6f4be3c..32216fc2b 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -4,7 +4,7 @@ use super::types::{RawBoard, RawGeneralInfo, RawMeasurement, RawPacket}; use crate::adj::error::LoadError; use crate::adj::{Adj, AdjInfo}; use crate::model::{ - AdjId, Board, BoardId, MacAddress, Measurement, PacketDef, PacketKind, PodData, + AdjId, Board, BoardId, MacAddress, Measurement, MessageLevel, PacketDef, PacketType, PodData, }; use serde::de::DeserializeOwned; use std::collections::HashMap; @@ -71,15 +71,18 @@ pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result, ) -> Result { - let kind = match raw.kind.as_str() { - "data" => PacketKind::Data(resolve_measurements( + let packet_type = match raw.packet_type.as_str() { + "data" => PacketType::Data(resolve_measurements( board, &raw.name, &raw.variables, measurements, )?), - "order" => PacketKind::Order(resolve_measurements( + "order" => PacketType::Order(resolve_measurements( board, &raw.name, &raw.variables, measurements, )?), - "message" => PacketKind::Message, + // The packet's own `name` must be one of `MessageLevel`'s four + // severities (`"fault"`/`"warning"`/`"info"`/`"panic"`) — that's + // the only way a `"message"`-type packet in `packets.json` says + // which severity it is; nothing else in its JSON shape does. + "message" => PacketType::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { + LoadError::UnknownMessageSeverity { + board: board.to_string(), + name: raw.name.clone(), + } + })?), other => { return Err(LoadError::UnknownPacketType { board: board.to_string(), @@ -154,7 +166,7 @@ fn build_packet_def( id: AdjId(raw.id), board: board_id, name: raw.name, - kind, + packet_type, }) } @@ -205,7 +217,7 @@ fn parse_mac(raw: &str) -> Option { mod tests { use super::*; use crate::adj::ProtectionTypeInfo; - use crate::model::{PacketKind, Port, ProtectionKind, Severity}; + use crate::model::{PacketType, Port, ProtectionKind, Severity}; use std::path::Path; use std::time::Duration; @@ -237,7 +249,7 @@ mod tests { fn build_packet_def_rejects_unknown_packet_type() { let raw = RawPacket { id: 1, - kind: "status".to_string(), + packet_type: "status".to_string(), name: "Status".to_string(), variables: vec![], }; @@ -251,7 +263,7 @@ mod tests { fn build_packet_def_rejects_unknown_alias() { let raw = RawPacket { id: 1, - kind: "data".to_string(), + packet_type: "data".to_string(), name: "Status".to_string(), variables: vec!["missing".to_string()], }; @@ -261,6 +273,34 @@ mod tests { )); } + #[test] + fn build_packet_def_resolves_a_message_packets_severity_from_its_name() { + let raw = RawPacket { + id: 2, + packet_type: "message".to_string(), + name: "fault".to_string(), + variables: vec![], + }; + let Ok(packet) = build_packet_def("VCU", BoardId(3), raw, &HashMap::new()) else { + unreachable!("\"fault\" is a known message severity"); + }; + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); + } + + #[test] + fn build_packet_def_rejects_a_message_whose_name_isnt_a_severity() { + let raw = RawPacket { + id: 2, + packet_type: "message".to_string(), + name: "not_a_severity".to_string(), + variables: vec![], + }; + assert!(matches!( + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), + Err(LoadError::UnknownMessageSeverity { .. }) + )); + } + #[test] fn loads_the_sample_adj_fixture() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adj/test/adj"); @@ -286,14 +326,14 @@ mod tests { let Some(order) = vcu.packets.get(&AdjId(600)) else { unreachable!("VCU should have order 600"); }; - let PacketKind::Order(_) = &order.kind else { + let PacketType::Order(_) = &order.packet_type else { unreachable!("order 600 should be an Order"); }; assert_eq!(order.board, BoardId(3)); let Some(data) = vcu.packets.get(&AdjId(625)) else { unreachable!("VCU should have data packet 625"); }; - let PacketKind::Data(_) = &data.kind else { + let PacketType::Data(_) = &data.packet_type else { unreachable!("data packet 625 should be Data"); }; @@ -303,7 +343,7 @@ mod tests { let Some(protected) = pcu .packets .values() - .flat_map(|p| p.kind.measurements()) + .flat_map(|p| p.packet_type.measurements()) .find(|m| !m.protections.is_empty()) else { unreachable!("PCU should have a measurement with a protection"); @@ -335,7 +375,7 @@ mod tests { let Some(protection_packet) = pcu.packets.get(&AdjId((1u16 << 13) | protected.id.0)) else { unreachable!("PCU should have a synthetic protection packet"); }; - let PacketKind::Protection(measurement) = &protection_packet.kind else { + let PacketType::Protection(measurement) = &protection_packet.packet_type else { unreachable!("protection_packet should be a Protection"); }; assert_eq!(measurement, protected); diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index 7b02ef51c..bac40de9f 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -8,7 +8,8 @@ use crate::adj::error::{LoadError, MeasurementError, ProtectionError}; use crate::adj::info::{AdjInfo, ProtectionTypeInfo}; use crate::model::{ - AdjId, Measurement, MeasurementKind, NumericKind, Port, Protection, ProtectionKind, Severity, + AdjId, Measurement, MeasurementKind, MessageLevel, NumericKind, Port, Protection, + ProtectionKind, Severity, }; use serde::Deserialize; use std::collections::HashMap; @@ -78,7 +79,7 @@ fn default_fault() -> bool { pub(crate) struct RawPacket { pub(crate) id: u16, #[serde(rename = "type")] - pub(crate) kind: String, + pub(crate) packet_type: String, pub(crate) name: String, #[serde(default)] pub(crate) variables: Vec, @@ -100,6 +101,21 @@ impl TryFrom for AdjInfo { addresses.insert(name, addr); } + let message_ids: HashMap = raw + .message_ids + .into_iter() + .map(|(name, id)| (name, AdjId(id))) + .collect(); + + // The reverse of `message_ids`, restricted to the four names + // that are actually a `MessageLevel` — see `AdjInfo::message_levels`'s + // own doc comment for why this is worth precomputing here rather + // than leaving every caller to search `message_ids` by value. + let message_levels: HashMap = MessageLevel::ALL + .into_iter() + .filter_map(|level| message_ids.get(level.name()).map(|&id| (id, level))) + .collect(); + Ok(AdjInfo { ports: raw .ports @@ -107,11 +123,8 @@ impl TryFrom for AdjInfo { .map(|(name, port)| (name, Port(port))) .collect(), addresses, - message_ids: raw - .message_ids - .into_iter() - .map(|(name, id)| (name, AdjId(id))) - .collect(), + message_ids, + message_levels, protection_types: raw .protection_types .into_iter() @@ -293,7 +306,7 @@ fn parse_duration(text: &str) -> Result { mod tests { use super::*; use crate::adj::raw::loader::load_board; - use crate::model::PacketKind; + use crate::model::PacketType; use std::net::IpAddr; fn raw_protection( @@ -488,7 +501,12 @@ mod tests { ports: HashMap::from([("TCP_SERVER".to_string(), 50500u16)]), addresses: HashMap::from([("backend".to_string(), "192.168.0.9".to_string())]), protection_types, - message_ids: HashMap::from([("fault".to_string(), 2u16)]), + message_ids: HashMap::from([ + ("fault".to_string(), 2u16), + // Not a `MessageLevel` — shares `message_ids` with the + // four severities, but shouldn't appear in `message_levels`. + ("add_state_order".to_string(), 5u16), + ]), }; let Ok(info) = AdjInfo::try_from(raw) else { @@ -498,7 +516,13 @@ mod tests { assert_eq!(info.ports["TCP_SERVER"], Port(50500)); assert_eq!(info.addresses["backend"], IpAddr::from([192, 168, 0, 9])); assert_eq!(info.message_ids["fault"], AdjId(2)); + assert_eq!(info.message_ids["add_state_order"], AdjId(5)); assert!(info.protection_types["Range"].is_range); + + // `message_levels` is the reverse of `message_ids`, but only for + // the one entry that's actually a `MessageLevel`. + assert_eq!(info.message_levels.len(), 1); + assert_eq!(info.message_levels[&AdjId(2)], MessageLevel::Fault); } #[test] @@ -599,7 +623,7 @@ mod tests { let protection_count = board .packets .values() - .filter(|p| matches!(p.kind, PacketKind::Protection(_))) + .filter(|p| matches!(p.packet_type, PacketType::Protection(_))) .count(); assert_eq!( protection_count, 2, diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs index 24ddd2554..fb8d888bf 100644 --- a/backend/crates/core/src/lib.rs +++ b/backend/crates/core/src/lib.rs @@ -13,3 +13,8 @@ pub mod model; /// [`model::PodData`], plus backend-session metadata not modeled as /// vehicle data. See [`adj::load_from_dir`]. pub mod adj; + +/// Translates bytes into typed packets and back, using the shapes +/// [`adj`] declares as the schema. See [`protocol::wire`], +/// [`protocol::tcp`], [`protocol::udp`]. +pub mod protocol; diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 6cfdda92d..75cdb319e 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -17,11 +17,12 @@ mod packet; mod protections; mod value; -pub use board::{Board, PacketDef, PacketKind, PodData}; +pub use board::{Board, PacketDef, PacketType, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, + Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, + PacketTypeTag, ProtectionPacket, ShortTimestamp, }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index 600ed0f68..20db8642d 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -6,8 +6,8 @@ This module is meant to store and organize all the different structs, enums and ## 1. `board.rs` All structs and enums needed for the Pod can be found defined in this file. -### 1.1 `PacketKind` -Enum used to define what kind of packet a `PacketDef` declares. Not every kind carries the same shape, so each variant holds whatever measurement data is actually meaningful for it, instead of every packet being forced through one shared field. +### 1.1 `PacketType` +Enum used to define what type of packet a `PacketDef` declares. Not every type carries the same shape, so each variant holds whatever measurement data is actually meaningful for it, instead of every packet being forced through one shared field. #### `Data(Vec)` Telemetry sent by a board: one value per measurement, decoded positionally in this order. @@ -15,10 +15,10 @@ Telemetry sent by a board: one value per measurement, decoded positionally in th The single measurement one of a board's declared protections watches. Always exactly one — never a list. #### `Order(Vec)` An order sent to a board. Many orders take no parameters at all, so this can be empty. -#### `Message` -A free-text log line. Never carries measurement data. +#### `Message(MessageLevel)` +A free-text log line of this severity. Never carries measurement data — the severity is the only thing that tells one `Message` `PacketDef` apart from another. Lives here, on `PacketType`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what type of packet is this" as a `Data` packet's measurement list is. -`PacketKind` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its kind. +`PacketType` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its type. ### 1.2 `PacketDef` Struct used to define the structure of a packet a board can send or recieve. @@ -29,8 +29,8 @@ ID of a packet, using the AdjId type which is a u16. Structure of ID is defined The owning board's `BoardId`. Carried here too (not just reachable through the owning `Board`) so anything holding just a `&PacketDef` — e.g. a lookup table indexed by `AdjId` — knows which board it belongs to without a separate index back to it. #### `name` Human-readable String shown to the user to identify packet. -#### `kind` -`PacketKind` enum: both what kind of packet this is, and the measurement shape — if any — specific to that kind. +#### `packet_type` +`PacketType` enum: both what type of packet this is, and the measurement shape — if any — specific to that type. ### 1.3 `Board` Struct used to identify a single board in the vehicle. @@ -56,6 +56,10 @@ Every board of the vehicle, as a `HashMap` keyed by id — the A Defined as their own types (wrapping a `u16`) instead of using a plain `u16`, so the compiler won't let an `AdjId` be confused with a `BoardId` by mistake, even though both are just numbers underneath ### 2.1 `AdjId` ID of an ADJ packet in u16. Structure of ID can be found in the ADJ documentation. + +For a protection's compound id (the high 3 bits are the protection's position, the low 13 bits the watched measurement's id — see the struct's own docs for the full range table), `AdjId` exposes the split as two methods instead of leaving every caller to redo the shift/mask: +- `protection_position() -> u8`: the high 3 bits (`0` for a normal, non-protection id). +- `measurement_id() -> AdjId`: the id with its high 3 bits zeroed out — the watched measurement's own id. ### 2.2 `BoardId` Unique identifier of a board, defined by the ADJ documentation. ### 2.3 `MacAddress` @@ -119,35 +123,60 @@ Second of Timestamp of values 0–59. #### `subsecond` The sub-second fraction, as the raw byte on the wire. Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. -### 4.2 `DataPacket` -Struct for the decoded values of the measurements a board just sent. Values are positional: they are not individually tagged with their measurement ID, but written in the order their `Measurement`s appear in this packet's `PacketKind::Data` list. +### 4.2 `DataOrderPacket` — a phantom type +`DataPacket` and `OrderPacket` hold the exact same shape (`id: AdjId`, `values: Vec`, both positional — see below), so they're both built on one generic struct, `DataOrderPacket`, instead of being copy-pasted. The two used to just be `type` aliases for the very same concrete struct (`type OrderPacket = DataOrderPacket; type DataPacket = DataOrderPacket;`) — which meant they weren't actually distinct types at all: nothing stopped a `DataPacket` just decoded off the UDP socket from being passed to `protocol::tcp::encode_order` (which wants an *order* to send) by mistake, the compiler had no way to tell them apart. -#### `id` -Which packet definition this is an instance of (`AdjId`). -#### `values` -Vector list of decoded values (`Vec`) corresponding positionally to the packet definition's measurements. +`Kind` fixes that. It's a *phantom* type parameter — `DataOrderPacket` doesn't actually store a `Kind` value (there's nothing to store, see `Data`/`Order` below), it only exists so the compiler tracks which one a given packet was built as: +```rust +pub struct DataOrderPacket { + pub id: AdjId, + pub values: Vec, + _marker: PhantomData, +} +``` +`_marker` is private, so a `DataOrderPacket` can't be built with struct-literal syntax from outside `packet.rs` — use `DataOrderPacket::new(id, values)` (or, through the aliases below, `DataPacket::new(...)`/`OrderPacket::new(...)`) instead. + +#### `Data` / `Order` +Two empty marker structs — the only two types `Kind` is ever instantiated with. Neither is ever actually constructed; they only ever appear as a type argument. That's what makes the whole thing *phantom*: the type parameter has no representation in the compiled struct, it's erased after type-checking, the same way `id.rs` (§2) uses distinct `AdjId`/`BoardId` types so the compiler won't confuse two different kinds of `u16`. + +#### `PacketTypeTag` +The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `PacketTypeTag` is how code that's generic over `Kind` answers that anyway, by asking the question of the *type* instead of the *value*: +```rust +pub trait PacketTypeTag { + fn matches(packet_type: &PacketType) -> bool; +} +``` +`Data`/`Order` each implement it by checking which `PacketType` (§1.1) variant they stand for (`matches!(packet_type, PacketType::Data(_))` / `PacketType::Order(_)`). This is the only thing `PacketTypeTag` decides — once a `PacketDef`'s type is confirmed to match, the actual measurement list still comes from the existing `PacketType::measurements()` (§1.1), not from `PacketTypeTag` itself. + +`protocol::packet::data_order`'s `encode`/`decode` use this to be generic over `Kind`: written once, they work for both `DataPacket` and `OrderPacket` instead of needing a near-duplicate pair of functions per type. -### 4.3 `OrderPacket` -An order sent from the backend to a board. Also positional, matching field definitions in order. +#### `DataPacket` / `OrderPacket` +```rust +pub type DataPacket = DataOrderPacket; +pub type OrderPacket = DataOrderPacket; +``` +Now genuinely different types: a function that takes an `OrderPacket` cannot be called with a `DataPacket`, even though both just wrap the same `(AdjId, Vec)` underneath — the mix-up is a compile error instead of a bug waiting to happen. `DataOrderPacket`'s own doc comment has a `compile_fail` doctest proving exactly that (run by `cargo test`, like any other doctest). + +`DataPacket` holds the decoded values of the measurements a board just sent — positional, not individually tagged with their measurement ID, just written in the order their `Measurement`s appear in this packet's `PacketType::Data` list. + +`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketType::Order`'s measurement list. #### `id` -Which order definition this is an instance of (`AdjId`). -#### `fields` -Vector list of values (`Vec`) to set for each field of the order. +Which packet/order definition this is an instance of (`AdjId`). +#### `values` +Vector list of decoded values (`Vec`) corresponding positionally to the packet definition's measurements. -### 4.4 `ProtectionPacket` +### 4.3 `ProtectionPacket` Sent by a board when one of its declared protections triggers. -#### `measurement` -The measurement this protection watches (`AdjId`). -#### `protection` -Which of that measurement's protections triggered (`AdjId`). +#### `id` +The compound wire id this protection triggered under (`AdjId`). Use `id.measurement_id()` for the measurement it watches and `id.protection_position()` for which of that measurement's protections this is (see `Protection::id`). Kept as a single field rather than storing both halves separately — they're never independent of each other, so a second field would only add a second place for them to disagree. #### `timestamp` When the protection triggered (`ShortTimestamp`). #### `value` The value that triggered the protection (`Value`), decoded with the same wire type as the watched measurement. -### 4.5 `MessageLevel` +### 4.4 `MessageLevel` Enum indicating how severe a message is (reusing `general_info.json`'s `message_ids`). Distinct from `Severity` because message and protection formats are separate in ADJv3. #### `Fault` @@ -159,11 +188,18 @@ Informational message, no action implied. #### `Panic` An unrecoverable firmware error. -### 4.6 `MessagePacket` +`MessageLevel` also exposes: +- `ALL: [MessageLevel; 4]` — every severity, for code that needs to go through each one (e.g. building a lookup from a loaded ADJ's `message_ids`). +- `name(self) -> &'static str` — this severity's key in `general_info.json`'s `message_ids` (`"fault"`, `"warning"`, `"info"`, `"panic"`). +- `from_name(name: &str) -> Option` — the inverse of `name()`; `None` if `name` isn't one of the four. + +This is deliberately where the name↔severity association lives — in `model`, not `adj` or `protocol` — since it's domain knowledge about the severity itself. `adj::AdjInfo::message_levels` (see `adj/README.md`) calls into `name()`/`ALL` to build its id→severity lookup rather than hardcoding `"fault"`/`"warning"`/`"info"`/`"panic"` itself. + +### 4.5 `MessagePacket` A free-text message sent by a board. -#### `level` -How severe the message is (`MessageLevel`). +#### `id` +The wire id this message was sent under — one of the severities declared in `general_info.json`'s `message_ids`. Not a `MessageLevel` directly, the same way `ProtectionPacket::id` isn't a `Protection`: resolving it to a severity means looking it up (`PacketType::Message`/`protocol::Index`), not storing a second, redundant copy of it here. #### `timestamp` When the message was sent (`ShortTimestamp`). #### `origin` @@ -171,7 +207,7 @@ Source identifier, such as a source file path (`String`). #### `message` The text contents of the message (`String`). -### 4.7 `Packet` +### 4.6 `Packet` Enum representing any packet actually sent or received over the wire, once decoded. #### `Data` diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 18cef3c3e..2dc56b6b7 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -10,17 +10,17 @@ //! left out rather than guessed — add them once the packet-level JSON //! shape is settled. -use super::{AdjId, BoardId, MacAddress, Measurement}; +use super::{AdjId, BoardId, MacAddress, Measurement, MessageLevel}; use std::collections::HashMap; use std::net::IpAddr; -/// What kind of packet a [`PacketDef`] declares, carrying whatever -/// measurement shape — if any — is specific to that kind. Not every kind +/// What type of packet a [`PacketDef`] declares, carrying whatever +/// measurement shape — if any — is specific to that type. Not every type /// has measurements, so this says so in the type instead of forcing every /// [`PacketDef`] through one shared `measurements: Vec` field /// regardless of whether that's meaningful for it. #[derive(Debug, Clone, PartialEq)] -pub enum PacketKind { +pub enum PacketType { /// A board's periodic telemetry: one value per measurement, decoded /// positionally in this order. Data(Vec), @@ -29,19 +29,21 @@ pub enum PacketKind { Protection(Measurement), /// A command sent to a board. Many orders take no parameters at all. Order(Vec), - /// A free-text log line. Never carries measurement data. - Message, + /// A free-text log line of this severity. Never carries measurement + /// data — the severity is the only thing that tells one `Message` + /// `PacketDef` apart from another. + Message(MessageLevel), } -impl PacketKind { - /// Every measurement this packet's kind references, flattened — for - /// code that doesn't care which kind this is, just what measurements +impl PacketType { + /// Every measurement this packet's type references, flattened — for + /// code that doesn't care which type this is, just what measurements /// it touches. pub fn measurements(&self) -> &[Measurement] { match self { - PacketKind::Data(measurements) | PacketKind::Order(measurements) => measurements, - PacketKind::Protection(measurement) => std::slice::from_ref(measurement), - PacketKind::Message => &[], + PacketType::Data(measurements) | PacketType::Order(measurements) => measurements, + PacketType::Protection(measurement) => std::slice::from_ref(measurement), + PacketType::Message(_) => &[], } } } @@ -64,8 +66,8 @@ pub struct PacketDef { /// The human-readable name shown to the user. pub name: String, /// Whether this is data, order, protection or message — and the - /// measurement shape specific to that kind, if any. - pub kind: PacketKind, + /// measurement shape specific to that type, if any. + pub packet_type: PacketType, } /// A single board of the vehicle, as defined by the ADJ. @@ -130,7 +132,7 @@ mod tests { id: AdjId(1000), board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![sample_measurement()]), + packet_type: PacketType::Data(vec![sample_measurement()]), }, )]), }; @@ -139,7 +141,7 @@ mod tests { let Some(packet) = board.packets.get(&AdjId(1000)) else { unreachable!("the packet we just inserted should be there"); }; - let PacketKind::Data(measurements) = &packet.kind else { + let PacketType::Data(measurements) = &packet.packet_type else { unreachable!("packet should be Data"); }; assert_eq!(measurements.len(), 1); diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index d6a6845a5..948e71b98 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -12,7 +12,32 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AdjId(pub u16); +impl AdjId { + /// The high 3 bits: `0` for a normal `packet`/`order`/`measurement` + /// id, `1`-`7` for which of the watched measurement's protections this + /// is. + pub fn protection_position(self) -> u8 { + (self.0 >> 13) as u8 + } + + /// The watched measurement's id: this id with its high 3 bits (the + /// protection's position) zeroed out. + /// + /// For a normal, non-protection id (`protection_position() == 0`), + /// this is just the id itself. + pub fn measurement_id(self) -> AdjId { + AdjId(self.0 & 0x1FFF) + } +} + /// Unique identifier of a board, defined by the ADJ. +/// +/// `BoardId(0)` is reserved: no real board uses it. [`crate::protocol::Index`] +/// uses it as the `board` for a synthesized [`super::PacketDef`] that +/// isn't owned by any one board at all — a message severity, a +/// session-wide id any board's free-text log frame can reuse. Not +/// validated anywhere; just a convention, the same way `0` is excluded +/// from [`AdjId`]'s own special range above. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BoardId(pub u16); @@ -74,4 +99,19 @@ mod tests { let board = BoardId(1); assert_eq!(packet.0, board.0); // only the inner numbers can be compared } + + #[test] + fn a_normal_ids_position_is_zero_and_it_is_its_own_measurement_id() { + let id = AdjId(3000); + assert_eq!(id.protection_position(), 0); + assert_eq!(id.measurement_id(), AdjId(3000)); + } + + #[test] + fn a_protections_compound_id_splits_into_position_and_measurement() { + // (3 << 13) | 100 = 24576 | 100 = 24676 + let compound = AdjId((3u16 << 13) | 100); + assert_eq!(compound.protection_position(), 3); + assert_eq!(compound.measurement_id(), AdjId(100)); + } } diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 7ac91f048..c3dda408d 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -6,7 +6,8 @@ //! packet or a protection *can* look like, or [`super::PacketDef`], which //! describes a whole packet. -use super::{AdjId, Value}; +use super::{AdjId, PacketType, Value}; +use std::marker::PhantomData; /// A short timestamp carrying only the time of day: hour, minute, second /// and a sub-second fraction, each one byte. @@ -32,48 +33,123 @@ pub struct ShortTimestamp { pub subsecond: u8, } -/// A data packet: the decoded values of the measurements a board just -/// sent. +/// Tags a [`DataOrderPacket`] as a board's periodic telemetry +/// ([`DataPacket`]) rather than a command sent to one. +/// +/// Carries no data — see [`DataOrderPacket`] for why it exists at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Data; + +/// Tags a [`DataOrderPacket`] as a command sent to a board +/// ([`OrderPacket`]) rather than telemetry received from one. +/// +/// Carries no data — see [`DataOrderPacket`] for why it exists at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Order; + +/// Bridges a [`DataOrderPacket`]'s phantom `Kind` to the runtime +/// [`PacketType`] variant it stands for. +/// +/// `protocol::packet::data_order`'s `encode`/`decode` are generic over +/// `Kind`: they're written once and work for both [`Data`] and [`Order`], +/// instead of needing a near-identical copy per type. This trait is the +/// one yes/no question that generic code can't answer on its own — +/// "is this `PacketDef`'s type actually the one I was asked to +/// encode/decode?" — everything after that (pulling out the +/// measurements) is already handled by [`PacketType::measurements`], +/// type-agnostic, so there's no need to redo that here. +pub trait PacketTypeTag { + /// Whether `packet_type` is the variant this `Kind` stands for. + fn matches(packet_type: &PacketType) -> bool; +} + +impl PacketTypeTag for Data { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Data(_)) + } +} + +impl PacketTypeTag for Order { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Order(_)) + } +} + +/// A Data/Order packet: the decoded values of the measurements a board just +/// sent, or that an order is about to send to one. /// /// The wire protocol is positional: values are not individually tagged /// with their measurement's id, just written back to back in the order /// their [`super::Measurement`]s appear in this packet's -/// [`super::PacketKind::Data`] list. `values[i]` is the decoded value of +/// [`super::PacketType::Data`] list. `values[i]` is the decoded value of /// that list's `i`-th measurement — matching them up by position is /// `protocol`'s job, this type just holds the result in that same order. +/// +/// `Kind` is a phantom type parameter (either [`Data`] or [`Order`], never +/// anything else — see [`DataPacket`]/[`OrderPacket`]): the struct stores +/// no `Kind` value, nothing to store (`Data`/`Order` carry no data), it +/// only exists so the compiler can tell a [`DataPacket`] apart from an +/// [`OrderPacket`], the same way [`super::AdjId`]/[`super::BoardId`] +/// (`id.rs`) are distinct types so the compiler won't let one be confused +/// with the other, even though both are just a `u16` underneath. Before +/// this, [`DataPacket`] and [`OrderPacket`] were both plain aliases for +/// the same concrete struct — nothing stopped a `DataPacket`, decoded off +/// the UDP socket, from being passed to [`crate::protocol::tcp::encode_order`] +/// by mistake; that mix-up is now a compile error: +/// +/// ```compile_fail +/// use software_core::model::{AdjId, DataPacket, OrderPacket}; +/// +/// fn send_order(_order: OrderPacket) {} +/// +/// let data = DataPacket::new(AdjId(1000), vec![]); +/// send_order(data); // doesn't compile: `DataPacket` isn't an `OrderPacket` +/// ``` #[derive(Debug, Clone, PartialEq)] -pub struct DataPacket { - /// Which packet definition this is an instance of. +pub struct DataOrderPacket { + /// The packet's id. pub id: AdjId, - /// The decoded value of each measurement in the packet, positional — - /// see the struct-level docs above. + /// Each measurement's decoded value, in the same order as the + /// matching [`super::PacketType::Data`]/[`super::PacketType::Order`] + /// list. pub values: Vec, + _marker: PhantomData, +} + +impl DataOrderPacket { + /// Builds a packet tagged as `Kind`, e.g. `DataPacket::new(id, values)` + /// or `OrderPacket::new(id, values)`. + pub fn new(id: AdjId, values: Vec) -> Self { + Self { + id, + values, + _marker: PhantomData, + } + } } /// An order sent from the backend to a board. /// /// Positional, the same way [`DataPacket::values`] is: see its docs. -#[derive(Debug, Clone, PartialEq)] -pub struct OrderPacket { - /// Which order definition this is an instance of. - pub id: AdjId, - /// The value to set for each field of the order, positional. - pub fields: Vec, -} +pub type OrderPacket = DataOrderPacket; +/// A set of measurements sent by a board. +/// +/// Positional, the same way [`DataOrderPacket::values`] is: see its docs. +pub type DataPacket = DataOrderPacket; /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. /// -/// `measurement` and `protection` together are the two halves of the -/// compound wire id — decoding that split is `protocol`'s job, this type -/// just holds the result. +/// `id` is the compound wire id: [`AdjId::measurement_id`] gives the +/// watched measurement, [`AdjId::protection_position`] which of that +/// measurement's protections triggered (see [`super::Protection::id`]). +/// Kept as a single field rather than storing both halves separately — +/// they're never independent of each other, so there's nothing a second +/// field would add beyond a second place for them to disagree. #[derive(Debug, Clone, PartialEq)] pub struct ProtectionPacket { - /// The measurement this protection watches. - pub measurement: AdjId, - /// Which of that measurement's protections triggered — see - /// [`super::Protection::id`]. - pub protection: AdjId, + /// The compound id this protection triggered under. + pub id: AdjId, /// When it triggered. pub timestamp: ShortTimestamp, /// The value that triggered it, decoded with the same wire type as the @@ -99,11 +175,53 @@ pub enum MessageLevel { Panic, } +impl MessageLevel { + /// All four severities, in no particular order — for code that needs + /// to go through each one, e.g. building a lookup from a loaded + /// ADJ's `general_info.json` `message_ids` (see [`MessageLevel::name`]). + pub const ALL: [MessageLevel; 4] = [ + MessageLevel::Fault, + MessageLevel::Warning, + MessageLevel::Info, + MessageLevel::Panic, + ]; + + /// This severity's key in `general_info.json`'s `message_ids` map + /// (ADJv3: `"fault"`, `"warning"`, `"info"`, `"panic"`). That map + /// only gives a severity's *name*, not which `MessageLevel` it is — + /// this is the one place that association is spelled out, so + /// nothing else has to hardcode these four strings again. + pub fn name(self) -> &'static str { + match self { + MessageLevel::Fault => "fault", + MessageLevel::Warning => "warning", + MessageLevel::Info => "info", + MessageLevel::Panic => "panic", + } + } + + /// The inverse of [`MessageLevel::name`]: the severity named `name`, + /// or `None` if `name` isn't one of the four (e.g. `general_info.json` + /// declares other, unrelated reserved names in that same map). + pub fn from_name(name: &str) -> Option { + MessageLevel::ALL + .into_iter() + .find(|level| level.name() == name) + } +} + /// A free-text message sent by a board. #[derive(Debug, Clone, PartialEq)] pub struct MessagePacket { - /// How severe the message is. - pub level: MessageLevel, + /// The wire id this message was sent under — one of the severities + /// declared in `general_info.json`'s `message_ids`. + /// + /// Not a [`MessageLevel`] directly, the same way [`ProtectionPacket::id`] + /// isn't a [`super::Protection`]: this is what was actually on the + /// wire, and resolving it to a severity means looking it up (see + /// [`PacketType::Message`]/[`crate::protocol::Index`]), not storing + /// a second, redundant copy of it here. + pub id: AdjId, /// When it was sent. pub timestamp: ShortTimestamp, /// Where it came from, e.g. a source file path. @@ -164,10 +282,10 @@ mod tests { } #[test] - fn a_protection_packet_carries_its_measurement_and_its_own_position_separately() { + fn a_protection_packets_id_splits_into_its_measurement_and_its_own_position() { + // (1 << 13) | 3000 = 8192 | 3000 = 11192 let packet = ProtectionPacket { - measurement: AdjId(3000), - protection: AdjId(1), + id: AdjId((1u16 << 13) | 3000), timestamp: ShortTimestamp { hour: 12, minute: 0, @@ -177,8 +295,8 @@ mod tests { value: Value::Number(53.2), }; - assert_eq!(packet.measurement, AdjId(3000)); - assert_eq!(packet.protection, AdjId(1)); + assert_eq!(packet.id.measurement_id(), AdjId(3000)); + assert_eq!(packet.id.protection_position(), 1); } #[test] @@ -197,14 +315,25 @@ mod tests { } } + #[test] + fn each_message_levels_name_matches_the_adj_and_is_distinct() { + let names: Vec<&str> = MessageLevel::ALL.iter().map(|level| level.name()).collect(); + assert_eq!(names, vec!["fault", "warning", "info", "panic"]); + } + + #[test] + fn from_name_is_the_inverse_of_name() { + for level in MessageLevel::ALL { + assert_eq!(MessageLevel::from_name(level.name()), Some(level)); + } + assert_eq!(MessageLevel::from_name("not_a_severity"), None); + } + #[test] fn data_packet_values_are_positional_not_tagged_with_an_id() { // values[0] is whatever measurements[0] is in this packet's // PacketDef — there is no id stored alongside each value here. - let packet = DataPacket { - id: AdjId(1000), - values: vec![Value::Number(53.2), Value::Bool(true)], - }; + let packet = DataPacket::new(AdjId(1000), vec![Value::Number(53.2), Value::Bool(true)]); assert_eq!(packet.values[0], Value::Number(53.2)); assert_eq!(packet.values[1], Value::Bool(true)); @@ -212,14 +341,8 @@ mod tests { #[test] fn packet_wraps_each_kind_without_mixing_them_up() { - let data = Packet::Data(DataPacket { - id: AdjId(1000), - values: vec![], - }); - let order = Packet::Order(OrderPacket { - id: AdjId(2000), - fields: vec![], - }); + let data = Packet::Data(DataPacket::new(AdjId(1000), vec![])); + let order = Packet::Order(OrderPacket::new(AdjId(2000), vec![])); assert_ne!(data, order); match data { @@ -227,4 +350,25 @@ mod tests { _ => unreachable!("this is a Packet::Data"), } } + + #[test] + fn two_data_packets_with_the_same_id_and_values_are_equal() { + let a = DataPacket::new(AdjId(1000), vec![Value::Number(1.0)]); + let b = DataPacket::new(AdjId(1000), vec![Value::Number(1.0)]); + assert_eq!(a, b); + } + + #[test] + fn an_order_packet_and_a_data_packet_built_from_the_same_id_and_values_are_different_types() { + // This is the whole point of tagging `DataOrderPacket` with a + // phantom `Kind`: `DataPacket` and `OrderPacket` used to be plain + // aliases for the same concrete struct, so nothing here would even + // have been a type error before. There's no runtime assertion to + // make for that — see this module's `compile_fail` doctest on + // `DataOrderPacket`, which proves the mix-up doesn't compile. + let data = DataPacket::new(AdjId(1000), vec![]); + let order = OrderPacket::new(AdjId(1000), vec![]); + assert_eq!(data.id, order.id); + assert_eq!(data.values, order.values); + } } diff --git a/backend/crates/core/src/protocol.rs b/backend/crates/core/src/protocol.rs new file mode 100644 index 000000000..e89fe6064 --- /dev/null +++ b/backend/crates/core/src/protocol.rs @@ -0,0 +1,24 @@ +//! Translates bytes <-> typed packets ([`crate::model::Packet`] and +//! friends), using the shapes [`crate::adj`] already declared +//! (`PacketDef`, `Measurement`) as the schema. Never touches a real +//! socket — that's `core::net`'s job, which calls into this module's +//! functions and owns the actual `TcpStream`/`UdpSocket`. +//! +//! The TCP/UDP split lives in the *types* each submodule can produce: +//! [`crate::protocol::udp::decode_datagram`] can only ever return a +//! `DataPacket`, [`crate::protocol::tcp::decode_next`] only a +//! [`crate::protocol::tcp::TcpFrame`] (`Protection`/`Message`) — the +//! compiler enforces the separation the ADJ itself draws between what +//! travels over which transport, not runtime convention. `tcp`/`udp` +//! themselves are thin wrappers around [`crate::protocol::packet`], which +//! does the actual per-packet-type work independent of transport. + +mod error; +mod index; +pub mod packet; +pub mod tcp; +pub mod udp; +pub mod wire; + +pub use error::{DecodeError, EncodeError}; +pub use index::Index; diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md new file mode 100644 index 000000000..f107da5d2 --- /dev/null +++ b/backend/crates/core/src/protocol/README.md @@ -0,0 +1,213 @@ +# Protocol Module + +This module handles encoding and decoding of packets for communication with hardware boards over TCP and UDP transports. It implements the ADJ protocol for telemetry data, commands, messages, and protection events. + +## Architecture + +The protocol module follows a layered architecture: + +``` +┌─────────────────────────────────────────┐ +│ Transport Layer │ +│ (tcp.rs, udp.rs) │ +├─────────────────────────────────────────┤ +│ Packet Layer │ +│ (packet/data_order.rs, │ +│ packet/message.rs, │ +│ packet/protection.rs) │ +├─────────────────────────────────────────┤ +│ Wire Layer │ +│ (wire.rs) │ +└─────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +protocol/ +├── index.rs # Packet definition lookup table +├── error.rs # Decode and encode error types +├── wire.rs # Low-level byte ↔ value conversion +├── packet.rs # Packet-level encoding/decoding module +│ ├── data_order.rs # Data (telemetry) & Order (command) packets +│ ├── message.rs # Free-text log message packets +│ └── protection.rs # Protection trigger packets +├── tcp.rs # TCP transport framing +└── udp.rs # UDP transport framing +``` + +## Modules + +### `index.rs` - Packet Definition Lookup + +Provides a fast lookup table from packet IDs (`AdjId`) to their declarations (`PacketDef`). Built once from loaded ADJ configuration data (`PodData`), it owns cloned copies of packet definitions so it can be shared across async tasks via `Arc`. + +A message severity is not a separate lookup bolted onto `Index` — it's just another `PacketDef`. `Index::new(pod_data, message_levels)` takes `message_levels` as `Option<&HashMap>` (an ADJ's `adj::AdjInfo::message_levels` — `None` for callers with no `AdjInfo` at all, e.g. most tests here) and, for each entry, synthesizes a `PacketDef { packet_type: PacketType::Message(level), board: BoardId(0), .. }` into the very same `AdjId -> PacketDef` table as every board's own packets. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own docs) because a severity isn't owned by any one board. Resolving a message is then `Index::packet(id)` + matching `PacketType::Message(level)`, exactly like `packet::protection` already does for `PacketType::Protection`. + +**Key types:** +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` (synthesized `Message` entries included) + +**Key functions:** +- `Index::new(pod_data, message_levels)` - Build an index from a loaded ADJ's `PodData` and, optionally, its `AdjInfo::message_levels` +- `Index::packet(id)` - Look up a packet's declaration by id — `Data`, `Order`, `Protection` or `Message` alike + +### `error.rs` - Error Types + +Defines errors that can occur during packet encoding and decoding. + +**Key types:** +- `DecodeError` - Errors when converting bytes to packets + - `UnknownId` - No packet declared with this ID + - `UnexpectedEof` - Ran out of bytes during decode + - `Io` - I/O error reading from stream (TCP only) + - `InvalidEnumValue` - Enum index out of range + - `UnterminatedString` - String field not null-terminated + - `InvalidUtf8` - String field's bytes weren't valid UTF-8 + - `WrongPacketType` - Packet exists but isn't expected type + +- `EncodeError` - Errors when converting packets to bytes + - `UnknownId` - No packet declared with this ID + - `WrongFieldCount` - Missing or extra fields in order + - `TypeMismatch` - Value doesn't match declared type + - `OutOfRange` - Numeric value doesn't fit wire type + - `WrongPacketType` - Packet exists but isn't expected type + - `StringTooLong` - String field + terminator doesn't fit the max length + - `EmbeddedNulByte` - String field contains a `\0` byte + +### `wire.rs` - Low-Level Byte Codec + +Handles conversion between raw bytes and `Value` types for individual measurements, based on their declared `MeasurementKind`. All numeric types use little-endian encoding. + +**Key functions:** +- `decode_value(bytes, kind)` - Decode a single value from bytes +- `encode_value(value, kind)` - Encode a single value to bytes +- `byte_width(kind)` - Get the wire width in bytes for a measurement kind + +**Supported types:** +- **Numeric** (U8, U16, U32, U64, I8, I16, I32, I64, F32, F64) - ✓ Implemented +- **Enum** (single byte index) - ✓ Implemented +- **Boolean** (single byte, zero = `false`, nonzero = `true`) - ✓ Implemented + +### `packet/` - Packet-Level Encoding/Decoding + +Contains per-packet-type encoding and decoding logic, independent of +transport. **Hard rule for this directory: all byte-level parsing for a +packet type lives in its own file here, never in `tcp.rs`/`udp.rs`.** +See **[`packet/README.md`](packet/README.md)** for the full design +rationale (the `Option` pattern below, and exactly what `tcp.rs` +is and isn't allowed to do). + +#### `data_order.rs` + +Handles the positional Data/Order wire shape shared by both packet types +(see `model::DataOrderPacket`'s phantom `Kind`). `encode`/`decode` are +generic over `Kind`, written once for both: +- **Order packets** (commands sent to boards) - sent over TCP +- **Data packets** (periodic telemetry from boards) - received over UDP + +Self-contained both ways: they always read/write the leading 2-byte id +themselves (there's no dispatch ambiguity to resolve first here). The +backend itself only ever calls `encode` with an Order packet (via +`tcp::encode_order`) and `decode::` (via `udp::decode_datagram`) - +that direction restriction lives at the transport layer, not here. + +**Key functions:** +- `encode(packet, index)` - Encode a Data or Order packet into bytes (`Kind` inferred from `packet`'s type) +- `decode::(bytes, index)` - Decode bytes into a Data or Order packet + +#### `message.rs` + +Decodes free-text log messages from boards over TCP (decode only — in +production this only ever travels board → backend). `encode` also +exists, but purely for test symmetry; nothing in the real backend calls +it. + +Unlike `data_order.rs`, a TCP dispatcher needs this frame's leading id +*before* it even knows to call this file (to tell a message apart from +a protection) — so, same as `protection.rs`, `id` is `Option`: +`Some` if the caller already read it (and `reader` starts right after +the id), `None` to have `decode` read it itself. Either way, `id` is +resolved against `index` to confirm it's actually a declared +`PacketType::Message` (`UnknownId`/`WrongPacketType` otherwise) — but unlike +`protection.rs`, the severity itself is never stored on the decoded +`MessagePacket`; it's metadata about the id, not about this particular +instance, so look it up again via `index.packet(id)` if it's needed. + +**Key functions:** +- `decode(reader, id: Option, index)` - Decode a message frame, resolving its id itself if not given one +- `encode(packet, index)` - Test-only: encode a complete `MessagePacket` frame, id included + +#### `protection.rs` + +Decodes protection trigger events from boards over TCP (decode only — +in production this only ever travels board → backend). `encode` also +exists, but purely for test symmetry; nothing in the real backend calls +it. + +Same `Option` pattern as `message.rs`, for the same reason: a TCP +dispatcher needs the leading id first to tell a protection apart from a +message. `compound_id: Option` - `Some` if the caller already +read it, `None` to have `decode` read it itself off `reader`'s first 2 +bytes. + +**Key functions:** +- `decode(reader, compound_id: Option, index)` - Decode a protection frame, resolving its id itself if not given one +- `encode(packet, index)` - Test-only: encode a complete `ProtectionPacket` frame, id included + +### `tcp.rs` - TCP Transport Framing + +Handles the dedicated TCP connection to each board. A TCP connection carries: +- Protection events (board → backend) +- Free-text messages (board → backend) +- Order commands (backend → board) + +`decode_next` reads the frame's leading 16-bit id itself (via `packet::read_id`, `pub(super)` so `tcp` — a sibling of `packet`, not a descendant — can reach it), then dispatches purely on the raw id: `id.0 >= 8192` (`1 << 13` — a protection's compound id always has its high 3 bits set, see `AdjId`'s own docs) goes to `packet::protection::decode(reader, Some(id), index)`, anything below that to `packet::message::decode(reader, Some(id), index)`. Neither branch does its own lookup first — each `decode` already resolves `UnknownId`/`WrongPacketType` against `index` itself. + +**Key types:** +- `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) + +**Key functions:** +- `decode_next(reader, index)` - ✓ Implemented - Decode the next frame from a TCP stream +- `encode_order(order, index)` - ✓ Implemented - Encode an order for sending + +### `udp.rs` - UDP Transport Framing + +Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete, already-delimited buffers containing Data packets. + +**Key functions:** +- `decode_datagram(bytes, index)` - ✓ Implemented - Decode a UDP datagram into a DataPacket + +## Packet Types Summary + +| Packet Type | Direction | Transport | Status | +|-------------|-----------|-----------|--------| +| Data (telemetry) | Board → Backend | UDP | ✓ Implemented | +| Order (command) | Backend → Board | TCP | ✓ Implemented | +| Message (log) | Board → Backend | TCP | ✓ Implemented (decode is the only real direction; `encode` is test-only) | +| Protection (event) | Board → Backend | TCP | ✓ Implemented (decode is the only real direction; `encode` is test-only) | + +## Implementation Status + +- ✅ **Data packets** - Full encode/decode with tests +- ✅ **Order packets** - Full encode/decode with tests +- ✅ **Wire-level numeric types** - All numeric kinds supported with tests +- ✅ **Wire-level enum types** - Full encode/decode with tests +- ✅ **Wire-level boolean types** - Single byte, confirmed with firmware +- ✅ **Message packets** - decode implemented, with tests (encode exists for test symmetry only) +- ✅ **Protection packets** - decode implemented, with tests (encode exists for test symmetry only) +- ✅ **TCP framing** - `decode_next` implemented and tested (dispatches to protection/message by `id.0 >= 8192`) + +## Testing + +The module includes comprehensive unit tests for: +- Wire-level encoding/decoding for all numeric, boolean and enum types +- Order/Data packet encoding and decoding, including round trips +- Message/Protection packet decoding (timestamps, strings, values) and + their test-only encoding, including round trips +- Error propagation for unknown IDs, wrong packet types, type mismatches, + unterminated/invalid strings, and out-of-range values + +Run tests with: +```bash +cargo test -p core protocol +``` diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs new file mode 100644 index 000000000..b6de39dea --- /dev/null +++ b/backend/crates/core/src/protocol/error.rs @@ -0,0 +1,135 @@ +//! Errors produced while translating bytes into packets and back. + +use crate::model::AdjId; + +/// An error decoding bytes into a packet. +#[derive(Debug, thiserror::Error)] +pub enum DecodeError { + /// No packet is declared with this id. + #[error("unknown packet id {0:?}")] + UnknownId(AdjId), + + /// Ran out of bytes before a complete value could be decoded. + #[error("not enough bytes to decode a value")] + UnexpectedEof, + + /// An I/O error while reading from a stream (TCP only — a UDP + /// datagram is decoded from an in-memory buffer, never a stream). + #[error("I/O error reading a frame")] + Io(#[from] std::io::Error), + + /// A decoded enum value's index doesn't match any of its declared + /// options. + #[error("enum value {index} is out of range for {options} option(s)")] + InvalidEnumValue { + /// The decoded index. + index: u32, + /// How many options the measurement declares. + options: usize, + }, + + /// A free-text field (`origin`/`message`) had no terminating `0` byte + /// within its maximum length. + #[error("string field wasn't terminated within {max} bytes")] + UnterminatedString { + /// The field's maximum length. + max: usize, + }, + + /// A free-text field's bytes weren't valid UTF-8. + #[error("string field wasn't valid UTF-8: {0:?}")] + InvalidUtf8(Vec), + + /// A looked-up packet exists, but isn't the type this function expects + /// (e.g. `packet::data::decode` found an id that resolves to an + /// `Order`). + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. + id: AdjId, + }, +} + +impl PartialEq for DecodeError { + // `Io`'s `std::io::Error` doesn't implement `PartialEq`, so this can't + // be derived — compare by `ErrorKind` instead for that variant. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::UnknownId(a), Self::UnknownId(b)) => a == b, + (Self::UnexpectedEof, Self::UnexpectedEof) => true, + (Self::Io(a), Self::Io(b)) => a.kind() == b.kind(), + ( + Self::InvalidEnumValue { + index: a_index, + options: a_options, + }, + Self::InvalidEnumValue { + index: b_index, + options: b_options, + }, + ) => a_index == b_index && a_options == b_options, + (Self::UnterminatedString { max: a }, Self::UnterminatedString { max: b }) => a == b, + (Self::InvalidUtf8(a), Self::InvalidUtf8(b)) => a == b, + (Self::WrongPacketType { id: a }, Self::WrongPacketType { id: b }) => a == b, + _ => false, + } + } +} + +/// An error encoding a packet into bytes. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EncodeError { + /// No packet is declared with this id. + #[error("unknown packet id {0:?}")] + UnknownId(AdjId), + + /// The order didn't provide a value for every field its declaration + /// expects. + #[error("order needs {expected} field(s), got {got}")] + WrongFieldCount { + /// How many fields the order's `PacketDef` declares. + expected: usize, + /// How many fields were actually given. + got: usize, + }, + + /// A field's [`crate::model::Value`] doesn't match the kind its + /// [`crate::model::Measurement`] declares (e.g. a `Value::Bool` for a + /// numeric field). + #[error("value doesn't match its declared type")] + TypeMismatch, + + /// A numeric value doesn't fit the wire type's representable range + /// (or is NaN). + #[error("value {value} is out of range for this wire type ({min}..={max})")] + OutOfRange { + /// The value that didn't fit. + value: f64, + /// The wire type's minimum representable value. + min: f64, + /// The wire type's maximum representable value. + max: f64, + }, + + /// A looked-up packet exists, but isn't the type this function expects + /// (e.g. `packet::order::encode` found an id that resolves to a + /// `Data` packet). + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. + id: AdjId, + }, + + /// A string field's content plus its terminator wouldn't fit in the + /// wire format's maximum length. + #[error("string content plus its terminator doesn't fit in {max} byte(s)")] + StringTooLong { + /// The field's maximum length, terminator included. + max: usize, + }, + + /// A string field contains an embedded NUL byte, which would be + /// misread as an early terminator on the wire. + #[error("string contains an embedded NUL byte")] + EmbeddedNulByte, +} diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs new file mode 100644 index 000000000..0f9d5a5bd --- /dev/null +++ b/backend/crates/core/src/protocol/index.rs @@ -0,0 +1,127 @@ +//! The `id -> PacketDef` lookup `tcp`/`udp` decode against. + +use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketType, PodData}; +use std::collections::HashMap; + +/// A lookup table from a packet's [`AdjId`] to its declaration, built once +/// from a loaded ADJ's [`PodData`] — and, optionally, its message +/// severities too. +/// +/// Owns its own copy of every [`PacketDef`] (cloned out of `PodData` at +/// construction) rather than borrowing, so it carries no lifetime and can +/// be shared across `tokio::spawn`ed tasks via `Arc` without a +/// fight with the borrow checker. Knows nothing about networking (no +/// `IpAddr`/`SocketAddr`) — that bookkeeping belongs to `core::net`, not +/// here. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Index { + by_id: HashMap, +} + +impl Index { + /// Builds an index over every packet declared by every board in + /// `pod_data`, plus one synthesized [`PacketDef`] per entry in + /// `message_levels` (an ADJ's `general_info.json`-derived + /// [`crate::adj::AdjInfo::message_levels`]). + /// + /// `message_levels` is `Option` rather than a plain reference so + /// that callers with no [`crate::adj::AdjInfo`] at hand — most tests + /// in this crate, which only ever build a bare [`PodData`] — can + /// just pass `None` instead of having to construct an empty map. + /// + /// A message severity isn't declared by any one board (any board's + /// free-text log frame can reuse the same `"fault"` id), so its + /// synthesized `PacketDef` gets the reserved `BoardId(0)` — no real + /// board uses that id — instead of a real one. + pub fn new(pod_data: &PodData, message_levels: Option<&HashMap>) -> Self { + let mut by_id: HashMap = pod_data + .boards + .values() + .flat_map(|board| board.packets.values()) + .map(|packet| (packet.id, packet.clone())) + .collect(); + + if let Some(message_levels) = message_levels { + for (&id, &level) in message_levels { + by_id.insert( + id, + PacketDef { + id, + board: BoardId(0), + name: level.name().to_string(), + packet_type: PacketType::Message(level), + }, + ); + } + } + + Self { by_id } + } + + /// Looks up a packet's declaration by id — a message severity + /// resolves through here too, as a synthesized `PacketDef` with + /// `packet_type: PacketType::Message(level)`; see this type's doc + /// comment. + pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { + self.by_id.get(&id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Board, BoardId, MacAddress, PacketType}; + use std::net::IpAddr; + + fn sample_pod_data() -> PodData { + let packet = PacketDef { + id: AdjId(1000), + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([192, 168, 0, 10]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + PodData { + boards: HashMap::from([(board.id, board)]), + } + } + + #[test] + fn finds_a_packet_declared_on_any_board() { + let index = Index::new(&sample_pod_data(), None); + let Some(packet) = index.packet(AdjId(1000)) else { + unreachable!("packet 1000 was declared in the sample pod data"); + }; + assert_eq!(packet.name, "bcu_data"); + } + + #[test] + fn unknown_id_is_not_found() { + let index = Index::new(&sample_pod_data(), None); + assert!(index.packet(AdjId(9999)).is_none()); + } + + #[test] + fn a_message_severity_resolves_as_a_synthesized_packet_def() { + let message_levels = HashMap::from([(AdjId(2), MessageLevel::Fault)]); + let index = Index::new(&sample_pod_data(), Some(&message_levels)); + + let Some(packet) = index.packet(AdjId(2)) else { + unreachable!("the \"fault\" severity was declared at id 2"); + }; + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); + assert_eq!(packet.board, BoardId(0)); + } + + #[test] + fn with_no_message_levels_given_only_board_packets_resolve() { + let index = Index::new(&sample_pod_data(), None); + assert_eq!(index.packet(AdjId(2)), None); + } +} diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs new file mode 100644 index 000000000..3e061e44d --- /dev/null +++ b/backend/crates/core/src/protocol/packet.rs @@ -0,0 +1,41 @@ +//! Encode/decode for one specific type of packet, independent of which +//! transport carries it — that mapping lives in [`super::tcp`]/ +//! [`super::udp`], which are thin wrappers around these. +//! +//! [`data_order`]'s `encode`/`decode` are generic over `Kind` +//! ([`crate::model::PacketTypeTag`]), so they work for both `Data` and +//! `Order` alike; the backend itself only ever exercises one direction per +//! type (an order is only ever sent, data only ever received), but that's +//! a restriction `tcp`/`udp` apply, not something baked into the codec. +//! +//! [`message`]/[`protection`] are genuinely one-directional in +//! production — a board only ever sends these, never receives them — +//! but both also expose an `encode`, purely so tests can build +//! well-formed frames without hand-written byte arrays; see each +//! module's own doc comment. + +pub mod data_order; +pub mod message; +pub mod protection; + +use crate::model::AdjId; +use crate::protocol::DecodeError; +use std::io::Read; + +/// Reads a raw little-endian 16-bit id off the front of `reader`. +/// +/// Shared by [`message`]/[`protection`] — the only two types whose +/// `decode` ever needs to read its own leading id, when the caller +/// doesn't already know it (see each module's own doc comment on why) — +/// and by [`super::tcp::decode_next`], which needs the id *before* it +/// can even decide which of the two to call. `pub(super)` (visible to +/// `protocol` and everything under it) rather than private, for that +/// last caller: `tcp` is `packet`'s sibling, not its descendant, so +/// private wouldn't reach it. [`data_order`] doesn't need this: it reads +/// its id a different way, straight out of an in-memory byte slice, +/// never a `Read` stream. +pub(super) fn read_id(reader: &mut impl Read) -> Result { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes)?; + Ok(AdjId(u16::from_le_bytes(bytes))) +} diff --git a/backend/crates/core/src/protocol/packet/README.md b/backend/crates/core/src/protocol/packet/README.md new file mode 100644 index 000000000..b55d7556b --- /dev/null +++ b/backend/crates/core/src/protocol/packet/README.md @@ -0,0 +1,94 @@ +# `packet/` — Per-Packet-Type Codecs + +Each file here owns **all** the byte-level parsing for one packet type — +how many bytes a field takes, in what order, what terminates a string, +what the leading id means. That parsing never gets split out into +`tcp.rs`/`udp.rs` (the transport layer) or anywhere else. A transport +module is only ever allowed to do the one thing it structurally has no +choice about — see "The dispatcher's one unavoidable read" below — and +then hands the rest straight to one of these files. + +## `data_order.rs` + +Self-contained both ways: `encode`/`decode` always read/write the +leading 2-byte id themselves, since there's no dispatch ambiguity to +resolve first — a UDP datagram is unambiguously `Data`, and an order is +only ever something the backend already decided to send. Generic over +`Kind` (`model::PacketTypeTag`), written once for both `Data` and `Order` — +see its own module doc and `model/README.md`'s §4.2 for why. + +## `message.rs` / `protection.rs` + +These two are different: over TCP, a `Protection` frame and a `Message` +frame share the same leading 16-bit id space, so **something** has to +read that id before it even knows which of the two decoders to call. +That something is `tcp::decode_next`. To keep that reality from leaking +actual decoding logic into `tcp.rs`, both files take their id as +`Option` — the exact same shape: + +```rust +// protection.rs +pub fn decode(reader: impl Read, compound_id: Option, index: &Index) -> Result; +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError>; + +// message.rs +pub fn decode(reader: impl Read, id: Option, index: &Index) -> Result; +pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeError>; +``` + +- **`Some(known_value)`** — the caller already read it, typically + because it had to read the leading id anyway to decide which of these + two functions to call in the first place. In this case `reader` must + already be positioned *right after* the id (byte 2 onward). +- **`None`** — the caller doesn't know it and isn't going to try. In + this case `reader` must start at byte 0 of the frame, id included, and + `decode` reads those 2 bytes itself. Either way, the id is then + resolved against `index` — `index.packet(id)` + matching + `PacketType::Protection`/`PacketType::Message` — to confirm it's + actually the declared type (`UnknownId`/`WrongPacketType` otherwise). A + message's severity is just another `PacketDef` by the time it gets + here (see `index.rs`'s own docs) — there's no separate lookup for it. + +One real difference between the two: `ProtectionPacket` stores its +`id` *and* the triggering `value` (which needs the watched measurement's +wire type, from `PacketType::Protection(measurement)`, to decode at +all). `MessagePacket` only stores `id` — its severity is metadata +*about* that id, not part of the frame's own byte layout, so `decode` +only uses `PacketType::Message(level)` to confirm the type and discards +`level` immediately; nothing about `timestamp`/`origin`/`message`'s +bytes depends on which severity it is. Look the severity up again via +`index.packet(id)` if it's needed later — it's never stored redundantly +on the decoded instance. + +Either way, **every byte of this frame is parsed inside the file that +owns that packet type** — `tcp.rs` never touches a timestamp, a `resv` +byte, a measurement's wire width, or a NUL-terminated string. `encode` +always writes the leading id back out too (from `packet.id`, after +confirming via `index` it resolves to the right type), so its output is +a complete, self-contained frame — feed it straight into +`decode(reader, None, index)` and get the same packet back; that's +exactly what each file's round-trip test does. + +### The dispatcher's one unavoidable read + +`tcp::decode_next` still has to read the raw 2-byte id itself once, to +decide *which* of `protection::decode`/`message::decode` to call — there +is no way around this for a non-seekable stream: once those bytes are +consumed, there's no handing them to the other function's `None` path +instead. That single mechanical "read a `u16`" is the one piece of +byte-handling that's expected to exist in `tcp.rs` — it calls the exact +same private `read_id` helper `protection.rs`/`message.rs` use for their +own `None` path (defined once in `packet.rs`, `pub(super)` so `tcp.rs` +can reach it too, rather than duplicated a third time). It is not +decoding — nothing is validated or interpreted by it beyond a single +`id.0 >= 8192` comparison (a protection's compound id always has its +high 3 bits set, see `AdjId`'s own docs) — and its result is +immediately handed to the matching function as `Some(id)`, which then +resolves it against `index` itself and does everything else. + +## Tests + +Every file's test module proves both `Some` and `None` work, plus a +round trip through its own `encode`/`decode` (`protection.rs`, +`message.rs`) or through `encode::`/`decode::` +(`data_order.rs`). See each file's own tests for the exact byte layouts. diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs new file mode 100644 index 000000000..caca99161 --- /dev/null +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -0,0 +1,426 @@ +//! Encodes and decodes a positional Data/Order packet — see +//! [`crate::model::DataOrderPacket`] for why both share this one wire +//! shape under a phantom `Kind`. +//! +//! [`encode`]/[`decode`] are generic over that `Kind` via +//! [`crate::model::PacketTypeTag`], so they're written once and work for +//! either [`crate::model::Data`] or [`crate::model::Order`] — the backend +//! itself only ever calls `encode::` (via [`super::super::tcp`]) +//! and `decode::` (via [`super::super::udp`]), but that restriction +//! lives at the transport layer, not here. [`crate::model::PacketTypeTag`] +//! only answers whether a `PacketDef`'s type matches `Kind` at all; the +//! actual measurement list comes from +//! [`crate::model::PacketType::measurements`] either way. + +use crate::model::{AdjId, DataOrderPacket, PacketTypeTag}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; + +/// Encodes `packet` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode( + packet: &DataOrderPacket, + index: &Index, +) -> Result, EncodeError> { + // Look up the packet definition for this packet's id + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + // Check that the packet is an order or data, as expected by the generic PacketTypeTag + if !Kind::matches(&packet_def.packet_type) { + return Err(EncodeError::WrongPacketType { id: packet.id }); + } + + // Get the measurements for this packet + let measurements = packet_def.packet_type.measurements(); + + // Check that the number of values that we want to encode matches the number of measurements in the packet definition + if packet.values.len() != measurements.len() { + return Err(EncodeError::WrongFieldCount { + expected: measurements.len(), + got: packet.values.len(), + }); + } + + // Encode the packet id as little-endian u16 + let mut bytes = Vec::new(); + bytes.extend_from_slice(&packet.id.0.to_le_bytes()); + + // for each value in the packet encode it according to the corresponding measurements + for (field, measurement) in packet.values.iter().zip(measurements) { + // Encode the field value according to the measurement's kind + let field_bytes = wire::encode_value(field, &measurement.kind)?; + + // Append the encoded field bytes to the output vector + bytes.extend(field_bytes); + } + + Ok(bytes) +} + +/// Decodes a complete, already-delimited buffer into a +/// [`DataOrderPacket`], resolving its field shapes against `index`. +pub fn decode( + bytes: &[u8], + index: &Index, +) -> Result, DecodeError> { + // The first two bytes are the packet id, in little-endian u16 + if bytes.len() < 2 { + return Err(DecodeError::UnexpectedEof); + } + + // generate the packet id from the first two bytes + let id: [u8; 2] = bytes[0..2] + .try_into() + .map_err(|_| DecodeError::UnexpectedEof)?; + + // generate ADJ id from the little-endian bytes + let adj_id = AdjId(u16::from_le_bytes(id)); + + // Look up the packet definition for this id if not found return an UnknownId error + let packet_def = index.packet(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; + + // Check that the packet is an order or data, as expected by the generic PacketTypeTag + if !Kind::matches(&packet_def.packet_type) { + return Err(DecodeError::WrongPacketType { id: adj_id }); + } + // Get the measurements for this packet + let measurements = packet_def.packet_type.measurements(); + + // Prepare a vector to hold the decoded values and a pointer to track the current position in the byte slice + let mut values = Vec::with_capacity(measurements.len()); + let mut pointer = 2; + + // For each measurement, decode the corresponding bytes from the input slice + for measurement in measurements { + let width = wire::byte_width(&measurement.kind); + let next = pointer + width; + + // Check if there are enough bytes to decode the field + if bytes.len() < next { + return Err(DecodeError::UnexpectedEof); + } + + // Slice the bytes for the current field and decode it according to the measurement's kind + let field = &bytes[pointer..next]; + let value = wire::decode_value(field, &measurement.kind)?; // Append the decoded value to the values vector + values.push(value); + + pointer = next; + } + + Ok(DataOrderPacket::new(adj_id, values)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + Board, BoardId, Data, DataPacket, MacAddress, Measurement, MeasurementKind, NumericKind, + Order, OrderPacket, PacketDef, PacketType, PodData, Value, + }; + use std::collections::HashMap; + use std::net::IpAddr; + + // Auxiliar function to create a sample measurement for testing + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: format!("field_{id}"), + name: format!("Field {id}"), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn sample_packet_def(id: AdjId, packet_type: PacketType) -> PacketDef { + PacketDef { + id, + board: BoardId(1), + name: "TestPacket".into(), + packet_type, + } + } + + fn make_test_index(defs: Vec) -> Index { + let packets: HashMap = defs.into_iter().map(|p| (p.id, p)).collect(); + let board = Board { + id: BoardId(1), + name: "TestBoard".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets, + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + Index::new(&pod_data, None) + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_success_little_endian_and_positional_fields() { + let order_id = AdjId(1001); + let packet_def = sample_packet_def( + order_id, + PacketType::Order(vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let packet = OrderPacket::new(order_id, vec![Value::Number(300.0), Value::Number(1.0)]); + + let Ok(encoded) = encode(&packet, &index) else { + unreachable!("encoding a well-formed order should succeed"); + }; + + // 1001 in little-endian u16: [0xE9, 0x03] + // 300 in little-endian u16: [0x2C, 0x01] + // enum index 1: [0x01] + assert_eq!(encoded, vec![0xE9, 0x03, 0x2C, 0x01, 0x01]); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let packet = OrderPacket::new(AdjId(9999), vec![]); + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_packet_type() { + let data_id = AdjId(2001); + let packet_def = sample_packet_def(data_id, PacketType::Data(vec![])); + + let index = make_test_index(vec![packet_def]); + let packet = OrderPacket::new(data_id, vec![]); + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet type should fail"); + }; + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); + } + + #[test] + fn encode_fails_on_wrong_field_count() { + let order_id = AdjId(1002); + let packet_def = sample_packet_def( + order_id, + PacketType::Order(vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ), + sample_measurement( + 2, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ), + ]), + ); + + let index = make_test_index(vec![packet_def]); + let packet = OrderPacket::new(order_id, vec![Value::Number(10.0)]); + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding with the wrong field count should fail"); + }; + assert_eq!( + err, + EncodeError::WrongFieldCount { + expected: 2, + got: 1, + } + ); + } + + // --- DECODE TESTS --- + + #[test] + fn decode_success_little_endian_and_values() { + let data_id = AdjId(3001); + let packet_def = sample_packet_def( + data_id, + PacketType::Data(vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + // 3001 in little-endian u16: [0xB9, 0x0B] + // 500 in little-endian u16: [0xF4, 0x01] + // enum index 0: [0x00] + let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; + + let Ok(decoded) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a well-formed data packet should succeed"); + }; + + assert_eq!(decoded.id, data_id); + assert_eq!( + decoded.values, + vec![Value::Number(500.0), Value::Enum("Off".to_string())] + ); + } + + #[test] + fn decode_fails_when_buffer_is_shorter_than_header() { + let index = make_test_index(vec![]); + let Err(err) = decode::(&[0x01], &index) else { + unreachable!("decoding a buffer shorter than the header should fail"); + }; + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unexpected_eof_in_payload() { + let data_id = AdjId(3002); + let packet_def = sample_packet_def( + data_id, + PacketType::Data(vec![sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U32, + }, + )]), + ); + + let index = make_test_index(vec![packet_def]); + + let raw_bytes = [0xBA, 0x0B, 0x01, 0x02]; + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a truncated payload should fail"); + }; + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let raw_bytes = [0x88, 0x13]; + + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding an unknown id should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); + } + + #[test] + fn decode_fails_on_wrong_packet_type() { + let order_id = AdjId(4001); + let packet_def = sample_packet_def(order_id, PacketType::Order(vec![])); + + let index = make_test_index(vec![packet_def]); + let raw_bytes = [0xA1, 0x0F]; + + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding the wrong packet type should fail"); + }; + assert_eq!(err, DecodeError::WrongPacketType { id: order_id }); + } + + // --- ROUND-TRIP TESTS --- + // + // `encode`/`decode` are generic over `Kind`, so the same `PacketDef` + // works for both directions here — that genericity is the whole + // point of giving `DataOrderPacket` a phantom `Kind` in the first + // place, instead of writing one hardcoded pair of functions per type. + // + // Enum fields are left out of both: `encode_enum` takes an + // already-selected index (`Value::Number`) but `decode_enum` returns + // the option's text (`Value::Enum`) — see `wire.rs` — so an enum + // field's `Value` is never expected to round-trip unchanged, only its + // underlying byte. + + fn numeric_measurements() -> Vec { + vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Numeric { + wire_type: NumericKind::I8, + }, + ), + ] + } + + #[test] + fn an_order_packet_round_trips_through_encode_then_decode() { + let id = AdjId(6001); + let index = make_test_index(vec![sample_packet_def( + id, + PacketType::Order(numeric_measurements()), + )]); + let original = OrderPacket::new(id, vec![Value::Number(1234.0), Value::Number(-5.0)]); + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding this well-formed order should succeed"); + }; + let Ok(decoded) = decode::(&bytes, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.values, original.values); + } + + #[test] + fn a_data_packet_round_trips_through_encode_then_decode() { + let id = AdjId(6002); + let index = make_test_index(vec![sample_packet_def( + id, + PacketType::Data(numeric_measurements()), + )]); + let original = DataPacket::new(id, vec![Value::Number(42.0), Value::Number(-1.0)]); + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding this well-formed data packet should succeed"); + }; + let Ok(decoded) = decode::(&bytes, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.values, original.values); + } +} diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs new file mode 100644 index 000000000..ec20e16e3 --- /dev/null +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -0,0 +1,444 @@ +//! Decodes a `Message` packet: a board's free-text log line. Only ever +//! received, over TCP. Also provides `encode`, purely so tests can build +//! well-formed frames without hand-written byte arrays — nothing in the +//! real backend calls it, since a message never travels backend -> board. +//! +//! Wire structure (whole frame, including the leading id): +//! ```text +//! | byte(s) | 0-1 | 2 | 3 | 4 | 5 | 6.. | .. | +//! |---------|-----|------|-----|-----|--------|-----------|-----------| +//! | field | id | hora | min | seg | subseg | origin\0 | message\0 | +//! ``` +//! `id` (2 bytes) is normally read by a TCP dispatcher before it even +//! knows to call [`decode`] (it needs the id first, to decide this is a +//! message and not a protection) — when that's the case, pass it as +//! `Some`, and `reader` should start right at byte 2 above. Otherwise +//! pass `None`: `reader` should then start at byte 0, id included, and +//! `decode` reads it itself — all of this frame's byte parsing stays in +//! this file either way, never split out into whatever dispatches here. +//! Unlike [`super::protection`], there's no `resv` padding — the +//! timestamp is followed directly by `origin`, a NUL-terminated string +//! of at most `ORIGIN_MAX` bytes (terminator included), then `message`, +//! at most `MESSAGE_MAX` bytes the same way. Both are variable-length — +//! there's no fixed byte range for either. +//! +//! Unlike [`super::protection`] too, [`MessagePacket`] doesn't carry its +//! own severity — `id` is resolved against `index` to confirm it's +//! actually a declared [`crate::model::PacketType::Message`] (so a +//! `WrongPacketType`/`UnknownId` frame is rejected), but the severity +//! itself is never stored redundantly on the decoded instance; look it up +//! via `index.packet(id)` again if it's needed. + +use crate::model::{AdjId, MessagePacket, PacketType, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index}; +use std::io::Read; + +// `origin`/`message` max lengths, content + NUL terminator included +// (docu_backend/Comunicaciones_Reunion01.qmd, lines 207-237). +const ORIGIN_MAX: usize = 48; +const MESSAGE_MAX: usize = 320; + +/// Decodes a message frame, resolving its id against `index` to confirm +/// it's actually a declared message. +/// +/// `id`: `Some` if the caller already read it (see this module's doc +/// comment); `None` to have `decode` read it itself off `reader`'s first +/// 2 bytes. +pub fn decode( + mut reader: impl Read, + id: Option, + index: &Index, +) -> Result { + let id = match id { + Some(id) => id, + None => super::read_id(&mut reader)?, + }; + + // Look up the packet definition for this id, and ensure it's a + // message packet. If not, the frame is malformed and we can't decode it. + let packet_def = index.packet(id).ok_or(DecodeError::UnknownId(id))?; + + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id }); + }; + + let mut header = [0u8; 4]; + reader.read_exact(&mut header)?; + let timestamp = ShortTimestamp { + hour: header[0], + minute: header[1], + second: header[2], + subsecond: header[3], + }; + + let origin = read_null_terminated_string(&mut reader, ORIGIN_MAX)?; + let message = read_null_terminated_string(&mut reader, MESSAGE_MAX)?; + + Ok(MessagePacket { + id, + timestamp, + origin, + message, + }) +} + +/// Reads bytes up to the first `0x00` (exclusive) or `max` bytes total, +/// whichever comes first — [`DecodeError::UnterminatedString`] if no +/// terminator shows up within `max` bytes. +fn read_null_terminated_string(reader: &mut impl Read, max: usize) -> Result { + // Byte by byte via `read_exact`, not `.bytes()` — that iterator warns + // under clippy for being unbuffered, and `reader` here can be any + // `impl Read`, not necessarily a buffered one. + let mut bytes = Vec::new(); + for _ in 0..max { + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte)?; + if byte[0] == 0 { + return String::from_utf8(bytes).map_err(|e| DecodeError::InvalidUtf8(e.into_bytes())); + } + bytes.push(byte[0]); + } + Err(DecodeError::UnterminatedString { max }) +} + +/// Encodes a [`MessagePacket`] back into the bytes [`decode`] expects — +/// a complete, self-contained frame, leading id included. +/// +/// Nothing in the real backend calls this — per the ADJ spec, message +/// packets only ever travel board -> backend. This exists purely so +/// tests can build well-formed frames without hand-written byte arrays, +/// and to prove `decode` recovers exactly what was encoded. +pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(EncodeError::WrongPacketType { id: packet.id }); + }; + + let mut bytes = packet.id.0.to_le_bytes().to_vec(); + bytes.extend([ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + ]); + + write_null_terminated_string(&mut bytes, &packet.origin, ORIGIN_MAX)?; + write_null_terminated_string(&mut bytes, &packet.message, MESSAGE_MAX)?; + + Ok(bytes) +} + +/// Appends `value` to `bytes` as a NUL-terminated string, erroring if it +/// can't be represented that way: an embedded `\0` would be misread as +/// an early terminator, and the content plus its terminator must still +/// fit within `max` bytes. +fn write_null_terminated_string( + bytes: &mut Vec, + value: &str, + max: usize, +) -> Result<(), EncodeError> { + if value.contains('\0') { + return Err(EncodeError::EmbeddedNulByte); + } + if value.len() >= max { + return Err(EncodeError::StringTooLong { max }); + } + + bytes.extend_from_slice(value.as_bytes()); + bytes.push(0); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Board, BoardId, MacAddress, MessageLevel, PacketDef, PodData}; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + /// Builds an `Index` declaring one message severity: `id` as + /// `MessageLevel::Fault`, exactly as `protocol::Index::new` would + /// synthesize it from a loaded ADJ's `message_levels`. + fn index_with_message(id: AdjId, level: MessageLevel) -> Index { + let message_levels = HashMap::from([(id, level)]); + Index::new(&PodData::default(), Some(&message_levels)) + } + + #[test] + fn decodes_timestamp_and_both_strings() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let mut bytes = vec![12, 30, 5, 0]; // hour, minute, second, subsecond + bytes.extend(b"main.c"); + bytes.push(0); + bytes.extend(b"something went wrong"); + bytes.push(0); + + let Ok(packet) = decode(Cursor::new(bytes), Some(AdjId(11)), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.id, AdjId(11)); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.timestamp.minute, 30); + assert_eq!(packet.timestamp.second, 5); + assert_eq!(packet.origin, "main.c"); + assert_eq!(packet.message, "something went wrong"); + } + + #[test] + fn decode_treats_a_leading_terminator_as_an_empty_string() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(packet) = decode(Cursor::new(bytes), Some(AdjId(12)), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.origin, ""); + assert_eq!(packet.message, ""); + } + + #[test] + fn decode_fails_when_origin_has_no_terminator_within_max() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let mut bytes = vec![0, 0, 0, 0]; + bytes.extend(vec![b'a'; ORIGIN_MAX]); // no terminator anywhere + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(10)), &index) else { + unreachable!("decoding an unterminated origin should fail"); + }; + assert_eq!(err, DecodeError::UnterminatedString { max: ORIGIN_MAX }); + } + + #[test] + fn decode_fails_when_message_has_no_terminator_within_max() { + let index = index_with_message(AdjId(13), MessageLevel::Panic); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0); // origin: empty, terminated + bytes.extend(vec![b'a'; MESSAGE_MAX]); // message: no terminator + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(13)), &index) else { + unreachable!("decoding an unterminated message should fail"); + }; + assert_eq!(err, DecodeError::UnterminatedString { max: MESSAGE_MAX }); + } + + #[test] + fn decode_fails_on_invalid_utf8() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0x80); // lone continuation byte: not valid UTF-8 on its own + bytes.push(0); + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(12)), &index) else { + unreachable!("decoding invalid UTF-8 should fail"); + }; + assert_eq!(err, DecodeError::InvalidUtf8(vec![0x80])); + } + + #[test] + fn decode_fails_on_truncated_header() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let Err(err) = decode(Cursor::new(vec![0, 0]), Some(AdjId(12)), &index) else { + unreachable!("decoding a truncated header should fail"); + }; + assert!(matches!(err, DecodeError::Io(_))); + } + + #[test] + fn decode_reads_the_id_itself_when_not_given_one() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let mut bytes = AdjId(11).0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0]); // hour, minute, second, subsecond + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(packet) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.id, AdjId(11)); + } + + #[test] + fn decode_fails_on_an_unknown_id() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let bytes = 9999u16.to_le_bytes().to_vec(); + + let Err(err) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding an unknown message id should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn decode_fails_when_the_id_resolves_to_a_non_message_packet() { + let data_id = AdjId(2000); + let packet = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let bytes = data_id.0.to_le_bytes().to_vec(); + let Err(err) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding an id that resolves to a Data packet should fail"); + }; + assert_eq!(err, DecodeError::WrongPacketType { id: data_id }); + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_then_decode_round_trips_timestamp_and_strings() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let original = MessagePacket { + id: AdjId(10), + timestamp: ShortTimestamp { + hour: 23, + minute: 59, + second: 58, + subsecond: 200, + }, + origin: "board/src/main.c".to_string(), + message: "overcurrent detected".to_string(), + }; + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding a well-formed message packet should succeed"); + }; + // `encode` writes the id too, so `decode` can read it back itself — + // proves the full frame round-trips, not just the body. + let Ok(decoded) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded, original); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let packet = MessagePacket { + id: AdjId(9999), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: String::new(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_packet_type() { + let data_id = AdjId(2000); + let packet_def = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let packet = MessagePacket { + id: data_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: String::new(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding into a non-message packet's id should fail"); + }; + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); + } + + #[test] + fn encode_rejects_a_too_long_origin() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let packet = MessagePacket { + id: AdjId(12), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: "a".repeat(ORIGIN_MAX), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an origin with no room for its terminator should fail"); + }; + assert_eq!(err, EncodeError::StringTooLong { max: ORIGIN_MAX }); + + let fits = MessagePacket { + origin: "a".repeat(ORIGIN_MAX - 1), + ..packet + }; + assert!(encode(&fits, &index).is_ok()); + } + + #[test] + fn encode_rejects_an_embedded_nul_byte() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let packet = MessagePacket { + id: AdjId(12), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: "ab\0cd".to_string(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding a string with an embedded NUL byte should fail"); + }; + assert_eq!(err, EncodeError::EmbeddedNulByte); + } +} diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs new file mode 100644 index 000000000..d3a4e1e4e --- /dev/null +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -0,0 +1,463 @@ +//! Decodes a `Protection` packet: a board reporting one of its declared +//! protections triggered. Only ever received, over TCP. Also provides +//! `encode`, purely so tests can build well-formed frames without +//! hand-written byte arrays — nothing in the real backend calls it, since +//! a protection never travels backend -> board. +//! +//! Wire structure (whole frame, including the leading id): +//! ```text +//! | byte(s) | 0-1 | 2 | 3 | 4 | 5 | 6-7 | 8.. | +//! |---------|-----|------|-----|-----|--------|------|--------------| +//! | field | id | hora | min | seg | subseg | resv | value | +//! ``` +//! `id` (2 bytes) is normally read by a TCP dispatcher before it even +//! knows to call [`decode`] (it needs the id first, to decide this is a +//! protection and not a message) — when that's the case, pass it as +//! `Some`, and `reader` should start right at byte 2 above. Otherwise +//! pass `None`: `reader` should then start at byte 0, id included, and +//! `decode` reads it itself — all of this frame's byte parsing stays in +//! this file either way, never split out into whatever dispatches here. +//! `resv` (2 bytes) only exists to align `value` to an 8-byte offset; +//! it's read and discarded, never stored. `value`'s width is whatever +//! [`crate::protocol::wire::byte_width`] says for the watched +//! measurement's kind — not fixed, unlike every other field here. + +use crate::model::{AdjId, PacketType, ProtectionPacket, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; +use std::io::Read; + +/// Decodes a protection frame, resolving the watched measurement's wire +/// type against `index`. +/// +/// `compound_id`: `Some` if the caller already read it (see this +/// module's doc comment); `None` to have `decode` read it itself off +/// `reader`'s first 2 bytes. +pub fn decode( + mut reader: impl Read, + compound_id: Option, + index: &Index, +) -> Result { + let compound_id = match compound_id { + Some(id) => id, + None => super::read_id(&mut reader)?, + }; + + // Look up the packet definition for this compound id, and ensure it's a + // protection packet. If not, the frame is malformed and we can't decode it. + let packet_def = index + .packet(compound_id) + .ok_or(DecodeError::UnknownId(compound_id))?; + + let PacketType::Protection(measurement) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id: compound_id }); + }; + + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + // padding that only exist to align `value` to an 8-byte offset — read + // and discarded, there's nowhere to put them in `ProtectionPacket`. + let mut header = [0u8; 6]; + reader.read_exact(&mut header)?; + let timestamp = ShortTimestamp { + hour: header[0], + minute: header[1], + second: header[2], + subsecond: header[3], + }; + + // Read the `value` bytes, whose length is determined by the measurement's wire type, and decode them into a `Value`. + let mut value_bytes = vec![0u8; wire::byte_width(&measurement.kind)]; + reader.read_exact(&mut value_bytes)?; + let value = wire::decode_value(&value_bytes, &measurement.kind)?; + + Ok(ProtectionPacket { + id: compound_id, + timestamp, + value, + }) +} + +/// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects +/// — a complete, self-contained frame, leading id included. +/// +/// Nothing in the real backend calls this — per the ADJ spec, protection +/// packets only ever travel board -> backend. This exists purely so +/// tests can build well-formed frames without hand-written byte arrays, +/// and to prove `decode` recovers exactly what was encoded. +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketType::Protection(measurement) = &packet_def.packet_type else { + return Err(EncodeError::WrongPacketType { id: packet.id }); + }; + + let mut bytes = packet.id.0.to_le_bytes().to_vec(); + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + // — decode discards these, nothing to round-trip. + bytes.extend([ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + 0, + 0, + ]); + bytes.extend(wire::encode_value(&packet.value, &measurement.kind)?); + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PodData, Value, + }; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: "mock_alias".to_string(), + name: "Mock Measurement".to_string(), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn index_with_protection(measurement: Measurement, position: u16) -> (Index, AdjId) { + let compound_id = AdjId((position << 13) | measurement.id.0); + let packet = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_protection".to_string(), + packet_type: PacketType::Protection(measurement), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + (Index::new(&pod_data, None), compound_id) + } + + #[test] + fn decodes_a_numeric_protections_timestamp_and_value() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let mut bytes = vec![12, 30, 5, 0, 0, 0]; // hora, min, seg, subseg, resv(2) + bytes.extend(53.2f32.to_le_bytes()); + let reader = Cursor::new(bytes); + + let Ok(packet) = decode(reader, Some(compound_id), &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + assert_eq!(packet.id.measurement_id(), AdjId(100)); + assert_eq!(packet.id.protection_position(), 1); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.timestamp.minute, 30); + assert_eq!(packet.timestamp.second, 5); + assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); + } + + #[test] + fn decode_reads_the_id_itself_when_not_given_one() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + // Full frame, id included — nobody's pre-read it for us. + let mut bytes = compound_id.0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0, 0, 0]); // hora, min, seg, subseg, resv(2) + bytes.extend(53.2f32.to_le_bytes()); + + let Ok(packet) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + assert_eq!(packet.id, compound_id); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); + } + + #[test] + fn unknown_compound_id_is_an_error() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, _) = index_with_protection(measurement, 1); + let unknown_id = AdjId((2 << 13) | 100); + + assert!(matches!( + decode(Cursor::new(vec![]), Some(unknown_id), &index), + Err(DecodeError::UnknownId(id)) if id == unknown_id + )); + } + + #[test] + fn a_non_protection_packet_at_that_id_is_the_wrong_packet_type() { + let measurement = sample_measurement( + 200, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let compound_id = AdjId(200); // pos = 0: a normal, non-protection id + let packet = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_data".to_string(), + packet_type: PacketType::Data(vec![measurement]), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + assert!(matches!( + decode(Cursor::new(vec![]), Some(compound_id), &index), + Err(DecodeError::WrongPacketType { id }) if id == compound_id + )); + } + + #[test] + fn truncated_frame_is_unexpected_eof() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + // Header alone, no value bytes at all. + let reader = Cursor::new(vec![0u8; 6]); + + assert!(matches!( + decode(reader, Some(compound_id), &index), + Err(DecodeError::Io(_)) + )); + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_then_decode_round_trips_timestamp_and_value() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let original = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 12, + minute: 30, + second: 5, + subsecond: 0, + }, + value: Value::Number(f64::from(53.2f32)), + }; + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding a well-formed protection packet should succeed"); + }; + // `encode` writes the id too, so `decode` can read it back itself — + // proves the full frame round-trips, not just the body. + let Ok(decoded) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded, original); + } + + #[test] + fn encode_zeroes_the_reserved_bytes() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 1, + minute: 2, + second: 3, + subsecond: 4, + }, + value: Value::Number(7.0), + }; + + let Ok(bytes) = encode(&packet, &index) else { + unreachable!("encoding a well-formed protection packet should succeed"); + }; + + // bytes[0..2] = id, [2..6] = timestamp, [6..8] = resv. + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); + } + + #[test] + fn encode_fails_on_unknown_id() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, _) = index_with_protection(measurement, 1); + let unknown_id = AdjId((2 << 13) | 100); + + let packet = ProtectionPacket { + id: unknown_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(0.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(unknown_id)); + } + + #[test] + fn encode_fails_on_wrong_packet_type() { + let measurement = sample_measurement( + 200, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let compound_id = AdjId(200); // pos = 0: a normal, non-protection id + let packet_def = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_data".to_string(), + packet_type: PacketType::Data(vec![measurement]), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(0.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet type should fail"); + }; + assert_eq!(err, EncodeError::WrongPacketType { id: compound_id }); + } + + #[test] + fn encode_bubbles_up_type_mismatch() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Bool(true), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding a value of the wrong variant should fail"); + }; + assert_eq!(err, EncodeError::TypeMismatch); + } + + #[test] + fn encode_bubbles_up_out_of_range() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(300.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an out-of-range value should fail"); + }; + assert!(matches!(err, EncodeError::OutOfRange { .. })); + } +} diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs new file mode 100644 index 000000000..d4becf3fb --- /dev/null +++ b/backend/crates/core/src/protocol/tcp.rs @@ -0,0 +1,162 @@ +//! Transport-level TCP framing: a board's dedicated connection carries +//! triggered protections and free-text messages from the board, and +//! orders to it. Never a `Data` packet — see the module doc on +//! [`super`]. +//! +//! Both functions here are thin: the actual per-type decoding lives in +//! [`super::packet::protection`]/[`super::packet::message`]/ +//! [`super::packet::data_order`], independent of TCP. This module only adds +//! the transport-level guarantee — [`decode_next`] can only ever return +//! a [`TcpFrame`], never a `DataPacket`. + +use super::{DecodeError, EncodeError, Index, packet}; +use crate::model::{MessagePacket, OrderPacket, ProtectionPacket}; +use std::io::Read; + +/// A decoded TCP frame — everything a board can send to the backend over +/// its dedicated TCP connection. +#[derive(Debug, Clone, PartialEq)] +pub enum TcpFrame { + /// A protection triggered. + Protection(ProtectionPacket), + /// A free-text message. + Message(MessagePacket), +} + +/// Reads and decodes the next complete frame from `reader`, resolving +/// field shapes against `index`. +/// +/// Reads the frame's leading 16-bit id first — the one piece of byte +/// handling this function does itself, since it has no way to decide +/// which of [`super::packet::protection::decode`]/ +/// [`super::packet::message::decode`] to call otherwise (see +/// `packet/README.md`'s "dispatcher's one unavoidable read"). A +/// protection's compound id always has its high 3 bits set to a nonzero +/// position (see [`crate::model::AdjId`]'s own docs on its ranges), so +/// `id.0 >= 8192` (`1 << 13`) is a protection; anything below that hands +/// off to `message::decode` instead — `message::decode` resolves it +/// against `index` itself (`UnknownId`/`WrongPacketType` if it isn't +/// actually a declared message), so there's no separate lookup to do here +/// first. +/// Either call's result is wrapped in the matching [`TcpFrame`] variant. +pub fn decode_next(mut reader: impl Read, index: &Index) -> Result { + let id = packet::read_id(&mut reader)?; + + if id.0 >= 8192 { + let protection = packet::protection::decode(reader, Some(id), index)?; + Ok(TcpFrame::Protection(protection)) + } else { + let message = packet::message::decode(reader, Some(id), index)?; + Ok(TcpFrame::Message(message)) + } +} + +/// Encodes `order` into the bytes to write to the board's connection. +pub fn encode_order(order: &OrderPacket, index: &Index) -> Result, EncodeError> { + packet::data_order::encode(order, index) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, MessageLevel, NumericKind, + PacketDef, PacketType, PodData, ShortTimestamp, Value, + }; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + /// A board with one protection (position 1 on measurement 100, wire + /// type `U8`) and one declared message severity (`id` 10 = `Fault`). + fn sample_index() -> Index { + let measurement = Measurement { + id: AdjId(100), + alias: "temp".to_string(), + name: "Temp".to_string(), + display_units: String::new(), + kind: MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + protections: vec![], + }; + let compound_id = AdjId((1u16 << 13) | measurement.id.0); + let packet_def = PacketDef { + id: compound_id, + board: BoardId(1), + name: "temp_protection".to_string(), + packet_type: PacketType::Protection(measurement), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let message_levels = HashMap::from([(AdjId(10), MessageLevel::Fault)]); + Index::new(&pod_data, Some(&message_levels)) + } + + #[test] + fn decode_next_dispatches_a_protection_frame() { + let index = sample_index(); + let compound_id = AdjId((1u16 << 13) | 100); + + let mut bytes = compound_id.0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0, 0, 0]); // hora, min, seg, subseg, resv(2) + bytes.push(42); // value, U8 + + let Ok(frame) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + let TcpFrame::Protection(protection) = frame else { + unreachable!("a non-zero protection_position should dispatch to Protection"); + }; + assert_eq!(protection.id, compound_id); + assert_eq!(protection.value, Value::Number(42.0)); + } + + #[test] + fn decode_next_dispatches_a_message_frame() { + let index = sample_index(); + + let mut bytes = AdjId(10).0.to_le_bytes().to_vec(); + bytes.extend([1, 2, 3, 4]); // hora, min, seg, subseg + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(frame) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + let TcpFrame::Message(message) = frame else { + unreachable!("a zero protection_position should dispatch to Message"); + }; + assert_eq!(message.id, AdjId(10)); + assert_eq!( + message.timestamp, + ShortTimestamp { + hour: 1, + minute: 2, + second: 3, + subsecond: 4, + } + ); + } + + #[test] + fn decode_next_propagates_an_unknown_message_id() { + let index = sample_index(); + let bytes = 9999u16.to_le_bytes().to_vec(); + + let Err(err) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding an id that's neither a protection nor a message should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(9999))); + } +} diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs new file mode 100644 index 000000000..05c44c3c0 --- /dev/null +++ b/backend/crates/core/src/protocol/udp.rs @@ -0,0 +1,17 @@ +//! Transport-level UDP framing: the shared socket carries periodic +//! telemetry (`Data` packets) from any board. Receive-only — the backend +//! never sends anything over this socket. +//! +//! The actual decoding lives in [`super::packet::data_order`], independent of +//! UDP — this module only adds the transport-level guarantee: a UDP +//! datagram is a complete, already-delimited buffer (the OS preserves +//! datagram boundaries even on a socket shared by every board), unlike +//! [`super::tcp::decode_next`]'s stream. + +use super::{DecodeError, Index, packet}; +use crate::model::DataPacket; + +/// Decodes a single UDP datagram's bytes into a [`DataPacket`]. +pub fn decode_datagram(bytes: &[u8], index: &Index) -> Result { + packet::data_order::decode(bytes, index) +} diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs new file mode 100644 index 000000000..9ebe6ab8b --- /dev/null +++ b/backend/crates/core/src/protocol/wire.rs @@ -0,0 +1,414 @@ +//! Byte <-> [`Value`] codec for a single measurement, given its declared +//! [`MeasurementKind`]. The one piece of `protocol` with no framing or id +//! lookup — just "this type, these bytes, that value" and back. `tcp`/ +//! `udp` build their packet-level decoding on top of this. +//! +//! Every wire width here is fixed by the measurement's own `kind` (a +//! `NumericKind` always has a known byte width; booleans and enums are +//! fixed-width too), so decoding never needs to report back how many +//! bytes it consumed — [`byte_width`] derives it straight from the +//! `MeasurementKind`, for a caller that needs to advance through a buffer +//! or know how much to read off a stream before decoding. +//! +//! [`decode_value`]/[`encode_value`] are thin dispatchers: they only pick +//! which per-kind function to call. All the actual work lives in its own +//! public function (`decode_numeric`, `decode_boolean`, `decode_enum`...), +//! each independently implementable and testable. + +use super::{DecodeError, EncodeError}; +use crate::model::{MeasurementKind, NumericKind, Value}; + +/// Decodes a single value from the front of `bytes`, according to `kind`. +pub fn decode_value(bytes: &[u8], kind: &MeasurementKind) -> Result { + match kind { + MeasurementKind::Numeric { wire_type } => decode_numeric(bytes, *wire_type), + MeasurementKind::Boolean => decode_boolean(bytes), + MeasurementKind::Enum { options } => decode_enum(bytes, options), + } +} + +/// Encodes a single value according to `kind`. +pub fn encode_value(value: &Value, kind: &MeasurementKind) -> Result, EncodeError> { + match kind { + MeasurementKind::Numeric { wire_type } => encode_numeric(value, *wire_type), + MeasurementKind::Boolean => encode_boolean(value), + MeasurementKind::Enum { options } => encode_enum(value, options), + } +} + +/// How many bytes `kind` takes on the wire. +pub fn byte_width(kind: &MeasurementKind) -> usize { + match kind { + MeasurementKind::Numeric { wire_type } => match wire_type { + NumericKind::U8 | NumericKind::I8 => 1, + NumericKind::U16 | NumericKind::I16 => 2, + NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, + NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, + }, + MeasurementKind::Boolean => 1, + MeasurementKind::Enum { .. } => 1, + } +} + +/// Decodes a numeric value of the given wire type, little-endian. +pub fn decode_numeric(bytes: &[u8], wire_type: NumericKind) -> Result { + fn take(bytes: &[u8]) -> Result<[u8; N], DecodeError> { + bytes + .get(..N) + .and_then(|slice| slice.try_into().ok()) + .ok_or(DecodeError::UnexpectedEof) + } + + let number: f64 = match wire_type { + NumericKind::U8 => f64::from(u8::from_le_bytes(take::<1>(bytes)?)), + NumericKind::U16 => f64::from(u16::from_le_bytes(take::<2>(bytes)?)), + NumericKind::U32 => f64::from(u32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::U64 => u64::from_le_bytes(take::<8>(bytes)?) as f64, + NumericKind::I8 => f64::from(i8::from_le_bytes(take::<1>(bytes)?)), + NumericKind::I16 => f64::from(i16::from_le_bytes(take::<2>(bytes)?)), + NumericKind::I32 => f64::from(i32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::I64 => i64::from_le_bytes(take::<8>(bytes)?) as f64, + NumericKind::F32 => f64::from(f32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::F64 => f64::from_le_bytes(take::<8>(bytes)?), + }; + + Ok(Value::Number(number)) +} + +/// Encodes a numeric value as the given wire type, little-endian. +/// +/// Errors with [`EncodeError::OutOfRange`] if `value` doesn't fit the +/// wire type's representable range (this also rejects NaN, which compares +/// false to every bound). +pub fn encode_numeric(value: &Value, wire_type: NumericKind) -> Result, EncodeError> { + let Value::Number(number) = value else { + return Err(EncodeError::TypeMismatch); + }; + let number = *number; + + let (min, max) = numeric_range(wire_type); + if !(min..=max).contains(&number) { + return Err(EncodeError::OutOfRange { + value: number, + min, + max, + }); + } + + Ok(match wire_type { + NumericKind::U8 => vec![number as u8], // no need to call `to_le_bytes` for a single byte + NumericKind::U16 => (number as u16).to_le_bytes().to_vec(), + NumericKind::U32 => (number as u32).to_le_bytes().to_vec(), + NumericKind::U64 => (number as u64).to_le_bytes().to_vec(), + NumericKind::I8 => (number as i8).to_le_bytes().to_vec(), + NumericKind::I16 => (number as i16).to_le_bytes().to_vec(), + NumericKind::I32 => (number as i32).to_le_bytes().to_vec(), + NumericKind::I64 => (number as i64).to_le_bytes().to_vec(), + NumericKind::F32 => (number as f32).to_le_bytes().to_vec(), + NumericKind::F64 => number.to_le_bytes().to_vec(), + }) +} + +/// The `[min, max]` range a wire type can represent, as `f64` bounds. +fn numeric_range(wire_type: NumericKind) -> (f64, f64) { + match wire_type { + NumericKind::U8 => (f64::from(u8::MIN), f64::from(u8::MAX)), + NumericKind::U16 => (f64::from(u16::MIN), f64::from(u16::MAX)), + NumericKind::U32 => (f64::from(u32::MIN), f64::from(u32::MAX)), + NumericKind::U64 => (u64::MIN as f64, u64::MAX as f64), + NumericKind::I8 => (f64::from(i8::MIN), f64::from(i8::MAX)), + NumericKind::I16 => (f64::from(i16::MIN), f64::from(i16::MAX)), + NumericKind::I32 => (f64::from(i32::MIN), f64::from(i32::MAX)), + NumericKind::I64 => (i64::MIN as f64, i64::MAX as f64), + NumericKind::F32 => (f64::from(f32::MIN), f64::from(f32::MAX)), + NumericKind::F64 => (f64::MIN, f64::MAX), + } +} + +/// Decodes a boolean value: a single byte, zero for `false`, any other +/// value for `true`. +pub fn decode_boolean(bytes: &[u8]) -> Result { + let &byte = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + Ok(Value::Bool(byte != 0)) +} + +/// Encodes a boolean value as a single byte: `0x00` for `false`, `0x01` +/// for `true`. +pub fn encode_boolean(value: &Value) -> Result, EncodeError> { + let Value::Bool(value) = value else { + return Err(EncodeError::TypeMismatch); + }; + Ok(vec![u8::from(*value)]) +} + +/// Decodes an enum value: a single `uint8` byte, the index into +/// `options`. Returns the option's text, not its index — see +/// [`encode_enum`] for why that's not symmetric. +pub fn decode_enum(bytes: &[u8], options: &[String]) -> Result { + let &index = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + let Some(option) = options.get(usize::from(index)) else { + return Err(DecodeError::InvalidEnumValue { + index: u32::from(index), + options: options.len(), + }); + }; + Ok(Value::Enum(option.clone())) +} + +/// Encodes an enum value as a single `uint8` byte. +/// +/// `value` is the already-selected index (e.g. a frontend dropdown +/// already resolved to its position in `options`), **not** the option's +/// text — unlike [`decode_enum`], which produces the text. Errors with +/// [`EncodeError::OutOfRange`] if the index isn't a whole number or +/// doesn't fall within `options`. +pub fn encode_enum(value: &Value, options: &[String]) -> Result, EncodeError> { + let Value::Number(index) = value else { + return Err(EncodeError::TypeMismatch); + }; + let index = *index; + let max = options.len().saturating_sub(1) as f64; + + let in_range = !options.is_empty() && index.fract() == 0.0 && (0.0..=max).contains(&index); + if !in_range { + return Err(EncodeError::OutOfRange { + value: index, + min: 0.0, + max, + }); + } + + Ok(vec![index as u8]) // no need to call `to_le_bytes` for a single byte +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn byte_width_matches_each_numeric_kinds_wire_size() { + let cases = [ + (NumericKind::U8, 1), + (NumericKind::I8, 1), + (NumericKind::U16, 2), + (NumericKind::I16, 2), + (NumericKind::U32, 4), + (NumericKind::I32, 4), + (NumericKind::F32, 4), + (NumericKind::U64, 8), + (NumericKind::I64, 8), + (NumericKind::F64, 8), + ]; + + for (wire_type, expected) in cases { + assert_eq!( + byte_width(&MeasurementKind::Numeric { wire_type }), + expected + ); + } + } + + #[test] + fn byte_width_of_an_enum_is_always_one() { + assert_eq!( + byte_width(&MeasurementKind::Enum { + options: vec!["Idle".to_string(), "Fault".to_string()] + }), + 1 + ); + } + + #[test] + fn byte_width_of_a_boolean_is_one() { + assert_eq!(byte_width(&MeasurementKind::Boolean), 1); + } + + #[test] + fn decodes_a_zero_byte_as_false() { + let Ok(value) = decode_boolean(&[0x00]) else { + unreachable!("decoding a zero byte should succeed"); + }; + assert_eq!(value, Value::Bool(false)); + } + + #[test] + fn decodes_any_nonzero_byte_as_true() { + let Ok(value) = decode_boolean(&[0xFF]) else { + unreachable!("decoding a nonzero byte should succeed"); + }; + assert_eq!(value, Value::Bool(true)); + } + + #[test] + fn decode_boolean_rejects_empty_input() { + assert!(matches!( + decode_boolean(&[]), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn boolean_round_trips_through_encode_then_decode() { + for value in [true, false] { + let Ok(bytes) = encode_boolean(&Value::Bool(value)) else { + unreachable!("encoding a well-formed boolean should succeed"); + }; + let Ok(decoded) = decode_boolean(&bytes) else { + unreachable!("decoding what we just encoded should succeed"); + }; + assert_eq!(decoded, Value::Bool(value)); + } + } + + #[test] + fn encode_boolean_rejects_the_wrong_value_variant() { + assert!(matches!( + encode_boolean(&Value::Number(1.0)), + Err(EncodeError::TypeMismatch) + )); + } + + #[test] + fn decodes_an_unsigned_integer_little_endian() { + let Ok(value) = decode_numeric(&[0x34, 0x12], NumericKind::U16) else { + unreachable!("decoding a well-formed U16 should succeed"); + }; + assert_eq!(value, Value::Number(0x1234 as f64)); + } + + #[test] + fn decodes_a_signed_integer_little_endian() { + let Ok(value) = decode_numeric(&[0xFF], NumericKind::I8) else { + unreachable!("decoding a well-formed I8 should succeed"); + }; + assert_eq!(value, Value::Number(-1.0)); + } + + #[test] + fn decodes_floats_little_endian() { + let bytes = 1.5f32.to_le_bytes(); + let Ok(value) = decode_numeric(&bytes, NumericKind::F32) else { + unreachable!("decoding a well-formed F32 should succeed"); + }; + assert_eq!(value, Value::Number(1.5)); + } + + #[test] + fn decode_numeric_rejects_short_input() { + assert!(matches!( + decode_numeric(&[0x00], NumericKind::U32), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn numeric_round_trips_through_encode_then_decode() { + let value = Value::Number(1234.0); + let Ok(bytes) = encode_numeric(&value, NumericKind::U16) else { + unreachable!("encoding a well-formed value should succeed"); + }; + let Ok(decoded) = decode_numeric(&bytes, NumericKind::U16) else { + unreachable!("decoding what we just encoded should succeed"); + }; + assert_eq!(decoded, value); + } + + #[test] + fn encode_numeric_rejects_the_wrong_value_variant() { + assert!(matches!( + encode_numeric(&Value::Bool(true), NumericKind::U8), + Err(EncodeError::TypeMismatch) + )); + } + + #[test] + fn encode_numeric_rejects_out_of_range_values() { + assert!(matches!( + encode_numeric(&Value::Number(300.0), NumericKind::U8), + Err(EncodeError::OutOfRange { .. }) + )); + assert!(matches!( + encode_numeric(&Value::Number(-1.0), NumericKind::U8), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_numeric_accepts_the_exact_boundary_values() { + assert!(encode_numeric(&Value::Number(0.0), NumericKind::U8).is_ok()); + assert!(encode_numeric(&Value::Number(255.0), NumericKind::U8).is_ok()); + } + + #[test] + fn encode_numeric_rejects_nan() { + assert!(matches!( + encode_numeric(&Value::Number(f64::NAN), NumericKind::F32), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn decodes_an_enum_value_by_index_into_its_text() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + let Ok(value) = decode_enum(&[1], &options) else { + unreachable!("decoding a valid enum index should succeed"); + }; + assert_eq!(value, Value::Enum("Fault".to_string())); + } + + #[test] + fn decode_enum_rejects_an_out_of_range_index() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + decode_enum(&[5], &options), + Err(DecodeError::InvalidEnumValue { + index: 5, + options: 1 + }) + )); + } + + #[test] + fn decode_enum_rejects_empty_input() { + assert!(matches!( + decode_enum(&[], &[]), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn encodes_an_enum_value_from_its_already_selected_index() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + let Ok(bytes) = encode_enum(&Value::Number(1.0), &options) else { + unreachable!("encoding a valid index should succeed"); + }; + assert_eq!(bytes, vec![1]); + } + + #[test] + fn encode_enum_rejects_an_out_of_range_index() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + encode_enum(&Value::Number(1.0), &options), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_enum_rejects_a_fractional_index() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + assert!(matches!( + encode_enum(&Value::Number(0.5), &options), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_enum_rejects_the_wrong_value_variant() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + encode_enum(&Value::Enum("Idle".to_string()), &options), + Err(EncodeError::TypeMismatch) + )); + } +}