From 9451b98f2974a5e849198c94328bbd594d8afae7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 14:19:46 +0200 Subject: [PATCH 01/34] feat(backend, prtotocol): update protocol --- backend/crates/core/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs index e1eda88f6..009b6d295 100644 --- a/backend/crates/core/src/lib.rs +++ b/backend/crates/core/src/lib.rs @@ -10,3 +10,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; From dba85ba46e2d189f7d47dd381721d8cbe166a42d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 14:20:19 +0200 Subject: [PATCH 02/34] feat(backend, protocol): main structure --- backend/crates/core/src/protocol.rs | 24 ++ backend/crates/core/src/protocol/error.rs | 92 +++++ backend/crates/core/src/protocol/index.rs | 78 +++++ backend/crates/core/src/protocol/packet.rs | 11 + .../crates/core/src/protocol/packet/data.rs | 19 ++ .../core/src/protocol/packet/message.rs | 16 + .../crates/core/src/protocol/packet/order.rs | 18 + .../core/src/protocol/packet/protection.rs | 26 ++ backend/crates/core/src/protocol/tcp.rs | 49 +++ backend/crates/core/src/protocol/udp.rs | 17 + backend/crates/core/src/protocol/wire.rs | 316 ++++++++++++++++++ 11 files changed, 666 insertions(+) create mode 100644 backend/crates/core/src/protocol.rs create mode 100644 backend/crates/core/src/protocol/error.rs create mode 100644 backend/crates/core/src/protocol/index.rs create mode 100644 backend/crates/core/src/protocol/packet.rs create mode 100644 backend/crates/core/src/protocol/packet/data.rs create mode 100644 backend/crates/core/src/protocol/packet/message.rs create mode 100644 backend/crates/core/src/protocol/packet/order.rs create mode 100644 backend/crates/core/src/protocol/packet/protection.rs create mode 100644 backend/crates/core/src/protocol/tcp.rs create mode 100644 backend/crates/core/src/protocol/udp.rs create mode 100644 backend/crates/core/src/protocol/wire.rs diff --git a/backend/crates/core/src/protocol.rs b/backend/crates/core/src/protocol.rs new file mode 100644 index 000000000..4ee7cfdd1 --- /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-kind 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/error.rs b/backend/crates/core/src/protocol/error.rs new file mode 100644 index 000000000..64660e696 --- /dev/null +++ b/backend/crates/core/src/protocol/error.rs @@ -0,0 +1,92 @@ +//! 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 looked-up packet exists, but isn't the kind this function expects + /// (e.g. `packet::data::decode` found an id that resolves to an + /// `Order`). + #[error("packet {id:?} isn't the expected kind")] + WrongKind { + /// The packet whose kind didn't match. + id: AdjId, + }, +} + +/// An error encoding a packet into bytes. +#[derive(Debug, thiserror::Error)] +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 kind this function expects + /// (e.g. `packet::order::encode` found an id that resolves to a + /// `Data` packet). + #[error("packet {id:?} isn't the expected kind")] + WrongKind { + /// The packet whose kind didn't match. + id: AdjId, + }, +} diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs new file mode 100644 index 000000000..c23397bf8 --- /dev/null +++ b/backend/crates/core/src/protocol/index.rs @@ -0,0 +1,78 @@ +//! The `id -> PacketDef` lookup `tcp`/`udp` decode against. + +use crate::model::{AdjId, PacketDef, PodData}; +use std::collections::HashMap; + +/// A lookup table from a packet's [`AdjId`] to its declaration, built once +/// from a loaded ADJ's [`PodData`]. +/// +/// 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`. + pub fn new(pod_data: &PodData) -> Self { + let by_id = pod_data + .boards + .values() + .flat_map(|board| board.packets.values()) + .map(|packet| (packet.id, packet.clone())) + .collect(); + Self { by_id } + } + + /// Looks up a packet's declaration by id. + 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, PacketKind}; + use std::net::IpAddr; + + fn sample_pod_data() -> PodData { + let packet = PacketDef { + id: AdjId(1000), + board: BoardId(1), + name: "bcu_data".to_string(), + kind: PacketKind::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()); + 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()); + assert!(index.packet(AdjId(9999)).is_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..b1c19e246 --- /dev/null +++ b/backend/crates/core/src/protocol/packet.rs @@ -0,0 +1,11 @@ +//! Encode/decode for one specific kind of packet, independent of which +//! transport carries it — that mapping lives in [`super::tcp`]/ +//! [`super::udp`], which are thin wrappers around these. Each kind only +//! implements whichever direction it actually needs: a `Data` packet is +//! only ever received (over UDP), an `Order` only ever sent (over TCP), +//! so there's no `encode` for the former or `decode` for the latter. + +pub mod data; +pub mod message; +pub mod order; +pub mod protection; diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs new file mode 100644 index 000000000..bc613cc8c --- /dev/null +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -0,0 +1,19 @@ +//! Decodes a `Data` packet: a board's periodic telemetry. Only ever +//! received, over UDP — a board never receives one back. + +use crate::model::DataPacket; +use crate::protocol::{DecodeError, Index}; + +/// Decodes a complete, already-delimited buffer into a [`DataPacket`], +/// resolving its field shapes against `index`. +pub fn decode(bytes: &[u8], index: &Index) -> Result { + let _ = (bytes, index); + todo!( + "read the leading 16-bit id; look up its PacketDef in `index` \ + (UnknownId if absent); match its `kind` for \ + PacketKind::Data(measurements) (WrongKind if it's some other \ + kind); decode each measurement's value positionally via \ + wire::decode_value, advancing through `bytes` using each \ + measurement's own wire width" + ) +} 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..88f70bd9c --- /dev/null +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -0,0 +1,16 @@ +//! Decodes a `Message` packet: a board's free-text log line. Only ever +//! received, over TCP. + +use crate::model::{MessageLevel, MessagePacket}; +use crate::protocol::DecodeError; +use std::io::Read; + +/// Decodes a message frame's remaining bytes, given the frame's +/// already-resolved severity level (`tcp::decode_next`'s job). +pub fn decode(reader: impl Read, level: MessageLevel) -> Result { + let _ = (reader, level); + todo!( + "read the timestamp, then the null-terminated `origin` (max 48 \ + bytes) and `message` (max 320 bytes) strings" + ) +} diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs new file mode 100644 index 000000000..4eb11a6cf --- /dev/null +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -0,0 +1,18 @@ +//! Encodes an `Order` packet: a command sent to a board. Only ever sent, +//! over TCP — the backend never receives one back. + +use crate::model::OrderPacket; +use crate::protocol::{EncodeError, Index}; + +/// Encodes `order` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { + let _ = (order, index); + todo!( + "look up order.id in `index` to get its PacketDef (UnknownId if \ + absent); match its `kind` for PacketKind::Order(measurements) \ + (WrongKind if it's some other kind); error with WrongFieldCount \ + if order.fields.len() doesn't match measurements.len(); encode \ + the id then each field positionally via wire::encode_value" + ) +} 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..e00a4acec --- /dev/null +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -0,0 +1,26 @@ +//! Decodes a `Protection` packet: a board reporting one of its declared +//! protections triggered. Only ever received, over TCP. + +use crate::model::{AdjId, ProtectionPacket}; +use crate::protocol::{DecodeError, Index}; +use std::io::Read; + +/// Decodes a protection frame's remaining bytes, given the frame's +/// already-read compound id (`tcp::decode_next`'s job), resolving the +/// watched measurement's wire type against `index`. +pub fn decode( + reader: impl Read, + compound_id: AdjId, + index: &Index, +) -> Result { + let _ = (reader, compound_id, index); + todo!( + "look up compound_id's PacketDef in `index` (already keyed by \ + this exact compound id, see adj::raw::loader); match its `kind` \ + for PacketKind::Protection(measurement) (WrongKind if it's some \ + other kind) to get the watched measurement's wire type; \ + separately split compound_id's bits into (measurement, position) \ + to fill ProtectionPacket's `measurement`/`protection` fields; \ + read the timestamp, then the value via wire::decode_value" + ) +} diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs new file mode 100644 index 000000000..af3882253 --- /dev/null +++ b/backend/crates/core/src/protocol/tcp.rs @@ -0,0 +1,49 @@ +//! 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-kind decoding lives in +//! [`super::packet::protection`]/[`super::packet::message`]/ +//! [`super::packet::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, then decides which of +/// [`super::packet::protection::decode`] (the id's high 3 bits are a +/// protection's position, non-zero) or [`super::packet::message::decode`] +/// (the id matches one of `general_info.json`'s `message_ids`) to hand +/// the rest of the frame to, wrapping the result in the matching +/// [`TcpFrame`] variant. +pub fn decode_next(reader: impl Read, index: &Index) -> Result { + let _ = (reader, index); + todo!( + "read the leading 16-bit id; if its top 3 bits are non-zero, call \ + packet::protection::decode; otherwise resolve it against \ + general_info.json's message_ids (not yet reachable from `Index` \ + — needs AdjInfo too) and call packet::message::decode; wrap \ + either result in the matching TcpFrame variant" + ) +} + +/// Encodes `order` into the bytes to write to the board's connection. +pub fn encode_order(order: &OrderPacket, index: &Index) -> Result, EncodeError> { + packet::order::encode(order, index) +} diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs new file mode 100644 index 000000000..4eb0d6723 --- /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`], 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::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..6dc151e04 --- /dev/null +++ b/backend/crates/core/src/protocol/wire.rs @@ -0,0 +1,316 @@ +//! 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 — the caller already has the `MeasurementKind` (from +//! the `Measurement` it's decoding) and can derive the width from it +//! directly if it needs to advance through a buffer. +//! +//! [`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), + } +} + +/// 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. +/// +/// Wire width isn't settled yet — not specified anywhere in the ADJv3 +/// spec read so far. Confirm against firmware before implementing. +pub fn decode_boolean(bytes: &[u8]) -> Result { + let _ = bytes; + todo!("confirm the wire width firmware uses for a boolean measurement") +} + +/// Encodes a boolean value. +pub fn encode_boolean(value: &Value) -> Result, EncodeError> { + let _ = value; + todo!("confirm the wire width firmware uses for a boolean measurement") +} + +/// 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 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) + )); + } +} From bb6f5a51bf2dd824df65e0d2e6537f931ef2a50a Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Sat, 10 Oct 2026 21:03:08 +0200 Subject: [PATCH 03/34] Feat(backend, protocol): Implemented Order Encoding. --- .../crates/core/src/protocol/packet/order.rs | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 4eb11a6cf..3b5eac356 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -1,18 +1,48 @@ //! Encodes an `Order` packet: a command sent to a board. Only ever sent, //! over TCP — the backend never receives one back. -use crate::model::OrderPacket; -use crate::protocol::{EncodeError, Index}; +use crate::model::{OrderPacket, PacketKind}; +use crate::protocol::{wire, EncodeError, Index}; /// Encodes `order` into the bytes to write to a board's connection, /// resolving its field shapes against `index`. pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { - let _ = (order, index); - todo!( - "look up order.id in `index` to get its PacketDef (UnknownId if \ - absent); match its `kind` for PacketKind::Order(measurements) \ - (WrongKind if it's some other kind); error with WrongFieldCount \ - if order.fields.len() doesn't match measurements.len(); encode \ - the id then each field positionally via wire::encode_value" - ) + + //Unwrap PacketDef for kind + let packet_def = index + .packet(order.id) + .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + + //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements + let measurements = match &packet_def.kind{ + PacketKind::Order(measurements) => measurements, + _ => return Err(EncodeError::WrongKind {id:order.id}) + }; + + //Check if length of list of fields matches number of measurements. + if order.fields.len() != measurements.len(){ + return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.fields.len()}) + } + + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&order.id.0.to_be_bytes());//Header prefix of byte stream to 16 bit big-endian + + //Encoding to bytes + for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement + let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind + bytes.extend(field_bytes);// Adds to byte stream + } + + Ok(bytes) // Return bytes } + + + +// todo!( +// "look up order.id in `index` to get its PacketDef (UnknownId if \ +// absent); match its `kind` for PacketKind::Order(measurements) \ +// (WrongKind if it's some other kind); error with WrongFieldCount \ +// if order.fields.len() doesn't match measurements.len(); encode \ +// the id then each field positionally via wire::encode_value" +// ) \ No newline at end of file From 9a097fd40dfc5ee206905c2c292989643743b3cf Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Sat, 10 Oct 2026 23:02:35 +0200 Subject: [PATCH 04/34] Feat(backend, protocol): Changed byte encoding in Order.rs to Little-Endian instead of Big-Endian. --- backend/crates/core/src/protocol/packet/order.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 3b5eac356..16fa74c02 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -26,7 +26,7 @@ pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError let mut bytes = Vec::new(); - bytes.extend_from_slice(&order.id.0.to_be_bytes());//Header prefix of byte stream to 16 bit big-endian + bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian //Encoding to bytes for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement From 553e3dd7f940642af4062f82b5f0cc948facf224 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 11 Oct 2026 23:47:17 +0200 Subject: [PATCH 05/34] feat(backend, protocol): function that returns the number of bytes that require each numeric kind --- backend/crates/core/src/protocol/wire.rs | 53 ++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs index 6dc151e04..8809c7126 100644 --- a/backend/crates/core/src/protocol/wire.rs +++ b/backend/crates/core/src/protocol/wire.rs @@ -6,9 +6,9 @@ //! 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 — the caller already has the `MeasurementKind` (from -//! the `Measurement` it's decoding) and can derive the width from it -//! directly if it needs to advance through a buffer. +//! 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 @@ -36,6 +36,23 @@ pub fn encode_value(value: &Value, kind: &MeasurementKind) -> Result, En } } +/// 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, + }, + // Not yet confirmed against firmware — see `decode_boolean`. + MeasurementKind::Boolean => { + todo!("confirm the wire width firmware uses for a boolean measurement") + } + 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> { @@ -170,6 +187,36 @@ pub fn encode_enum(value: &Value, options: &[String]) -> Result, EncodeE 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 decodes_an_unsigned_integer_little_endian() { let Ok(value) = decode_numeric(&[0x34, 0x12], NumericKind::U16) else { From 0edcd3ae5ac5cf28d807392279679560ca100d4d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 00:18:37 +0200 Subject: [PATCH 06/34] fix(backend, adj): add methods to ADJID to separate get messurement Id from protection id --- backend/crates/core/src/adj/raw/loader.rs | 9 ++++--- backend/crates/core/src/model/README.md | 10 ++++--- backend/crates/core/src/model/id.rs | 33 +++++++++++++++++++++++ backend/crates/core/src/model/packet.rs | 26 +++++++++--------- 4 files changed, 58 insertions(+), 20 deletions(-) diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 6d6f4be3c..6f252d48c 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -71,9 +71,12 @@ pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result` 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` @@ -138,10 +142,8 @@ Vector list of values (`Vec`) to set for each field of the order. ### 4.4 `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` diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index d6a6845a5..a8278993d 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -12,6 +12,24 @@ #[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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BoardId(pub u16); @@ -74,4 +92,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..7cba9ae47 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -64,16 +64,16 @@ pub struct OrderPacket { /// 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 @@ -164,10 +164,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 +177,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] From d5e7e4b5c72a0be5a7daa802b8ced26328985dd9 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:36:08 +0200 Subject: [PATCH 07/34] Feat(backend, protocol): Removed todos and left as comments. Implemented Decoding of data. --- .../crates/core/src/protocol/packet/data.rs | 72 ++++++++++++++++--- .../crates/core/src/protocol/packet/order.rs | 4 +- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index bc613cc8c..5f2fa1d86 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,19 +1,69 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::DataPacket; -use crate::protocol::{DecodeError, Index}; +use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. pub fn decode(bytes: &[u8], index: &Index) -> Result { - let _ = (bytes, index); - todo!( - "read the leading 16-bit id; look up its PacketDef in `index` \ - (UnknownId if absent); match its `kind` for \ - PacketKind::Data(measurements) (WrongKind if it's some other \ - kind); decode each measurement's value positionally via \ - wire::decode_value, advancing through `bytes` using each \ - measurement's own wire width" - ) + if bytes.len() < 2 { + // If less than 2 bytes return error + return Err(DecodeError::UnexpectedEof); + } + + let id: [u8; 2] = bytes[0..2] // Recieve first two bytes + .try_into() // Tries to make it into desired type + .map_err(|_| DecodeError::UnexpectedEof)?; // If fails, return error + + let adj_id = AdjId(u16::from_le_bytes(id)); // Parse into u16 and make it AdjId type + + let packet_def = index.get(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; // Get packet definition from index + + let measurements = match &packet_def.kind { + // Get measurements from packet definition + PacketKind::Data(measurements) => measurements, // If packet kind is data, return measurements + _ => return Err(DecodeError::WrongKind { id: adj_id }), // If not, return Error + }; + + let mut values = Vec::with_capacity(measurements.len()); // Create vectir list of values same size as measurements + + let mut pointer = 2; // Pointer to after ID in bytes + + for measurement in measurements { + let width = match &measurement.kind { // Set width of measurement according to the type. + 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 + }; + + let next = pointer + width; + + if bytes.len() < next { + // Check if there is enough bytes left + return Err(DecodeError::UnexpectedEof); + } + + let field = &bytes[pointer..next]; // Size of field (bytes between pointer and next position) + let value = wire::decode_value(field, &measurement.kind)?; // Decode value from field size and measurement width + values.push(value); //Add to list of values + + pointer = next; // Next position + } + + Ok(DataPacket { id: adj_id, values }) // Return data packet } + +// +// "read the leading 16-bit id; look up its PacketDef in `index` \ +// (UnknownId if absent); match its `kind` for \ +// PacketKind::Data(measurements) (WrongKind if it's some other \ +// kind); decode each measurement's value positionally via \ +// wire::decode_value, advancing through `bytes` using each \ +// measurement's own wire width" +// diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 16fa74c02..1f6c98109 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -39,10 +39,10 @@ pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError -// todo!( +// // "look up order.id in `index` to get its PacketDef (UnknownId if \ // absent); match its `kind` for PacketKind::Order(measurements) \ // (WrongKind if it's some other kind); error with WrongFieldCount \ // if order.fields.len() doesn't match measurements.len(); encode \ // the id then each field positionally via wire::encode_value" -// ) \ No newline at end of file +// \ No newline at end of file From e8180bde43d6173c8b84f8c26ec3875e0b0c1771 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:50:23 +0200 Subject: [PATCH 08/34] Feat(backend, protocol): used new function to retrieve the width of a measurement in data.rs decoding data function. --- backend/crates/core/src/protocol/packet/data.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index 5f2fa1d86..a649a9db8 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,7 +1,7 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::model::{AdjId, DataPacket, PacketKind}; use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], @@ -31,16 +31,7 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { let mut pointer = 2; // Pointer to after ID in bytes for measurement in measurements { - let width = match &measurement.kind { // Set width of measurement according to the type. - 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 - }; + let width = wire::byte_width(&measurement.kind); let next = pointer + width; From 9670faef7ca239a156e6b858dc707564666df82e Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:36:08 +0200 Subject: [PATCH 09/34] Feat(backend, protocol): Removed todos and left as comments. Implemented Decoding of data. --- .../crates/core/src/protocol/packet/data.rs | 72 ++++++++++++++++--- .../crates/core/src/protocol/packet/order.rs | 4 +- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index bc613cc8c..5f2fa1d86 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,19 +1,69 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::DataPacket; -use crate::protocol::{DecodeError, Index}; +use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. pub fn decode(bytes: &[u8], index: &Index) -> Result { - let _ = (bytes, index); - todo!( - "read the leading 16-bit id; look up its PacketDef in `index` \ - (UnknownId if absent); match its `kind` for \ - PacketKind::Data(measurements) (WrongKind if it's some other \ - kind); decode each measurement's value positionally via \ - wire::decode_value, advancing through `bytes` using each \ - measurement's own wire width" - ) + if bytes.len() < 2 { + // If less than 2 bytes return error + return Err(DecodeError::UnexpectedEof); + } + + let id: [u8; 2] = bytes[0..2] // Recieve first two bytes + .try_into() // Tries to make it into desired type + .map_err(|_| DecodeError::UnexpectedEof)?; // If fails, return error + + let adj_id = AdjId(u16::from_le_bytes(id)); // Parse into u16 and make it AdjId type + + let packet_def = index.get(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; // Get packet definition from index + + let measurements = match &packet_def.kind { + // Get measurements from packet definition + PacketKind::Data(measurements) => measurements, // If packet kind is data, return measurements + _ => return Err(DecodeError::WrongKind { id: adj_id }), // If not, return Error + }; + + let mut values = Vec::with_capacity(measurements.len()); // Create vectir list of values same size as measurements + + let mut pointer = 2; // Pointer to after ID in bytes + + for measurement in measurements { + let width = match &measurement.kind { // Set width of measurement according to the type. + 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 + }; + + let next = pointer + width; + + if bytes.len() < next { + // Check if there is enough bytes left + return Err(DecodeError::UnexpectedEof); + } + + let field = &bytes[pointer..next]; // Size of field (bytes between pointer and next position) + let value = wire::decode_value(field, &measurement.kind)?; // Decode value from field size and measurement width + values.push(value); //Add to list of values + + pointer = next; // Next position + } + + Ok(DataPacket { id: adj_id, values }) // Return data packet } + +// +// "read the leading 16-bit id; look up its PacketDef in `index` \ +// (UnknownId if absent); match its `kind` for \ +// PacketKind::Data(measurements) (WrongKind if it's some other \ +// kind); decode each measurement's value positionally via \ +// wire::decode_value, advancing through `bytes` using each \ +// measurement's own wire width" +// diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 16fa74c02..1f6c98109 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -39,10 +39,10 @@ pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError -// todo!( +// // "look up order.id in `index` to get its PacketDef (UnknownId if \ // absent); match its `kind` for PacketKind::Order(measurements) \ // (WrongKind if it's some other kind); error with WrongFieldCount \ // if order.fields.len() doesn't match measurements.len(); encode \ // the id then each field positionally via wire::encode_value" -// ) \ No newline at end of file +// \ No newline at end of file From abf0e44291373a331f8ae302cdf71e1ce447ecc1 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:50:23 +0200 Subject: [PATCH 10/34] Feat(backend, protocol): used new function to retrieve the width of a measurement in data.rs decoding data function. --- backend/crates/core/src/protocol/packet/data.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index 5f2fa1d86..a649a9db8 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,7 +1,7 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::model::{AdjId, DataPacket, PacketKind}; use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], @@ -31,16 +31,7 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { let mut pointer = 2; // Pointer to after ID in bytes for measurement in measurements { - let width = match &measurement.kind { // Set width of measurement according to the type. - 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 - }; + let width = wire::byte_width(&measurement.kind); let next = pointer + width; From c3b2939f1b72f9153e7c660d75cf1407ed379226 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:14:16 +0200 Subject: [PATCH 11/34] Feat(backend, protocol): Refactored to merge data.rs and order.rs to data_order.rs --- backend/crates/core/src/model.rs | 2 +- backend/crates/core/src/model/packet.rs | 21 +++---- backend/crates/core/src/protocol/packet.rs | 3 +- .../packet/{data.rs => data_order.rs} | 56 +++++++++++++++++-- .../crates/core/src/protocol/packet/order.rs | 48 ---------------- 5 files changed, 60 insertions(+), 70 deletions(-) rename backend/crates/core/src/protocol/packet/{data.rs => data_order.rs} (51%) delete mode 100644 backend/crates/core/src/protocol/packet/order.rs diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 6cfdda92d..ccef505c5 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,7 +21,7 @@ pub use board::{Board, PacketDef, PacketKind, 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, + DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, DataOrderPacket }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 7ac91f048..54727d403 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -32,7 +32,7 @@ pub struct ShortTimestamp { pub subsecond: u8, } -/// A data packet: the decoded values of the measurements a board just +/// A Data/Order packet: the decoded values of the measurements a board just /// sent. /// /// The wire protocol is positional: values are not individually tagged @@ -41,25 +41,18 @@ pub struct ShortTimestamp { /// [`super::PacketKind::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. + #[derive(Debug, Clone, PartialEq)] -pub struct DataPacket { - /// Which packet definition this is an instance of. +pub struct DataOrderPacket{ pub id: AdjId, - /// The decoded value of each measurement in the packet, positional — - /// see the struct-level docs above. - pub values: Vec, + pub values: Vec, } /// 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; +pub type DataPacket = DataOrderPacket; /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. @@ -218,7 +211,7 @@ mod tests { }); let order = Packet::Order(OrderPacket { id: AdjId(2000), - fields: vec![], + values: vec![], }); assert_ne!(data, order); diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index b1c19e246..7f357df8f 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -5,7 +5,6 @@ //! only ever received (over UDP), an `Order` only ever sent (over TCP), //! so there's no `encode` for the former or `decode` for the latter. -pub mod data; pub mod message; -pub mod order; pub mod protection; +pub mod data_order; diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data_order.rs similarity index 51% rename from backend/crates/core/src/protocol/packet/data.rs rename to backend/crates/core/src/protocol/packet/data_order.rs index a649a9db8..e9cc5e210 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -1,12 +1,58 @@ +use crate::model::{AdjId, DataPacket, PacketKind, Value, DataOrderPacket}; +use crate::protocol::{wire, EncodeError, Index, DecodeError}; + + +//! Encodes an `Order` packet: a command sent to a board. Only ever sent, +//! over TCP — the backend never receives one back. + +/// Encodes `order` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode(order: &DataOrderPacket, index: &Index) -> Result, EncodeError> { + + //Unwrap PacketDef for kind + let packet_def = index + .packet(order.id) + .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + + //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements + let measurements = match &packet_def.kind{ + PacketKind::Order(measurements) => measurements, + _ => return Err(EncodeError::WrongKind {id:order.id}) + }; + + //Check if length of list of fields matches number of measurements. + if order.values.len() != measurements.len(){ + return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.values.len()}) + } + + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian + + //Encoding to bytes + for (field,measurement) in order.values.iter().zip(measurements){ //For each measurement + let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind + bytes.extend(field_bytes);// Adds to byte stream + } + + Ok(bytes) // Return bytes +} + + +// +// "look up order.id in `index` to get its PacketDef (UnknownId if \ +// absent); match its `kind` for PacketKind::Order(measurements) \ +// (WrongKind if it's some other kind); error with WrongFieldCount \ +// if order.fields.len() doesn't match measurements.len(); encode \ +// the id then each field positionally via wire::encode_value" +// + //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, PacketKind}; -use crate::protocol::{DecodeError, Index, wire}; - /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. -pub fn decode(bytes: &[u8], index: &Index) -> Result { +pub fn decode(bytes: &[u8], index: &Index) -> Result, DecodeError> { if bytes.len() < 2 { // If less than 2 bytes return error return Err(DecodeError::UnexpectedEof); @@ -57,4 +103,4 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// +// \ No newline at end of file diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs deleted file mode 100644 index 1f6c98109..000000000 --- a/backend/crates/core/src/protocol/packet/order.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Encodes an `Order` packet: a command sent to a board. Only ever sent, -//! over TCP — the backend never receives one back. - -use crate::model::{OrderPacket, PacketKind}; -use crate::protocol::{wire, EncodeError, Index}; - -/// Encodes `order` into the bytes to write to a board's connection, -/// resolving its field shapes against `index`. -pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { - - //Unwrap PacketDef for kind - let packet_def = index - .packet(order.id) - .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash - - //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements - let measurements = match &packet_def.kind{ - PacketKind::Order(measurements) => measurements, - _ => return Err(EncodeError::WrongKind {id:order.id}) - }; - - //Check if length of list of fields matches number of measurements. - if order.fields.len() != measurements.len(){ - return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.fields.len()}) - } - - let mut bytes = Vec::new(); - - bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian - - //Encoding to bytes - for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement - let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind - bytes.extend(field_bytes);// Adds to byte stream - } - - Ok(bytes) // Return bytes -} - - - -// -// "look up order.id in `index` to get its PacketDef (UnknownId if \ -// absent); match its `kind` for PacketKind::Order(measurements) \ -// (WrongKind if it's some other kind); error with WrongFieldCount \ -// if order.fields.len() doesn't match measurements.len(); encode \ -// the id then each field positionally via wire::encode_value" -// \ No newline at end of file From d9d2b4df61feadb354d258631a0bbf3c6f142441 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:20:31 +0200 Subject: [PATCH 12/34] Feat(backend, protocol): Created tests for data_order.rs. --- .../core/src/protocol/packet/data_order.rs | 204 +++++++++++++++++- 1 file changed, 203 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index e9cc5e210..eaa4cb84d 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -103,4 +103,206 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result, Dec // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// \ No newline at end of file +// + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PacketKind, PodData, Value, + }; + use std::collections::HashMap; + use std::net::IpAddr; + + 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, kind: PacketKind) -> PacketDef { + PacketDef { + id, + board: BoardId(1), + name: "TestPacket".into(), + kind, + } + } + + 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) + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_success_little_endian_and_positional_fields() { + let order_id = AdjId(1001); + let packet_def = sample_packet_def( + order_id, + PacketKind::Order(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let packet = DataOrderPacket { + id: order_id, + values: vec![Value::Number(300.0), Value::Bool(true)], + }; + + let encoded = encode(&packet, &index).expect("encoding should succeed"); + + // 1001 in little-endian u16: [0xE9, 0x03] + // 300 in little-endian u16: [0x2C, 0x01] + // true: [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 = DataOrderPacket { + id: AdjId(9999), + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_kind() { + let data_id = AdjId(2001); + let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); + + let index = make_test_index(vec![packet_def]); + let packet = DataOrderPacket { + id: data_id, + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::WrongKind { id: data_id }); + } + + #[test] + fn encode_fails_on_wrong_field_count() { + let order_id = AdjId(1002); + let packet_def = sample_packet_def( + order_id, + PacketKind::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 = DataOrderPacket { + id: order_id, + values: vec![Value::Number(10.0)], + }; + + let err = encode(&packet, &index).unwrap_err(); + 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, + PacketKind::Data(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + // 3001 in little-endian u16: [0xB9, 0x0B] + // 500 in little-endian u16: [0xF4, 0x01] + // false: [0x00] + let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; + + let decoded = decode(&raw_bytes, &index).expect("decoding should succeed"); + + assert_eq!(decoded.id, data_id); + assert_eq!( + decoded.values, + vec![Value::Number(500.0), Value::Bool(false)] + ); + } + + #[test] + fn decode_fails_when_buffer_is_shorter_than_header() { + let index = make_test_index(vec![]); + let err = decode(&[0x01], &index).unwrap_err(); + 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, + PacketKind::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 = decode(&raw_bytes, &index).unwrap_err(); + 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 = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); + } + + #[test] + fn decode_fails_on_wrong_kind() { + let order_id = AdjId(4001); + let packet_def = sample_packet_def(order_id, PacketKind::Order(vec![])); + + let index = make_test_index(vec![packet_def]); + let raw_bytes = [0xA1, 0x0F]; + + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::WrongKind { id: order_id }); + } +} \ No newline at end of file From 71258e56a0c9d36a49d9b649bc28f363b37be26f Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:40:58 +0200 Subject: [PATCH 13/34] Feat(backend, protocol): Created ReadMe for the protocol directory. --- backend/crates/core/src/protocol/README.md | 157 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 backend/crates/core/src/protocol/README.md diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md new file mode 100644 index 000000000..1d81566f4 --- /dev/null +++ b/backend/crates/core/src/protocol/README.md @@ -0,0 +1,157 @@ +# 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 (stub) +│ └── protection.rs # Protection trigger packets (stub) +├── tcp.rs # TCP transport framing (stub) +└── 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`. + +**Key types:** +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` + +### `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 + - `WrongKind` - Packet exists but isn't expected kind + +- `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 + - `WrongKind` - Packet exists but isn't expected kind + +### `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** - ⚠ Stub (wire width not yet confirmed with firmware) + +### `packet/` - Packet-Level Encoding/Decoding + +Contains per-packet-kind encoding and decoding logic, independent of transport. + +#### `data_order.rs` + +Handles two packet kinds: +- **Order packets** (commands sent to boards) - Encoded only, sent over TCP +- **Data packets** (periodic telemetry from boards) - Decoded only, received over UDP + +**Key functions:** +- `encode(order, index)` - Encode an Order packet into bytes +- `decode(bytes, index)` - Decode a Data packet from bytes + +#### `message.rs` ⚠ Stub + +Decodes free-text log messages from boards over TCP. Not yet implemented. + +#### `protection.rs` ⚠ Stub + +Decodes protection trigger events from boards over TCP. Not yet implemented. + +### `tcp.rs` - TCP Transport Framing ⚠ Partial + +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) + +**Key types:** +- `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) + +**Key functions:** +- `decode_next(reader, index)` - ⚠ Stub - 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 | ⚠ Stub | +| Protection (event) | Board → Backend | TCP | ⚠ Stub | + +## 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** - Stub (awaiting firmware confirmation) +- ⚠️ **Message packets** - Stub (decode not implemented) +- ⚠️ **Protection packets** - Stub (decode not implemented) +- ⚠️ **TCP framing** - Partial (decode_next not implemented) + +## Testing + +The module includes comprehensive unit tests for: +- Wire-level encoding/decoding for all numeric types +- Enum encoding/decoding with boundary checks +- Order packet encoding with field validation +- Data packet decoding with error handling +- Error propagation for unknown IDs, wrong kinds, and type mismatches + +Run tests with: +```bash +cargo test -p core protocol +``` From 60dfd9c8ade7b5e3220d6fd57be3e2dfbbe85ac7 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:14:16 +0200 Subject: [PATCH 14/34] Feat(backend, protocol): Refactored to merge data.rs and order.rs to data_order.rs --- backend/crates/core/src/model.rs | 2 +- backend/crates/core/src/model/packet.rs | 21 +++---- backend/crates/core/src/protocol/packet.rs | 3 +- .../packet/{data.rs => data_order.rs} | 56 +++++++++++++++++-- .../crates/core/src/protocol/packet/order.rs | 48 ---------------- 5 files changed, 60 insertions(+), 70 deletions(-) rename backend/crates/core/src/protocol/packet/{data.rs => data_order.rs} (51%) delete mode 100644 backend/crates/core/src/protocol/packet/order.rs diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 6cfdda92d..ccef505c5 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,7 +21,7 @@ pub use board::{Board, PacketDef, PacketKind, 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, + DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, DataOrderPacket }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 7cba9ae47..4c249fb53 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -32,7 +32,7 @@ pub struct ShortTimestamp { pub subsecond: u8, } -/// A data packet: the decoded values of the measurements a board just +/// A Data/Order packet: the decoded values of the measurements a board just /// sent. /// /// The wire protocol is positional: values are not individually tagged @@ -41,25 +41,18 @@ pub struct ShortTimestamp { /// [`super::PacketKind::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. + #[derive(Debug, Clone, PartialEq)] -pub struct DataPacket { - /// Which packet definition this is an instance of. +pub struct DataOrderPacket{ pub id: AdjId, - /// The decoded value of each measurement in the packet, positional — - /// see the struct-level docs above. - pub values: Vec, + pub values: Vec, } /// 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; +pub type DataPacket = DataOrderPacket; /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. @@ -218,7 +211,7 @@ mod tests { }); let order = Packet::Order(OrderPacket { id: AdjId(2000), - fields: vec![], + values: vec![], }); assert_ne!(data, order); diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index b1c19e246..7f357df8f 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -5,7 +5,6 @@ //! only ever received (over UDP), an `Order` only ever sent (over TCP), //! so there's no `encode` for the former or `decode` for the latter. -pub mod data; pub mod message; -pub mod order; pub mod protection; +pub mod data_order; diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data_order.rs similarity index 51% rename from backend/crates/core/src/protocol/packet/data.rs rename to backend/crates/core/src/protocol/packet/data_order.rs index a649a9db8..e9cc5e210 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -1,12 +1,58 @@ +use crate::model::{AdjId, DataPacket, PacketKind, Value, DataOrderPacket}; +use crate::protocol::{wire, EncodeError, Index, DecodeError}; + + +//! Encodes an `Order` packet: a command sent to a board. Only ever sent, +//! over TCP — the backend never receives one back. + +/// Encodes `order` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode(order: &DataOrderPacket, index: &Index) -> Result, EncodeError> { + + //Unwrap PacketDef for kind + let packet_def = index + .packet(order.id) + .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + + //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements + let measurements = match &packet_def.kind{ + PacketKind::Order(measurements) => measurements, + _ => return Err(EncodeError::WrongKind {id:order.id}) + }; + + //Check if length of list of fields matches number of measurements. + if order.values.len() != measurements.len(){ + return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.values.len()}) + } + + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian + + //Encoding to bytes + for (field,measurement) in order.values.iter().zip(measurements){ //For each measurement + let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind + bytes.extend(field_bytes);// Adds to byte stream + } + + Ok(bytes) // Return bytes +} + + +// +// "look up order.id in `index` to get its PacketDef (UnknownId if \ +// absent); match its `kind` for PacketKind::Order(measurements) \ +// (WrongKind if it's some other kind); error with WrongFieldCount \ +// if order.fields.len() doesn't match measurements.len(); encode \ +// the id then each field positionally via wire::encode_value" +// + //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, PacketKind}; -use crate::protocol::{DecodeError, Index, wire}; - /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. -pub fn decode(bytes: &[u8], index: &Index) -> Result { +pub fn decode(bytes: &[u8], index: &Index) -> Result, DecodeError> { if bytes.len() < 2 { // If less than 2 bytes return error return Err(DecodeError::UnexpectedEof); @@ -57,4 +103,4 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// +// \ No newline at end of file diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs deleted file mode 100644 index 1f6c98109..000000000 --- a/backend/crates/core/src/protocol/packet/order.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Encodes an `Order` packet: a command sent to a board. Only ever sent, -//! over TCP — the backend never receives one back. - -use crate::model::{OrderPacket, PacketKind}; -use crate::protocol::{wire, EncodeError, Index}; - -/// Encodes `order` into the bytes to write to a board's connection, -/// resolving its field shapes against `index`. -pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { - - //Unwrap PacketDef for kind - let packet_def = index - .packet(order.id) - .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash - - //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements - let measurements = match &packet_def.kind{ - PacketKind::Order(measurements) => measurements, - _ => return Err(EncodeError::WrongKind {id:order.id}) - }; - - //Check if length of list of fields matches number of measurements. - if order.fields.len() != measurements.len(){ - return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.fields.len()}) - } - - let mut bytes = Vec::new(); - - bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian - - //Encoding to bytes - for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement - let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind - bytes.extend(field_bytes);// Adds to byte stream - } - - Ok(bytes) // Return bytes -} - - - -// -// "look up order.id in `index` to get its PacketDef (UnknownId if \ -// absent); match its `kind` for PacketKind::Order(measurements) \ -// (WrongKind if it's some other kind); error with WrongFieldCount \ -// if order.fields.len() doesn't match measurements.len(); encode \ -// the id then each field positionally via wire::encode_value" -// \ No newline at end of file From e6587150b3aaaf00dbab67550f46b897ba38a3e4 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:20:31 +0200 Subject: [PATCH 15/34] Feat(backend, protocol): Created tests for data_order.rs. --- .../core/src/protocol/packet/data_order.rs | 204 +++++++++++++++++- 1 file changed, 203 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index e9cc5e210..eaa4cb84d 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -103,4 +103,206 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result, Dec // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// \ No newline at end of file +// + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PacketKind, PodData, Value, + }; + use std::collections::HashMap; + use std::net::IpAddr; + + 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, kind: PacketKind) -> PacketDef { + PacketDef { + id, + board: BoardId(1), + name: "TestPacket".into(), + kind, + } + } + + 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) + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_success_little_endian_and_positional_fields() { + let order_id = AdjId(1001); + let packet_def = sample_packet_def( + order_id, + PacketKind::Order(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let packet = DataOrderPacket { + id: order_id, + values: vec![Value::Number(300.0), Value::Bool(true)], + }; + + let encoded = encode(&packet, &index).expect("encoding should succeed"); + + // 1001 in little-endian u16: [0xE9, 0x03] + // 300 in little-endian u16: [0x2C, 0x01] + // true: [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 = DataOrderPacket { + id: AdjId(9999), + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_kind() { + let data_id = AdjId(2001); + let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); + + let index = make_test_index(vec![packet_def]); + let packet = DataOrderPacket { + id: data_id, + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::WrongKind { id: data_id }); + } + + #[test] + fn encode_fails_on_wrong_field_count() { + let order_id = AdjId(1002); + let packet_def = sample_packet_def( + order_id, + PacketKind::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 = DataOrderPacket { + id: order_id, + values: vec![Value::Number(10.0)], + }; + + let err = encode(&packet, &index).unwrap_err(); + 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, + PacketKind::Data(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + // 3001 in little-endian u16: [0xB9, 0x0B] + // 500 in little-endian u16: [0xF4, 0x01] + // false: [0x00] + let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; + + let decoded = decode(&raw_bytes, &index).expect("decoding should succeed"); + + assert_eq!(decoded.id, data_id); + assert_eq!( + decoded.values, + vec![Value::Number(500.0), Value::Bool(false)] + ); + } + + #[test] + fn decode_fails_when_buffer_is_shorter_than_header() { + let index = make_test_index(vec![]); + let err = decode(&[0x01], &index).unwrap_err(); + 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, + PacketKind::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 = decode(&raw_bytes, &index).unwrap_err(); + 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 = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); + } + + #[test] + fn decode_fails_on_wrong_kind() { + let order_id = AdjId(4001); + let packet_def = sample_packet_def(order_id, PacketKind::Order(vec![])); + + let index = make_test_index(vec![packet_def]); + let raw_bytes = [0xA1, 0x0F]; + + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::WrongKind { id: order_id }); + } +} \ No newline at end of file From 8218e7476abb5dce583baa2a58cc929df3081dc1 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:40:58 +0200 Subject: [PATCH 16/34] Feat(backend, protocol): Created ReadMe for the protocol directory. --- backend/crates/core/src/protocol/README.md | 157 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 backend/crates/core/src/protocol/README.md diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md new file mode 100644 index 000000000..1d81566f4 --- /dev/null +++ b/backend/crates/core/src/protocol/README.md @@ -0,0 +1,157 @@ +# 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 (stub) +│ └── protection.rs # Protection trigger packets (stub) +├── tcp.rs # TCP transport framing (stub) +└── 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`. + +**Key types:** +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` + +### `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 + - `WrongKind` - Packet exists but isn't expected kind + +- `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 + - `WrongKind` - Packet exists but isn't expected kind + +### `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** - ⚠ Stub (wire width not yet confirmed with firmware) + +### `packet/` - Packet-Level Encoding/Decoding + +Contains per-packet-kind encoding and decoding logic, independent of transport. + +#### `data_order.rs` + +Handles two packet kinds: +- **Order packets** (commands sent to boards) - Encoded only, sent over TCP +- **Data packets** (periodic telemetry from boards) - Decoded only, received over UDP + +**Key functions:** +- `encode(order, index)` - Encode an Order packet into bytes +- `decode(bytes, index)` - Decode a Data packet from bytes + +#### `message.rs` ⚠ Stub + +Decodes free-text log messages from boards over TCP. Not yet implemented. + +#### `protection.rs` ⚠ Stub + +Decodes protection trigger events from boards over TCP. Not yet implemented. + +### `tcp.rs` - TCP Transport Framing ⚠ Partial + +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) + +**Key types:** +- `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) + +**Key functions:** +- `decode_next(reader, index)` - ⚠ Stub - 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 | ⚠ Stub | +| Protection (event) | Board → Backend | TCP | ⚠ Stub | + +## 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** - Stub (awaiting firmware confirmation) +- ⚠️ **Message packets** - Stub (decode not implemented) +- ⚠️ **Protection packets** - Stub (decode not implemented) +- ⚠️ **TCP framing** - Partial (decode_next not implemented) + +## Testing + +The module includes comprehensive unit tests for: +- Wire-level encoding/decoding for all numeric types +- Enum encoding/decoding with boundary checks +- Order packet encoding with field validation +- Data packet decoding with error handling +- Error propagation for unknown IDs, wrong kinds, and type mismatches + +Run tests with: +```bash +cargo test -p core protocol +``` From b615ec814970816e2efcf4decc4dbe1d9f7c3a59 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:52:32 +0200 Subject: [PATCH 17/34] fix(backend, model): use phantom type to diferenciate betwen orders and data packets --- backend/crates/core/src/model.rs | 3 +- backend/crates/core/src/model/README.md | 46 +++++++---- backend/crates/core/src/model/packet.rs | 103 +++++++++++++++++++----- 3 files changed, 118 insertions(+), 34 deletions(-) diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index ccef505c5..54754b45b 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,7 +21,8 @@ pub use board::{Board, PacketDef, PacketKind, 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, DataOrderPacket + Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, + 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 fb85173ea..dc1d58f49 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -123,23 +123,39 @@ 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`. -### 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 `PacketKind::Data` list. + +`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketKind::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. #### `id` @@ -149,7 +165,7 @@ 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` @@ -161,7 +177,7 @@ Informational message, no action implied. #### `Panic` An unrecoverable firmware error. -### 4.6 `MessagePacket` +### 4.5 `MessagePacket` A free-text message sent by a board. #### `level` @@ -173,7 +189,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/packet.rs b/backend/crates/core/src/model/packet.rs index 4c249fb53..ca766809a 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -7,6 +7,7 @@ //! describes a whole packet. use super::{AdjId, 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,8 +33,22 @@ pub struct ShortTimestamp { pub subsecond: u8, } +/// 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; + /// A Data/Order packet: the decoded values of the measurements a board just -/// sent. +/// 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 @@ -41,18 +56,58 @@ pub struct ShortTimestamp { /// [`super::PacketKind::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 DataOrderPacket{ +pub struct DataOrderPacket { + /// The packet's id. pub id: AdjId, - pub values: Vec, + /// Each measurement's decoded value, in the same order as the + /// matching [`super::PacketKind::Data`]/[`super::PacketKind::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. -pub type OrderPacket = DataOrderPacket; -pub type DataPacket = DataOrderPacket; +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. @@ -194,10 +249,7 @@ mod tests { 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)); @@ -205,14 +257,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), - values: 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 { @@ -220,4 +266,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); + } } From fc7216b2da11fe7901c4f9f4eb7c669787d13011 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:58:00 +0200 Subject: [PATCH 18/34] fix(backend, protocol): changes due to unification of data_order --- backend/crates/core/src/protocol/packet.rs | 1 + backend/crates/core/src/protocol/tcp.rs | 2 +- backend/crates/core/src/protocol/udp.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 7f357df8f..7d17cc623 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -5,6 +5,7 @@ //! only ever received (over UDP), an `Order` only ever sent (over TCP), //! so there's no `encode` for the former or `decode` for the latter. +pub mod data_order; pub mod message; pub mod protection; pub mod data_order; diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index af3882253..5adf7edd8 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -45,5 +45,5 @@ pub fn decode_next(reader: impl Read, index: &Index) -> Result Result, EncodeError> { - packet::order::encode(order, index) + packet::data_order::encode(order, index) } diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs index 4eb0d6723..35e918ce0 100644 --- a/backend/crates/core/src/protocol/udp.rs +++ b/backend/crates/core/src/protocol/udp.rs @@ -13,5 +13,5 @@ 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::decode(bytes, index) + packet::data_order::decode(bytes, index) } From 45642f856d66ca659c471b6e6055c4cd1da58f10 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:58:29 +0200 Subject: [PATCH 19/34] fix(backend, protocol): implement partial eq manually for decode error (due to IO error) --- backend/crates/core/src/protocol/error.rs | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs index 64660e696..1ffd3b638 100644 --- a/backend/crates/core/src/protocol/error.rs +++ b/backend/crates/core/src/protocol/error.rs @@ -46,8 +46,33 @@ pub enum DecodeError { }, } +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::WrongKind { id: a }, Self::WrongKind { id: b }) => a == b, + _ => false, + } + } +} + /// An error encoding a packet into bytes. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, PartialEq)] pub enum EncodeError { /// No packet is declared with this id. #[error("unknown packet id {0:?}")] From 7f6364467f77feeb179b6f1a4fbc456b4460e1e4 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:59:01 +0200 Subject: [PATCH 20/34] docs(backend, protocol): update docs --- backend/crates/core/src/protocol/tcp.rs | 21 ++++++++++----------- backend/crates/core/src/protocol/udp.rs | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index 5adf7edd8..9d4169087 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -5,7 +5,7 @@ //! //! Both functions here are thin: the actual per-kind decoding lives in //! [`super::packet::protection`]/[`super::packet::message`]/ -//! [`super::packet::order`], independent of TCP. This module only adds +//! [`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`. @@ -27,19 +27,18 @@ pub enum TcpFrame { /// field shapes against `index`. /// /// Reads the frame's leading 16-bit id first, then decides which of -/// [`super::packet::protection::decode`] (the id's high 3 bits are a -/// protection's position, non-zero) or [`super::packet::message::decode`] -/// (the id matches one of `general_info.json`'s `message_ids`) to hand -/// the rest of the frame to, wrapping the result in the matching -/// [`TcpFrame`] variant. +/// [`super::packet::protection::decode`] ([`crate::model::AdjId::protection_position`] +/// is non-zero) or [`super::packet::message::decode`] (the id matches one +/// of `general_info.json`'s `message_ids`) to hand the rest of the frame +/// to, wrapping the result in the matching [`TcpFrame`] variant. pub fn decode_next(reader: impl Read, index: &Index) -> Result { let _ = (reader, index); todo!( - "read the leading 16-bit id; if its top 3 bits are non-zero, call \ - packet::protection::decode; otherwise resolve it against \ - general_info.json's message_ids (not yet reachable from `Index` \ - — needs AdjInfo too) and call packet::message::decode; wrap \ - either result in the matching TcpFrame variant" + "read the leading 16-bit id; if its AdjId::protection_position() is \ + non-zero, call packet::protection::decode; otherwise resolve it \ + against general_info.json's message_ids (not yet reachable from \ + `Index` — needs AdjInfo too) and call packet::message::decode; \ + wrap either result in the matching TcpFrame variant" ) } diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs index 35e918ce0..05c44c3c0 100644 --- a/backend/crates/core/src/protocol/udp.rs +++ b/backend/crates/core/src/protocol/udp.rs @@ -2,7 +2,7 @@ //! telemetry (`Data` packets) from any board. Receive-only — the backend //! never sends anything over this socket. //! -//! The actual decoding lives in [`super::packet::data`], independent of +//! 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 From 5a99aa40279ca5092873662b000b9eeaf5f590b5 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:59:31 +0200 Subject: [PATCH 21/34] fix(backend, protocol): format --- backend/crates/core/src/protocol/wire.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs index 8809c7126..89bf317e1 100644 --- a/backend/crates/core/src/protocol/wire.rs +++ b/backend/crates/core/src/protocol/wire.rs @@ -203,7 +203,10 @@ mod tests { ]; for (wire_type, expected) in cases { - assert_eq!(byte_width(&MeasurementKind::Numeric { wire_type }), expected); + assert_eq!( + byte_width(&MeasurementKind::Numeric { wire_type }), + expected + ); } } From 3e13e0985e904d3828e98e1a8d854371b82a25f2 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 18:00:17 +0200 Subject: [PATCH 22/34] fix(backend, protocol): remove duplicated data_order exportation --- backend/crates/core/src/protocol/packet.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 7d17cc623..4bf19cc13 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -8,4 +8,3 @@ pub mod data_order; pub mod message; pub mod protection; -pub mod data_order; From 618e696adbd7f63217cf3225dca955c22d451e43 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 19:23:05 +0200 Subject: [PATCH 23/34] fix(backend, model): include kind --- backend/crates/core/src/model.rs | 4 ++-- backend/crates/core/src/model/README.md | 11 +++++++++ backend/crates/core/src/model/packet.rs | 30 ++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 54754b45b..04f933cb3 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,8 +21,8 @@ pub use board::{Board, PacketDef, PacketKind, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, - ProtectionPacket, ShortTimestamp, + Data, DataOrderPacket, DataPacket, KindTag, MessageLevel, MessagePacket, Order, OrderPacket, + Packet, 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 dc1d58f49..aa088648c 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -139,6 +139,17 @@ pub struct DataOrderPacket { #### `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`. +#### `KindTag` +The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `KindTag` 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 KindTag { + fn matches(kind: &PacketKind) -> bool; +} +``` +`Data`/`Order` each implement it by checking which `PacketKind` (§1.1) variant they stand for (`matches!(kind, PacketKind::Data(_))` / `PacketKind::Order(_)`). This is the only thing `KindTag` decides — once a `PacketDef`'s kind is confirmed to match, the actual measurement list still comes from the existing `PacketKind::measurements()` (§1.1), not from `KindTag` 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 kind. + #### `DataPacket` / `OrderPacket` ```rust pub type DataPacket = DataOrderPacket; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index ca766809a..56b9c60e8 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -6,7 +6,7 @@ //! packet or a protection *can* look like, or [`super::PacketDef`], which //! describes a whole packet. -use super::{AdjId, Value}; +use super::{AdjId, PacketKind, Value}; use std::marker::PhantomData; /// A short timestamp carrying only the time of day: hour, minute, second @@ -47,6 +47,34 @@ pub struct Data; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Order; +/// Bridges a [`DataOrderPacket`]'s phantom `Kind` to the runtime +/// [`PacketKind`] 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 kind. This trait is the +/// one yes/no question that generic code can't answer on its own — +/// "is this `PacketDef`'s kind actually the one I was asked to +/// encode/decode?" — everything after that (pulling out the +/// measurements) is already handled by [`PacketKind::measurements`], +/// kind-agnostic, so there's no need to redo that here. +pub trait KindTag { + /// Whether `kind` is the variant this `Kind` stands for. + fn matches(kind: &PacketKind) -> bool; +} + +impl KindTag for Data { + fn matches(kind: &PacketKind) -> bool { + matches!(kind, PacketKind::Data(_)) + } +} + +impl KindTag for Order { + fn matches(kind: &PacketKind) -> bool { + matches!(kind, PacketKind::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. /// From d47baea1b06b93c3c7944a2160402da1c98401d0 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 19:24:13 +0200 Subject: [PATCH 24/34] feat(backend, protocol): implement boolean --- backend/crates/core/src/protocol/wire.rs | 74 +++++++++++++++++++----- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs index 89bf317e1..9ebe6ab8b 100644 --- a/backend/crates/core/src/protocol/wire.rs +++ b/backend/crates/core/src/protocol/wire.rs @@ -45,10 +45,7 @@ pub fn byte_width(kind: &MeasurementKind) -> usize { NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, }, - // Not yet confirmed against firmware — see `decode_boolean`. - MeasurementKind::Boolean => { - todo!("confirm the wire width firmware uses for a boolean measurement") - } + MeasurementKind::Boolean => 1, MeasurementKind::Enum { .. } => 1, } } @@ -128,19 +125,20 @@ fn numeric_range(wire_type: NumericKind) -> (f64, f64) { } } -/// Decodes a boolean value. -/// -/// Wire width isn't settled yet — not specified anywhere in the ADJv3 -/// spec read so far. Confirm against firmware before implementing. +/// Decodes a boolean value: a single byte, zero for `false`, any other +/// value for `true`. pub fn decode_boolean(bytes: &[u8]) -> Result { - let _ = bytes; - todo!("confirm the wire width firmware uses for a boolean measurement") + let &byte = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + Ok(Value::Bool(byte != 0)) } -/// Encodes a boolean value. +/// Encodes a boolean value as a single byte: `0x00` for `false`, `0x01` +/// for `true`. pub fn encode_boolean(value: &Value) -> Result, EncodeError> { - let _ = value; - todo!("confirm the wire width firmware uses for a boolean measurement") + 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 @@ -220,6 +218,56 @@ mod tests { ); } + #[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 { From 87c015931e37c1cb9205319a74d9581917155a53 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 19:25:01 +0200 Subject: [PATCH 25/34] fix(backend, protocol): solve issues and add comments --- .../core/src/protocol/packet/data_order.rs | 342 ++++++++++++------ 1 file changed, 230 insertions(+), 112 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index eaa4cb84d..d4643a050 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -1,120 +1,129 @@ -use crate::model::{AdjId, DataPacket, PacketKind, Value, DataOrderPacket}; -use crate::protocol::{wire, EncodeError, Index, DecodeError}; - - -//! Encodes an `Order` packet: a command sent to a board. Only ever sent, -//! over TCP — the backend never receives one back. - -/// Encodes `order` into the bytes to write to a board's connection, +//! 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::KindTag`], 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::KindTag`] only +//! answers whether a `PacketDef`'s kind matches `Kind` at all; the actual +//! measurement list comes from [`crate::model::PacketKind::measurements`] +//! either way. + +use crate::model::{AdjId, DataOrderPacket, KindTag}; +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(order: &DataOrderPacket, index: &Index) -> Result, EncodeError> { - - //Unwrap PacketDef for kind +pub fn encode( + packet: &DataOrderPacket, + index: &Index, +) -> Result, EncodeError> { + // Look up the packet definition for this packet's id let packet_def = index - .packet(order.id) - .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; - //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements - let measurements = match &packet_def.kind{ - PacketKind::Order(measurements) => measurements, - _ => return Err(EncodeError::WrongKind {id:order.id}) - }; + // Check that the packet is an order or data, as expected by the generic KindTag + if !Kind::matches(&packet_def.kind) { + return Err(EncodeError::WrongKind { id: packet.id }); + } + + // Get the measurements for this packet + let measurements = packet_def.kind.measurements(); - //Check if length of list of fields matches number of measurements. - if order.values.len() != measurements.len(){ - return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.values.len()}) + // 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()); - bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian + // 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)?; - //Encoding to bytes - for (field,measurement) in order.values.iter().zip(measurements){ //For each measurement - let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind - bytes.extend(field_bytes);// Adds to byte stream + // Append the encoded field bytes to the output vector + bytes.extend(field_bytes); } - Ok(bytes) // Return bytes + Ok(bytes) } - -// -// "look up order.id in `index` to get its PacketDef (UnknownId if \ -// absent); match its `kind` for PacketKind::Order(measurements) \ -// (WrongKind if it's some other kind); error with WrongFieldCount \ -// if order.fields.len() doesn't match measurements.len(); encode \ -// the id then each field positionally via wire::encode_value" -// - -//! Decodes a `Data` packet: a board's periodic telemetry. Only ever -//! received, over UDP — a board never receives one back. - -/// Decodes a complete, already-delimited buffer into a [`DataPacket`], -/// resolving its field shapes against `index`. -pub fn decode(bytes: &[u8], index: &Index) -> Result, DecodeError> { +/// 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 { - // If less than 2 bytes return error return Err(DecodeError::UnexpectedEof); } - let id: [u8; 2] = bytes[0..2] // Recieve first two bytes - .try_into() // Tries to make it into desired type - .map_err(|_| DecodeError::UnexpectedEof)?; // If fails, return error - - let adj_id = AdjId(u16::from_le_bytes(id)); // Parse into u16 and make it AdjId type + // generate the packet id from the first two bytes + let id: [u8; 2] = bytes[0..2] + .try_into() + .map_err(|_| DecodeError::UnexpectedEof)?; - let packet_def = index.get(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; // Get packet definition from index + // generate ADJ id from the little-endian bytes + let adj_id = AdjId(u16::from_le_bytes(id)); - let measurements = match &packet_def.kind { - // Get measurements from packet definition - PacketKind::Data(measurements) => measurements, // If packet kind is data, return measurements - _ => return Err(DecodeError::WrongKind { id: adj_id }), // If not, return Error - }; + // 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))?; - let mut values = Vec::with_capacity(measurements.len()); // Create vectir list of values same size as measurements + // Check that the packet is an order or data, as expected by the generic KindTag + if !Kind::matches(&packet_def.kind) { + return Err(DecodeError::WrongKind { id: adj_id }); + } + // Get the measurements for this packet + let measurements = packet_def.kind.measurements(); - let mut pointer = 2; // Pointer to after ID in bytes + // 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 { - // Check if there is enough bytes left return Err(DecodeError::UnexpectedEof); } - let field = &bytes[pointer..next]; // Size of field (bytes between pointer and next position) - let value = wire::decode_value(field, &measurement.kind)?; // Decode value from field size and measurement width - values.push(value); //Add to list of values + // 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; // Next position + pointer = next; } - Ok(DataPacket { id: adj_id, values }) // Return data packet + Ok(DataOrderPacket::new(adj_id, values)) } -// -// "read the leading 16-bit id; look up its PacketDef in `index` \ -// (UnknownId if absent); match its `kind` for \ -// PacketKind::Data(measurements) (WrongKind if it's some other \ -// kind); decode each measurement's value positionally via \ -// wire::decode_value, advancing through `bytes` using each \ -// measurement's own wire width" -// - #[cfg(test)] mod tests { use super::*; use crate::model::{ - Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, - PacketKind, PodData, Value, + Board, BoardId, Data, DataPacket, MacAddress, Measurement, MeasurementKind, NumericKind, + Order, OrderPacket, PacketDef, PacketKind, 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), @@ -158,35 +167,43 @@ mod tests { let packet_def = sample_packet_def( order_id, PacketKind::Order(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), - sample_measurement(2, MeasurementKind::Boolean), + 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 = DataOrderPacket { - id: order_id, - values: vec![Value::Number(300.0), Value::Bool(true)], - }; + let packet = OrderPacket::new(order_id, vec![Value::Number(300.0), Value::Number(1.0)]); - let encoded = encode(&packet, &index).expect("encoding should succeed"); + 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] - // true: [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 = DataOrderPacket { - id: AdjId(9999), - values: vec![], - }; + let packet = OrderPacket::new(AdjId(9999), vec![]); - let err = encode(&packet, &index).unwrap_err(); + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); } @@ -196,12 +213,11 @@ mod tests { let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); let index = make_test_index(vec![packet_def]); - let packet = DataOrderPacket { - id: data_id, - values: vec![], - }; + let packet = OrderPacket::new(data_id, vec![]); - let err = encode(&packet, &index).unwrap_err(); + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet kind should fail"); + }; assert_eq!(err, EncodeError::WrongKind { id: data_id }); } @@ -211,18 +227,27 @@ mod tests { let packet_def = sample_packet_def( order_id, PacketKind::Order(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), - sample_measurement(2, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), + 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 = DataOrderPacket { - id: order_id, - values: vec![Value::Number(10.0)], - }; + let packet = OrderPacket::new(order_id, vec![Value::Number(10.0)]); - let err = encode(&packet, &index).unwrap_err(); + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding with the wrong field count should fail"); + }; assert_eq!( err, EncodeError::WrongFieldCount { @@ -240,8 +265,18 @@ mod tests { let packet_def = sample_packet_def( data_id, PacketKind::Data(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), - sample_measurement(2, MeasurementKind::Boolean), + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), ]), ); @@ -249,22 +284,26 @@ mod tests { // 3001 in little-endian u16: [0xB9, 0x0B] // 500 in little-endian u16: [0xF4, 0x01] - // false: [0x00] + // enum index 0: [0x00] let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; - let decoded = decode(&raw_bytes, &index).expect("decoding should succeed"); + 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::Bool(false)] + 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 = decode(&[0x01], &index).unwrap_err(); + let Err(err) = decode::(&[0x01], &index) else { + unreachable!("decoding a buffer shorter than the header should fail"); + }; assert_eq!(err, DecodeError::UnexpectedEof); } @@ -273,15 +312,20 @@ mod tests { let data_id = AdjId(3002); let packet_def = sample_packet_def( data_id, - PacketKind::Data(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U32 }), - ]), + PacketKind::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 = decode(&raw_bytes, &index).unwrap_err(); + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a truncated payload should fail"); + }; assert_eq!(err, DecodeError::UnexpectedEof); } @@ -290,7 +334,9 @@ mod tests { let index = make_test_index(vec![]); let raw_bytes = [0x88, 0x13]; - let err = decode(&raw_bytes, &index).unwrap_err(); + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding an unknown id should fail"); + }; assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); } @@ -302,7 +348,79 @@ mod tests { let index = make_test_index(vec![packet_def]); let raw_bytes = [0xA1, 0x0F]; - let err = decode(&raw_bytes, &index).unwrap_err(); + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding the wrong packet kind should fail"); + }; assert_eq!(err, DecodeError::WrongKind { id: order_id }); } -} \ No newline at end of file + + // --- 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 kind. + // + // 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, + PacketKind::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, + PacketKind::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); + } +} From 1a2127f29a8baf91aa0fc9018618b3f1edd00124 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 21:22:47 +0200 Subject: [PATCH 26/34] feat(backend, protocol): protection --- .../core/src/protocol/packet/protection.rs | 425 +++++++++++++++++- 1 file changed, 411 insertions(+), 14 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index e00a4acec..af6eb53f5 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -1,26 +1,423 @@ //! Decodes a `Protection` packet: a board reporting one of its declared -//! protections triggered. Only ever received, over TCP. +//! 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. +//! Structure +//! ``` +//! | 0-1 | 2 | 3 | 4 | 5 | 6-9 | 10-13 | 14-17 | 18-21 | 22-25 | 26-29 | 30-33 | +//! |-------|-------|-------|-------|-------|-------|-------|-------| 30-33 | 34-37 | 38-41 | 42-45 | +//! | id | hora | min | seg | subseg| resv | resv | value | value | value | value | value | +//! | 34-37 | 38-41 | 42-45 | +//! |-------| 34-37 | 38-41 | 42-45 | +//! | value | value | value | +//! ``` +//! -use crate::model::{AdjId, ProtectionPacket}; -use crate::protocol::{DecodeError, Index}; +use crate::model::{AdjId, PacketKind, ProtectionPacket, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; use std::io::Read; /// Decodes a protection frame's remaining bytes, given the frame's /// already-read compound id (`tcp::decode_next`'s job), resolving the /// watched measurement's wire type against `index`. pub fn decode( - reader: impl Read, + mut reader: impl Read, compound_id: AdjId, index: &Index, ) -> Result { - let _ = (reader, compound_id, index); - todo!( - "look up compound_id's PacketDef in `index` (already keyed by \ - this exact compound id, see adj::raw::loader); match its `kind` \ - for PacketKind::Protection(measurement) (WrongKind if it's some \ - other kind) to get the watched measurement's wire type; \ - separately split compound_id's bits into (measurement, position) \ - to fill ProtectionPacket's `measurement`/`protection` fields; \ - read the timestamp, then the value via wire::decode_value" - ) + // 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 PacketKind::Protection(measurement) = &packet_def.kind else { + return Err(DecodeError::WrongKind { 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. +/// +/// 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. +/// +/// Like `decode`, doesn't write the leading compound id (`packet.id`) — +/// `decode` doesn't read it either, since that's resolved one level up, +/// by whatever dispatches to this function in the first place. +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketKind::Protection(measurement) = &packet_def.kind else { + return Err(EncodeError::WrongKind { id: packet.id }); + }; + + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + let header = [ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + 0, // resv — decode discards these, nothing to round-trip + 0, // resv + ]; + let mut bytes = header.to_vec(); + + 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(), + kind: PacketKind::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), 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, 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 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![]), unknown_id, &index), + Err(DecodeError::UnknownId(id)) if id == unknown_id + )); + } + + #[test] + fn a_non_protection_packet_at_that_id_is_the_wrong_kind() { + 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(), + kind: PacketKind::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); + + assert!(matches!( + decode(Cursor::new(vec![]), compound_id, &index), + Err(DecodeError::WrongKind { 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, 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"); + }; + let Ok(decoded) = decode(Cursor::new(bytes), compound_id, &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"); + }; + + assert_eq!(bytes[4], 0); + assert_eq!(bytes[5], 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_kind() { + 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(), + kind: PacketKind::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); + + 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 kind should fail"); + }; + assert_eq!(err, EncodeError::WrongKind { 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 { .. })); + } } From 7e632fbe688eb34b316b5e19c08c6a18c0343283 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 22:51:13 +0200 Subject: [PATCH 27/34] feat(backend, protection): optional id --- .../core/src/protocol/packet/protection.rs | 111 +++++++++++++----- 1 file changed, 80 insertions(+), 31 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index af6eb53f5..b3d99b2d0 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -3,29 +3,45 @@ //! `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. -//! Structure -//! ``` -//! | 0-1 | 2 | 3 | 4 | 5 | 6-9 | 10-13 | 14-17 | 18-21 | 22-25 | 26-29 | 30-33 | -//! |-------|-------|-------|-------|-------|-------|-------|-------| 30-33 | 34-37 | 38-41 | 42-45 | -//! | id | hora | min | seg | subseg| resv | resv | value | value | value | value | value | -//! | 34-37 | 38-41 | 42-45 | -//! |-------| 34-37 | 38-41 | 42-45 | -//! | value | value | value | -//! ``` //! +//! 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, PacketKind, ProtectionPacket, ShortTimestamp}; use crate::protocol::{DecodeError, EncodeError, Index, wire}; use std::io::Read; -/// Decodes a protection frame's remaining bytes, given the frame's -/// already-read compound id (`tcp::decode_next`'s job), resolving the -/// watched measurement's wire type against `index`. +/// 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: AdjId, + compound_id: Option, index: &Index, ) -> Result { + let compound_id = match compound_id { + Some(id) => id, + None => 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 @@ -60,16 +76,22 @@ pub fn decode( }) } -/// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects. +/// Reads a raw little-endian 16-bit id off the front of `reader`. The +/// one piece of byte parsing [`decode`]/[`encode`] share, so it only +/// lives here once instead of wherever else might need an `AdjId`. +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))) +} + +/// 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. -/// -/// Like `decode`, doesn't write the leading compound id (`packet.id`) — -/// `decode` doesn't read it either, since that's resolved one level up, -/// by whatever dispatches to this function in the first place. pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError> { let packet_def = index .packet(packet.id) @@ -79,17 +101,17 @@ pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, Encod return Err(EncodeError::WrongKind { 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` - let header = [ + // — decode discards these, nothing to round-trip. + bytes.extend([ packet.timestamp.hour, packet.timestamp.minute, packet.timestamp.second, packet.timestamp.subsecond, - 0, // resv — decode discards these, nothing to round-trip - 0, // resv - ]; - let mut bytes = header.to_vec(); - + 0, + 0, + ]); bytes.extend(wire::encode_value(&packet.value, &measurement.kind)?); Ok(bytes) @@ -152,7 +174,7 @@ mod tests { bytes.extend(53.2f32.to_le_bytes()); let reader = Cursor::new(bytes); - let Ok(packet) = decode(reader, compound_id, &index) else { + let Ok(packet) = decode(reader, Some(compound_id), &index) else { unreachable!("decoding a well-formed protection frame should succeed"); }; @@ -164,6 +186,30 @@ mod tests { 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( @@ -176,7 +222,7 @@ mod tests { let unknown_id = AdjId((2 << 13) | 100); assert!(matches!( - decode(Cursor::new(vec![]), unknown_id, &index), + decode(Cursor::new(vec![]), Some(unknown_id), &index), Err(DecodeError::UnknownId(id)) if id == unknown_id )); } @@ -209,7 +255,7 @@ mod tests { let index = Index::new(&pod_data); assert!(matches!( - decode(Cursor::new(vec![]), compound_id, &index), + decode(Cursor::new(vec![]), Some(compound_id), &index), Err(DecodeError::WrongKind { id }) if id == compound_id )); } @@ -228,7 +274,7 @@ mod tests { let reader = Cursor::new(vec![0u8; 6]); assert!(matches!( - decode(reader, compound_id, &index), + decode(reader, Some(compound_id), &index), Err(DecodeError::Io(_)) )); } @@ -259,7 +305,9 @@ mod tests { let Ok(bytes) = encode(&original, &index) else { unreachable!("encoding a well-formed protection packet should succeed"); }; - let Ok(decoded) = decode(Cursor::new(bytes), compound_id, &index) else { + // `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"); }; @@ -291,8 +339,9 @@ mod tests { unreachable!("encoding a well-formed protection packet should succeed"); }; - assert_eq!(bytes[4], 0); - assert_eq!(bytes[5], 0); + // bytes[0..2] = id, [2..6] = timestamp, [6..8] = resv. + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); } #[test] From 57197879777782a5a4b551bb208f8136690cff73 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:04:59 +0200 Subject: [PATCH 28/34] fix(backend, adj-model): read messages id as a proper package --- backend/crates/core/src/adj/README.md | 26 ++++++++++++- backend/crates/core/src/adj/info.rs | 20 +++++++++- backend/crates/core/src/adj/raw/types.rs | 38 ++++++++++++++---- backend/crates/core/src/model/README.md | 7 ++++ backend/crates/core/src/model/packet.rs | 49 ++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index 33bdedb49..f1bda8b73 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -30,10 +30,34 @@ Adj └── 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()` (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. + --- ## Why It Is Structured This Way 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/types.rs b/backend/crates/core/src/adj/raw/types.rs index 7b02ef51c..3942a3bc7 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; @@ -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() @@ -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] diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index aa088648c..8d1e190d0 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -188,6 +188,13 @@ Informational message, no action implied. #### `Panic` An unrecoverable firmware error. +`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. diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 56b9c60e8..d53c83df3 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -175,6 +175,41 @@ 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 { @@ -273,6 +308,20 @@ 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 From e3aa05ef6a9f13972f8565eec636c14ff0e672f0 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:26:07 +0200 Subject: [PATCH 29/34] fix(backend, adj, model): define messages packets in the same way as protections and datapackets --- backend/crates/core/src/adj/README.md | 24 ++++++++++--- backend/crates/core/src/adj/error.rs | 10 ++++++ backend/crates/core/src/adj/raw/loader.rs | 41 +++++++++++++++++++++-- backend/crates/core/src/model/README.md | 8 ++--- backend/crates/core/src/model/board.rs | 10 +++--- backend/crates/core/src/model/id.rs | 7 ++++ backend/crates/core/src/model/packet.rs | 11 ++++-- 7 files changed, 95 insertions(+), 16 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index f1bda8b73..815cbb6e1 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -23,7 +23,10 @@ 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) @@ -54,9 +57,22 @@ let message_levels: HashMap = MessageLevel::ALL ``` Which ADJ name corresponds to which `MessageLevel` is **not** decided -here — `level.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. +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 { kind: PacketKind::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. --- 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/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 6f252d48c..53390d252 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, PacketKind, PodData, }; use serde::de::DeserializeOwned; use std::collections::HashMap; @@ -143,7 +143,16 @@ fn build_packet_def( &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" => PacketKind::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(), @@ -264,6 +273,34 @@ mod tests { )); } + #[test] + fn build_packet_def_resolves_a_message_packets_severity_from_its_name() { + let raw = RawPacket { + id: 2, + kind: "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.kind, PacketKind::Message(MessageLevel::Fault)); + } + + #[test] + fn build_packet_def_rejects_a_message_whose_name_isnt_a_severity() { + let raw = RawPacket { + id: 2, + kind: "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"); diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index 8d1e190d0..eaa09367e 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -15,8 +15,8 @@ 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 `PacketKind`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what kind 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. @@ -198,8 +198,8 @@ This is deliberately where the name↔severity association lives — in `model`, ### 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 (`PacketKind::Message`/`protocol::Index`), not storing a second, redundant copy of it here. #### `timestamp` When the message was sent (`ShortTimestamp`). #### `origin` diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 18cef3c3e..b00e3a82c 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -10,7 +10,7 @@ //! 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; @@ -29,8 +29,10 @@ 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 { @@ -41,7 +43,7 @@ impl PacketKind { match self { PacketKind::Data(measurements) | PacketKind::Order(measurements) => measurements, PacketKind::Protection(measurement) => std::slice::from_ref(measurement), - PacketKind::Message => &[], + PacketKind::Message(_) => &[], } } } diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index a8278993d..948e71b98 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -31,6 +31,13 @@ impl AdjId { } /// 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); diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index d53c83df3..948d77834 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -213,8 +213,15 @@ impl MessageLevel { /// 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 + /// [`PacketKind::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. From cfa789cb909f615be79eb2edbdbfb12923c1d031 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:56:12 +0200 Subject: [PATCH 30/34] feat(backend, protocol): allow index to index packets --- backend/crates/core/src/protocol/index.rs | 64 ++++++++++++++++--- .../core/src/protocol/packet/data_order.rs | 2 +- .../core/src/protocol/packet/protection.rs | 6 +- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs index c23397bf8..117802a3a 100644 --- a/backend/crates/core/src/protocol/index.rs +++ b/backend/crates/core/src/protocol/index.rs @@ -1,10 +1,11 @@ //! The `id -> PacketDef` lookup `tcp`/`udp` decode against. -use crate::model::{AdjId, PacketDef, PodData}; +use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketKind, PodData}; use std::collections::HashMap; /// A lookup table from a packet's [`AdjId`] to its declaration, built once -/// from a loaded ADJ's [`PodData`]. +/// 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 @@ -19,18 +20,47 @@ pub struct Index { impl Index { /// Builds an index over every packet declared by every board in - /// `pod_data`. - pub fn new(pod_data: &PodData) -> Self { - let by_id = pod_data + /// `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(), + kind: PacketKind::Message(level), + }, + ); + } + } + Self { by_id } } - /// Looks up a packet's declaration by id. + /// Looks up a packet's declaration by id — a message severity + /// resolves through here too, as a synthesized `PacketDef` with + /// `kind: PacketKind::Message(level)`; see this type's doc comment. pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { self.by_id.get(&id) } @@ -63,7 +93,7 @@ mod tests { #[test] fn finds_a_packet_declared_on_any_board() { - let index = Index::new(&sample_pod_data()); + 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"); }; @@ -72,7 +102,25 @@ mod tests { #[test] fn unknown_id_is_not_found() { - let index = Index::new(&sample_pod_data()); + 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.kind, PacketKind::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/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index d4643a050..c01971d35 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -156,7 +156,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - Index::new(&pod_data) + Index::new(&pod_data, None) } // --- ENCODE TESTS --- diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index b3d99b2d0..29d171356 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -157,7 +157,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - (Index::new(&pod_data), compound_id) + (Index::new(&pod_data, None), compound_id) } #[test] @@ -252,7 +252,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - let index = Index::new(&pod_data); + let index = Index::new(&pod_data, None); assert!(matches!( decode(Cursor::new(vec![]), Some(compound_id), &index), @@ -397,7 +397,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - let index = Index::new(&pod_data); + let index = Index::new(&pod_data, None); let packet = ProtectionPacket { id: compound_id, From cc65fbef2ab669ee88c3a69b346464e7d6bbfa63 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:59:03 +0200 Subject: [PATCH 31/34] feat(backend, protocol): messages decode & encode --- backend/crates/core/src/protocol/README.md | 102 +++- backend/crates/core/src/protocol/error.rs | 18 + backend/crates/core/src/protocol/packet.rs | 19 +- .../crates/core/src/protocol/packet/README.md | 90 ++++ .../core/src/protocol/packet/message.rs | 459 +++++++++++++++++- 5 files changed, 648 insertions(+), 40 deletions(-) create mode 100644 backend/crates/core/src/protocol/packet/README.md diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md index 1d81566f4..a62b38c26 100644 --- a/backend/crates/core/src/protocol/README.md +++ b/backend/crates/core/src/protocol/README.md @@ -30,8 +30,8 @@ protocol/ ├── 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 (stub) -│ └── protection.rs # Protection trigger packets (stub) +│ ├── message.rs # Free-text log message packets +│ └── protection.rs # Protection trigger packets ├── tcp.rs # TCP transport framing (stub) └── udp.rs # UDP transport framing ``` @@ -42,8 +42,14 @@ protocol/ 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 { kind: PacketKind::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 `PacketKind::Message(level)`, exactly like `packet::protection` already does for `PacketKind::Protection`. + **Key types:** -- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` +- `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 @@ -56,6 +62,7 @@ Defines errors that can occur during packet encoding and decoding. - `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 - `WrongKind` - Packet exists but isn't expected kind - `EncodeError` - Errors when converting packets to bytes @@ -64,6 +71,8 @@ Defines errors that can occur during packet encoding and decoding. - `TypeMismatch` - Value doesn't match declared type - `OutOfRange` - Numeric value doesn't fit wire type - `WrongKind` - Packet exists but isn't expected kind + - `StringTooLong` - String field + terminator doesn't fit the max length + - `EmbeddedNulByte` - String field contains a `\0` byte ### `wire.rs` - Low-Level Byte Codec @@ -77,29 +86,73 @@ Handles conversion between raw bytes and `Value` types for individual measuremen **Supported types:** - **Numeric** (U8, U16, U32, U64, I8, I16, I32, I64, F32, F64) - ✓ Implemented - **Enum** (single byte index) - ✓ Implemented -- **Boolean** - ⚠ Stub (wire width not yet confirmed with firmware) +- **Boolean** (single byte, zero = `false`, nonzero = `true`) - ✓ Implemented ### `packet/` - Packet-Level Encoding/Decoding -Contains per-packet-kind encoding and decoding logic, independent of transport. +Contains per-packet-kind encoding and decoding logic, independent of +transport. **Hard rule for this directory: all byte-level parsing for a +packet kind 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 two packet kinds: -- **Order packets** (commands sent to boards) - Encoded only, sent over TCP -- **Data packets** (periodic telemetry from boards) - Decoded only, received over UDP +Handles the positional Data/Order wire shape shared by both packet kinds +(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(order, index)` - Encode an Order packet into bytes -- `decode(bytes, index)` - Decode a Data packet from bytes +- `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 +`PacketKind::Message` (`UnknownId`/`WrongKind` 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. -#### `message.rs` ⚠ Stub +**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 free-text log messages from boards over TCP. Not yet implemented. +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. -#### `protection.rs` ⚠ Stub +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. -Decodes protection trigger events from boards over TCP. Not yet implemented. +**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 ⚠ Partial @@ -128,8 +181,8 @@ Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete |-------------|-----------|-----------|--------| | Data (telemetry) | Board → Backend | UDP | ✓ Implemented | | Order (command) | Backend → Board | TCP | ✓ Implemented | -| Message (log) | Board → Backend | TCP | ⚠ Stub | -| Protection (event) | Board → Backend | TCP | ⚠ Stub | +| 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 @@ -137,19 +190,20 @@ Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete - ✅ **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** - Stub (awaiting firmware confirmation) -- ⚠️ **Message packets** - Stub (decode not implemented) -- ⚠️ **Protection packets** - Stub (decode not implemented) +- ✅ **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** - Partial (decode_next not implemented) ## Testing The module includes comprehensive unit tests for: -- Wire-level encoding/decoding for all numeric types -- Enum encoding/decoding with boundary checks -- Order packet encoding with field validation -- Data packet decoding with error handling -- Error propagation for unknown IDs, wrong kinds, and type mismatches +- 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 kinds, type mismatches, + unterminated/invalid strings, and out-of-range values Run tests with: ```bash diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs index 1ffd3b638..b08a065a6 100644 --- a/backend/crates/core/src/protocol/error.rs +++ b/backend/crates/core/src/protocol/error.rs @@ -36,6 +36,10 @@ pub enum DecodeError { 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 kind this function expects /// (e.g. `packet::data::decode` found an id that resolves to an /// `Order`). @@ -65,6 +69,7 @@ impl PartialEq for DecodeError { }, ) => 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::WrongKind { id: a }, Self::WrongKind { id: b }) => a == b, _ => false, } @@ -114,4 +119,17 @@ pub enum EncodeError { /// The packet whose kind 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/packet.rs b/backend/crates/core/src/protocol/packet.rs index 7f357df8f..11b2847a8 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -1,10 +1,19 @@ //! Encode/decode for one specific kind of packet, independent of which //! transport carries it — that mapping lives in [`super::tcp`]/ -//! [`super::udp`], which are thin wrappers around these. Each kind only -//! implements whichever direction it actually needs: a `Data` packet is -//! only ever received (over UDP), an `Order` only ever sent (over TCP), -//! so there's no `encode` for the former or `decode` for the latter. +//! [`super::udp`], which are thin wrappers around these. +//! +//! [`data_order`]'s `encode`/`decode` are generic over `Kind` +//! ([`crate::model::KindTag`]), so they work for both `Data` and `Order` +//! alike; the backend itself only ever exercises one direction per kind +//! (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; -pub mod data_order; 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..fce1586c9 --- /dev/null +++ b/backend/crates/core/src/protocol/packet/README.md @@ -0,0 +1,90 @@ +# `packet/` — Per-Packet-Kind Codecs + +Each file here owns **all** the byte-level parsing for one packet kind — +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::KindTag`), 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` (still unimplemented). 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 + `PacketKind::Protection`/`PacketKind::Message` — to confirm it's + actually the declared kind (`UnknownId`/`WrongKind` 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 `PacketKind::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 `PacketKind::Message(level)` to confirm the kind 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 kind** — `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 kind), 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 is not decoding — +nothing is validated or interpreted by it beyond "is the top bit of +`AdjId::protection_position()` set" — 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/message.rs b/backend/crates/core/src/protocol/packet/message.rs index 88f70bd9c..eb9f1986b 100644 --- a/backend/crates/core/src/protocol/packet/message.rs +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -1,16 +1,453 @@ //! Decodes a `Message` packet: a board's free-text log line. Only ever -//! received, over TCP. +//! 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::PacketKind::Message`] (so a +//! `WrongKind`/`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::{MessageLevel, MessagePacket}; -use crate::protocol::DecodeError; +use crate::model::{AdjId, MessagePacket, PacketKind, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index}; use std::io::Read; -/// Decodes a message frame's remaining bytes, given the frame's -/// already-resolved severity level (`tcp::decode_next`'s job). -pub fn decode(reader: impl Read, level: MessageLevel) -> Result { - let _ = (reader, level); - todo!( - "read the timestamp, then the null-terminated `origin` (max 48 \ - bytes) and `message` (max 320 bytes) strings" - ) +// `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 => 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 PacketKind::Message(_) = &packet_def.kind else { + return Err(DecodeError::WrongKind { 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 a raw little-endian 16-bit id off the front of `reader`. The +/// one piece of byte parsing [`decode`]/[`encode`] share, so it only +/// lives here once instead of wherever else might need an `AdjId`. +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))) +} + +/// 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 PacketKind::Message(_) = &packet_def.kind else { + return Err(EncodeError::WrongKind { 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(), + kind: PacketKind::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::WrongKind { 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_kind() { + let data_id = AdjId(2000); + let packet_def = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + kind: PacketKind::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::WrongKind { 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); + } } From 2a11b67e0bd7f83905851e71e8cedd54be027828 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 13 Oct 2026 00:05:47 +0200 Subject: [PATCH 32/34] fix(backend, protocol): remove duplicated function --- backend/crates/core/src/protocol/packet.rs | 17 +++++++++++++++++ .../crates/core/src/protocol/packet/message.rs | 11 +---------- .../core/src/protocol/packet/protection.rs | 11 +---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 11b2847a8..d0f49d3b8 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -17,3 +17,20 @@ 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 kinds 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). +/// [`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. +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/message.rs b/backend/crates/core/src/protocol/packet/message.rs index eb9f1986b..b1b776a62 100644 --- a/backend/crates/core/src/protocol/packet/message.rs +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -51,7 +51,7 @@ pub fn decode( ) -> Result { let id = match id { Some(id) => id, - None => read_id(&mut reader)?, + None => super::read_id(&mut reader)?, }; // Look up the packet definition for this id, and ensure it's a @@ -82,15 +82,6 @@ pub fn decode( }) } -/// Reads a raw little-endian 16-bit id off the front of `reader`. The -/// one piece of byte parsing [`decode`]/[`encode`] share, so it only -/// lives here once instead of wherever else might need an `AdjId`. -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))) -} - /// 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. diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index 29d171356..fdb36768d 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -39,7 +39,7 @@ pub fn decode( ) -> Result { let compound_id = match compound_id { Some(id) => id, - None => read_id(&mut reader)?, + None => super::read_id(&mut reader)?, }; // Look up the packet definition for this compound id, and ensure it's a @@ -76,15 +76,6 @@ pub fn decode( }) } -/// Reads a raw little-endian 16-bit id off the front of `reader`. The -/// one piece of byte parsing [`decode`]/[`encode`] share, so it only -/// lives here once instead of wherever else might need an `AdjId`. -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))) -} - /// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects /// — a complete, self-contained frame, leading id included. /// From 80d34b3d19569b1bcf792be6dba0c46f52b98bea Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 13 Oct 2026 00:19:37 +0200 Subject: [PATCH 33/34] feat(backend, protocol): tcp decode --- backend/crates/core/src/protocol/README.md | 10 +- backend/crates/core/src/protocol/packet.rs | 13 +- .../crates/core/src/protocol/packet/README.md | 20 ++- backend/crates/core/src/protocol/tcp.rs | 141 ++++++++++++++++-- 4 files changed, 154 insertions(+), 30 deletions(-) diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md index a62b38c26..400359608 100644 --- a/backend/crates/core/src/protocol/README.md +++ b/backend/crates/core/src/protocol/README.md @@ -32,7 +32,7 @@ protocol/ │ ├── 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 (stub) +├── tcp.rs # TCP transport framing └── udp.rs # UDP transport framing ``` @@ -154,18 +154,20 @@ bytes. - `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 ⚠ Partial +### `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`/`WrongKind` against `index` itself. + **Key types:** - `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) **Key functions:** -- `decode_next(reader, index)` - ⚠ Stub - Decode the next frame from a TCP stream +- `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 @@ -193,7 +195,7 @@ Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete - ✅ **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** - Partial (decode_next not implemented) +- ✅ **TCP framing** - `decode_next` implemented and tested (dispatches to protection/message by `id.0 >= 8192`) ## Testing diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index d0f49d3b8..df7842292 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -26,10 +26,15 @@ use std::io::Read; /// /// Shared by [`message`]/[`protection`] — the only two kinds 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). -/// [`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. -fn read_id(reader: &mut impl Read) -> Result { +/// 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 index fce1586c9..668e8532b 100644 --- a/backend/crates/core/src/protocol/packet/README.md +++ b/backend/crates/core/src/protocol/packet/README.md @@ -22,9 +22,9 @@ see its own module doc and `model/README.md`'s §4.2 for why. 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` (still unimplemented). To keep that -reality from leaking actual decoding logic into `tcp.rs`, both files -take their id as `Option` — the exact same shape: +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 @@ -76,11 +76,15 @@ 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 is not decoding — -nothing is validated or interpreted by it beyond "is the top bit of -`AdjId::protection_position()` set" — and its result is immediately -handed to the matching function as `Some(id)`, which then resolves it -against `index` itself and does everything else. +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 diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index 9d4169087..a2d645944 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -26,23 +26,136 @@ pub enum TcpFrame { /// Reads and decodes the next complete frame from `reader`, resolving /// field shapes against `index`. /// -/// Reads the frame's leading 16-bit id first, then decides which of -/// [`super::packet::protection::decode`] ([`crate::model::AdjId::protection_position`] -/// is non-zero) or [`super::packet::message::decode`] (the id matches one -/// of `general_info.json`'s `message_ids`) to hand the rest of the frame -/// to, wrapping the result in the matching [`TcpFrame`] variant. -pub fn decode_next(reader: impl Read, index: &Index) -> Result { - let _ = (reader, index); - todo!( - "read the leading 16-bit id; if its AdjId::protection_position() is \ - non-zero, call packet::protection::decode; otherwise resolve it \ - against general_info.json's message_ids (not yet reachable from \ - `Index` — needs AdjInfo too) and call packet::message::decode; \ - wrap either result in the matching TcpFrame variant" - ) +/// 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`/`WrongKind` 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, PacketKind, 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(), + kind: PacketKind::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))); + } +} From 8d7b6b52337ad91420375f710886d4ec3564ef3b Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 13 Oct 2026 14:15:12 +0200 Subject: [PATCH 34/34] refactor(backend): change PacketKind for Pakcet .type --- backend/crates/core/src/adj/README.md | 4 +- backend/crates/core/src/adj/raw/loader.rs | 34 ++++----- backend/crates/core/src/adj/raw/types.rs | 6 +- backend/crates/core/src/model.rs | 6 +- backend/crates/core/src/model/README.md | 30 ++++---- backend/crates/core/src/model/board.rs | 26 +++---- backend/crates/core/src/model/packet.rs | 36 ++++----- backend/crates/core/src/protocol.rs | 2 +- backend/crates/core/src/protocol/README.md | 18 ++--- backend/crates/core/src/protocol/error.rs | 18 ++--- backend/crates/core/src/protocol/index.rs | 13 ++-- backend/crates/core/src/protocol/packet.rs | 12 +-- .../crates/core/src/protocol/packet/README.md | 18 ++--- .../core/src/protocol/packet/data_order.rs | 76 +++++++++---------- .../core/src/protocol/packet/message.rs | 28 +++---- .../core/src/protocol/packet/protection.rs | 26 +++---- backend/crates/core/src/protocol/tcp.rs | 11 +-- 17 files changed, 183 insertions(+), 181 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index 815cbb6e1..c167bdfbc 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -67,7 +67,7 @@ doesn't hardcode `"fault"`/`"warning"`/`"info"`/`"panic"` itself. `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 { kind: PacketKind::Message(level), board: BoardId(0), .. }` +`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 @@ -89,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/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 53390d252..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, MessageLevel, PacketDef, PacketKind, PodData, + AdjId, Board, BoardId, MacAddress, Measurement, MessageLevel, PacketDef, PacketType, PodData, }; use serde::de::DeserializeOwned; use std::collections::HashMap; @@ -82,7 +82,7 @@ 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, @@ -147,7 +147,7 @@ fn build_packet_def( // 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" => PacketKind::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { + "message" => PacketType::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { LoadError::UnknownMessageSeverity { board: board.to_string(), name: raw.name.clone(), @@ -166,7 +166,7 @@ fn build_packet_def( id: AdjId(raw.id), board: board_id, name: raw.name, - kind, + packet_type, }) } @@ -217,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; @@ -249,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![], }; @@ -263,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()], }; @@ -277,21 +277,21 @@ mod tests { fn build_packet_def_resolves_a_message_packets_severity_from_its_name() { let raw = RawPacket { id: 2, - kind: "message".to_string(), + 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.kind, PacketKind::Message(MessageLevel::Fault)); + 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, - kind: "message".to_string(), + packet_type: "message".to_string(), name: "not_a_severity".to_string(), variables: vec![], }; @@ -326,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"); }; @@ -343,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"); @@ -375,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 3942a3bc7..bac40de9f 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -79,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, @@ -306,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( @@ -623,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/model.rs b/backend/crates/core/src/model.rs index 04f933cb3..75cdb319e 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -17,12 +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::{ - Data, DataOrderPacket, DataPacket, KindTag, MessageLevel, MessagePacket, Order, 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 eaa09367e..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. @@ -16,9 +16,9 @@ The single measurement one of a board's declared protections watches. Always exa #### `Order(Vec)` An order sent to a board. Many orders take no parameters at all, so this can be empty. #### `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 `PacketKind`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what kind of packet is this" as a `Data` packet's measurement list is. +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. @@ -139,16 +139,16 @@ pub struct DataOrderPacket { #### `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`. -#### `KindTag` -The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `KindTag` is how code that's generic over `Kind` answers that anyway, by asking the question of the *type* instead of the *value*: +#### `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 KindTag { - fn matches(kind: &PacketKind) -> bool; +pub trait PacketTypeTag { + fn matches(packet_type: &PacketType) -> bool; } ``` -`Data`/`Order` each implement it by checking which `PacketKind` (§1.1) variant they stand for (`matches!(kind, PacketKind::Data(_))` / `PacketKind::Order(_)`). This is the only thing `KindTag` decides — once a `PacketDef`'s kind is confirmed to match, the actual measurement list still comes from the existing `PacketKind::measurements()` (§1.1), not from `KindTag` itself. +`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 kind. +`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. #### `DataPacket` / `OrderPacket` ```rust @@ -157,9 +157,9 @@ 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 `PacketKind::Data` list. +`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 `PacketKind::Order`'s measurement list. +`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketType::Order`'s measurement list. #### `id` Which packet/order definition this is an instance of (`AdjId`). @@ -199,7 +199,7 @@ This is deliberately where the name↔severity association lives — in `model`, A free-text message sent by a board. #### `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 (`PacketKind::Message`/`protocol::Index`), not storing a second, redundant copy of it here. +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` diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index b00e3a82c..2dc56b6b7 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -14,13 +14,13 @@ 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), @@ -35,15 +35,15 @@ pub enum PacketKind { 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(_) => &[], } } } @@ -66,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. @@ -132,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()]), }, )]), }; @@ -141,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/packet.rs b/backend/crates/core/src/model/packet.rs index 948d77834..c3dda408d 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -6,7 +6,7 @@ //! packet or a protection *can* look like, or [`super::PacketDef`], which //! describes a whole packet. -use super::{AdjId, PacketKind, Value}; +use super::{AdjId, PacketType, Value}; use std::marker::PhantomData; /// A short timestamp carrying only the time of day: hour, minute, second @@ -48,30 +48,30 @@ pub struct Data; pub struct Order; /// Bridges a [`DataOrderPacket`]'s phantom `Kind` to the runtime -/// [`PacketKind`] variant it stands for. +/// [`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 kind. This trait is the +/// 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 kind actually the one I was asked to +/// "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 [`PacketKind::measurements`], -/// kind-agnostic, so there's no need to redo that here. -pub trait KindTag { - /// Whether `kind` is the variant this `Kind` stands for. - fn matches(kind: &PacketKind) -> bool; +/// 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 KindTag for Data { - fn matches(kind: &PacketKind) -> bool { - matches!(kind, PacketKind::Data(_)) +impl PacketTypeTag for Data { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Data(_)) } } -impl KindTag for Order { - fn matches(kind: &PacketKind) -> bool { - matches!(kind, PacketKind::Order(_)) +impl PacketTypeTag for Order { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Order(_)) } } @@ -81,7 +81,7 @@ impl KindTag for Order { /// 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. /// @@ -110,7 +110,7 @@ pub struct DataOrderPacket { /// The packet's id. pub id: AdjId, /// Each measurement's decoded value, in the same order as the - /// matching [`super::PacketKind::Data`]/[`super::PacketKind::Order`] + /// matching [`super::PacketType::Data`]/[`super::PacketType::Order`] /// list. pub values: Vec, _marker: PhantomData, @@ -219,7 +219,7 @@ pub struct MessagePacket { /// 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 - /// [`PacketKind::Message`]/[`crate::protocol::Index`]), not storing + /// [`PacketType::Message`]/[`crate::protocol::Index`]), not storing /// a second, redundant copy of it here. pub id: AdjId, /// When it was sent. diff --git a/backend/crates/core/src/protocol.rs b/backend/crates/core/src/protocol.rs index 4ee7cfdd1..e89fe6064 100644 --- a/backend/crates/core/src/protocol.rs +++ b/backend/crates/core/src/protocol.rs @@ -11,7 +11,7 @@ //! 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-kind work independent of transport. +//! does the actual per-packet-type work independent of transport. mod error; mod index; diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md index 400359608..f107da5d2 100644 --- a/backend/crates/core/src/protocol/README.md +++ b/backend/crates/core/src/protocol/README.md @@ -42,7 +42,7 @@ protocol/ 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 { kind: PacketKind::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 `PacketKind::Message(level)`, exactly like `packet::protection` already does for `PacketKind::Protection`. +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) @@ -63,14 +63,14 @@ Defines errors that can occur during packet encoding and decoding. - `InvalidEnumValue` - Enum index out of range - `UnterminatedString` - String field not null-terminated - `InvalidUtf8` - String field's bytes weren't valid UTF-8 - - `WrongKind` - Packet exists but isn't expected kind + - `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 - - `WrongKind` - Packet exists but isn't expected kind + - `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 @@ -90,16 +90,16 @@ Handles conversion between raw bytes and `Value` types for individual measuremen ### `packet/` - Packet-Level Encoding/Decoding -Contains per-packet-kind encoding and decoding logic, independent of +Contains per-packet-type encoding and decoding logic, independent of transport. **Hard rule for this directory: all byte-level parsing for a -packet kind lives in its own file here, never in `tcp.rs`/`udp.rs`.** +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 kinds +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 @@ -128,7 +128,7 @@ 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 -`PacketKind::Message` (`UnknownId`/`WrongKind` otherwise) — but unlike +`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. @@ -161,7 +161,7 @@ Handles the dedicated TCP connection to each board. A TCP connection carries: - 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`/`WrongKind` against `index` itself. +`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) @@ -204,7 +204,7 @@ The module includes comprehensive unit tests for: - 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 kinds, type mismatches, +- Error propagation for unknown IDs, wrong packet types, type mismatches, unterminated/invalid strings, and out-of-range values Run tests with: diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs index b08a065a6..b6de39dea 100644 --- a/backend/crates/core/src/protocol/error.rs +++ b/backend/crates/core/src/protocol/error.rs @@ -40,12 +40,12 @@ pub enum DecodeError { #[error("string field wasn't valid UTF-8: {0:?}")] InvalidUtf8(Vec), - /// A looked-up packet exists, but isn't the kind this function expects + /// 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 kind")] - WrongKind { - /// The packet whose kind didn't match. + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. id: AdjId, }, } @@ -70,7 +70,7 @@ impl PartialEq for DecodeError { ) => 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::WrongKind { id: a }, Self::WrongKind { id: b }) => a == b, + (Self::WrongPacketType { id: a }, Self::WrongPacketType { id: b }) => a == b, _ => false, } } @@ -111,12 +111,12 @@ pub enum EncodeError { max: f64, }, - /// A looked-up packet exists, but isn't the kind this function expects + /// 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 kind")] - WrongKind { - /// The packet whose kind didn't match. + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. id: AdjId, }, diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs index 117802a3a..0f9d5a5bd 100644 --- a/backend/crates/core/src/protocol/index.rs +++ b/backend/crates/core/src/protocol/index.rs @@ -1,6 +1,6 @@ //! The `id -> PacketDef` lookup `tcp`/`udp` decode against. -use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketKind, PodData}; +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 @@ -49,7 +49,7 @@ impl Index { id, board: BoardId(0), name: level.name().to_string(), - kind: PacketKind::Message(level), + packet_type: PacketType::Message(level), }, ); } @@ -60,7 +60,8 @@ impl Index { /// Looks up a packet's declaration by id — a message severity /// resolves through here too, as a synthesized `PacketDef` with - /// `kind: PacketKind::Message(level)`; see this type's doc comment. + /// `packet_type: PacketType::Message(level)`; see this type's doc + /// comment. pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { self.by_id.get(&id) } @@ -69,7 +70,7 @@ impl Index { #[cfg(test)] mod tests { use super::*; - use crate::model::{Board, BoardId, MacAddress, PacketKind}; + use crate::model::{Board, BoardId, MacAddress, PacketType}; use std::net::IpAddr; fn sample_pod_data() -> PodData { @@ -77,7 +78,7 @@ mod tests { id: AdjId(1000), board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![]), + packet_type: PacketType::Data(vec![]), }; let board = Board { id: BoardId(1), @@ -114,7 +115,7 @@ mod tests { let Some(packet) = index.packet(AdjId(2)) else { unreachable!("the \"fault\" severity was declared at id 2"); }; - assert_eq!(packet.kind, PacketKind::Message(MessageLevel::Fault)); + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); assert_eq!(packet.board, BoardId(0)); } diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index df7842292..3e061e44d 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -1,12 +1,12 @@ -//! Encode/decode for one specific kind of packet, independent of which +//! 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::KindTag`]), so they work for both `Data` and `Order` -//! alike; the backend itself only ever exercises one direction per kind -//! (an order is only ever sent, data only ever received), but that's a -//! restriction `tcp`/`udp` apply, not something baked into the codec. +//! ([`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 — @@ -24,7 +24,7 @@ use std::io::Read; /// Reads a raw little-endian 16-bit id off the front of `reader`. /// -/// Shared by [`message`]/[`protection`] — the only two kinds whose +/// 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 diff --git a/backend/crates/core/src/protocol/packet/README.md b/backend/crates/core/src/protocol/packet/README.md index 668e8532b..b55d7556b 100644 --- a/backend/crates/core/src/protocol/packet/README.md +++ b/backend/crates/core/src/protocol/packet/README.md @@ -1,6 +1,6 @@ -# `packet/` — Per-Packet-Kind Codecs +# `packet/` — Per-Packet-Type Codecs -Each file here owns **all** the byte-level parsing for one packet kind — +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 @@ -14,7 +14,7 @@ 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::KindTag`), written once for both `Data` and `Order` — +`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` @@ -44,27 +44,27 @@ pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeEr 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 - `PacketKind::Protection`/`PacketKind::Message` — to confirm it's - actually the declared kind (`UnknownId`/`WrongKind` otherwise). A + `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 `PacketKind::Protection(measurement)`, to decode at +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 `PacketKind::Message(level)` to confirm the kind and discards +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 kind** — `tcp.rs` never touches a timestamp, a `resv` +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 kind), so its output is +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. diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index c01971d35..caca99161 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -3,21 +3,21 @@ //! shape under a phantom `Kind`. //! //! [`encode`]/[`decode`] are generic over that `Kind` via -//! [`crate::model::KindTag`], 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::KindTag`] only -//! answers whether a `PacketDef`'s kind matches `Kind` at all; the actual -//! measurement list comes from [`crate::model::PacketKind::measurements`] -//! either way. - -use crate::model::{AdjId, DataOrderPacket, KindTag}; +//! [`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( +pub fn encode( packet: &DataOrderPacket, index: &Index, ) -> Result, EncodeError> { @@ -26,13 +26,13 @@ pub fn encode( .packet(packet.id) .ok_or(EncodeError::UnknownId(packet.id))?; - // Check that the packet is an order or data, as expected by the generic KindTag - if !Kind::matches(&packet_def.kind) { - return Err(EncodeError::WrongKind { id: 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.kind.measurements(); + 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() { @@ -60,7 +60,7 @@ pub fn encode( /// Decodes a complete, already-delimited buffer into a /// [`DataOrderPacket`], resolving its field shapes against `index`. -pub fn decode( +pub fn decode( bytes: &[u8], index: &Index, ) -> Result, DecodeError> { @@ -80,12 +80,12 @@ pub fn decode( // 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 KindTag - if !Kind::matches(&packet_def.kind) { - return Err(DecodeError::WrongKind { id: 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.kind.measurements(); + 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()); @@ -117,7 +117,7 @@ mod tests { use super::*; use crate::model::{ Board, BoardId, Data, DataPacket, MacAddress, Measurement, MeasurementKind, NumericKind, - Order, OrderPacket, PacketDef, PacketKind, PodData, Value, + Order, OrderPacket, PacketDef, PacketType, PodData, Value, }; use std::collections::HashMap; use std::net::IpAddr; @@ -135,12 +135,12 @@ mod tests { } } - fn sample_packet_def(id: AdjId, kind: PacketKind) -> PacketDef { + fn sample_packet_def(id: AdjId, packet_type: PacketType) -> PacketDef { PacketDef { id, board: BoardId(1), name: "TestPacket".into(), - kind, + packet_type, } } @@ -166,7 +166,7 @@ mod tests { let order_id = AdjId(1001); let packet_def = sample_packet_def( order_id, - PacketKind::Order(vec![ + PacketType::Order(vec![ sample_measurement( 1, MeasurementKind::Numeric { @@ -208,17 +208,17 @@ mod tests { } #[test] - fn encode_fails_on_wrong_kind() { + fn encode_fails_on_wrong_packet_type() { let data_id = AdjId(2001); - let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); + 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 kind should fail"); + unreachable!("encoding the wrong packet type should fail"); }; - assert_eq!(err, EncodeError::WrongKind { id: data_id }); + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); } #[test] @@ -226,7 +226,7 @@ mod tests { let order_id = AdjId(1002); let packet_def = sample_packet_def( order_id, - PacketKind::Order(vec![ + PacketType::Order(vec![ sample_measurement( 1, MeasurementKind::Numeric { @@ -264,7 +264,7 @@ mod tests { let data_id = AdjId(3001); let packet_def = sample_packet_def( data_id, - PacketKind::Data(vec![ + PacketType::Data(vec![ sample_measurement( 1, MeasurementKind::Numeric { @@ -312,7 +312,7 @@ mod tests { let data_id = AdjId(3002); let packet_def = sample_packet_def( data_id, - PacketKind::Data(vec![sample_measurement( + PacketType::Data(vec![sample_measurement( 1, MeasurementKind::Numeric { wire_type: NumericKind::U32, @@ -341,17 +341,17 @@ mod tests { } #[test] - fn decode_fails_on_wrong_kind() { + fn decode_fails_on_wrong_packet_type() { let order_id = AdjId(4001); - let packet_def = sample_packet_def(order_id, PacketKind::Order(vec![])); + 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 kind should fail"); + unreachable!("decoding the wrong packet type should fail"); }; - assert_eq!(err, DecodeError::WrongKind { id: order_id }); + assert_eq!(err, DecodeError::WrongPacketType { id: order_id }); } // --- ROUND-TRIP TESTS --- @@ -359,7 +359,7 @@ mod 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 kind. + // 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 @@ -389,7 +389,7 @@ mod tests { let id = AdjId(6001); let index = make_test_index(vec![sample_packet_def( id, - PacketKind::Order(numeric_measurements()), + PacketType::Order(numeric_measurements()), )]); let original = OrderPacket::new(id, vec![Value::Number(1234.0), Value::Number(-5.0)]); @@ -409,7 +409,7 @@ mod tests { let id = AdjId(6002); let index = make_test_index(vec![sample_packet_def( id, - PacketKind::Data(numeric_measurements()), + PacketType::Data(numeric_measurements()), )]); let original = DataPacket::new(id, vec![Value::Number(42.0), Value::Number(-1.0)]); diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs index b1b776a62..ec20e16e3 100644 --- a/backend/crates/core/src/protocol/packet/message.rs +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -24,12 +24,12 @@ //! //! 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::PacketKind::Message`] (so a -//! `WrongKind`/`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. +//! 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, PacketKind, ShortTimestamp}; +use crate::model::{AdjId, MessagePacket, PacketType, ShortTimestamp}; use crate::protocol::{DecodeError, EncodeError, Index}; use std::io::Read; @@ -58,8 +58,8 @@ pub fn decode( // 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 PacketKind::Message(_) = &packet_def.kind else { - return Err(DecodeError::WrongKind { id }); + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id }); }; let mut header = [0u8; 4]; @@ -113,8 +113,8 @@ pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeEr .packet(packet.id) .ok_or(EncodeError::UnknownId(packet.id))?; - let PacketKind::Message(_) = &packet_def.kind else { - return Err(EncodeError::WrongKind { id: 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(); @@ -284,7 +284,7 @@ mod tests { id: data_id, board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![]), + packet_type: PacketType::Data(vec![]), }; let board = Board { id: BoardId(1), @@ -302,7 +302,7 @@ mod tests { 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::WrongKind { id: data_id }); + assert_eq!(err, DecodeError::WrongPacketType { id: data_id }); } // --- ENCODE TESTS --- @@ -356,13 +356,13 @@ mod tests { } #[test] - fn encode_fails_on_wrong_kind() { + 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(), - kind: PacketKind::Data(vec![]), + packet_type: PacketType::Data(vec![]), }; let board = Board { id: BoardId(1), @@ -391,7 +391,7 @@ mod tests { let Err(err) = encode(&packet, &index) else { unreachable!("encoding into a non-message packet's id should fail"); }; - assert_eq!(err, EncodeError::WrongKind { id: data_id }); + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); } #[test] diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index fdb36768d..d3a4e1e4e 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -22,7 +22,7 @@ //! [`crate::protocol::wire::byte_width`] says for the watched //! measurement's kind — not fixed, unlike every other field here. -use crate::model::{AdjId, PacketKind, ProtectionPacket, ShortTimestamp}; +use crate::model::{AdjId, PacketType, ProtectionPacket, ShortTimestamp}; use crate::protocol::{DecodeError, EncodeError, Index, wire}; use std::io::Read; @@ -48,8 +48,8 @@ pub fn decode( .packet(compound_id) .ok_or(DecodeError::UnknownId(compound_id))?; - let PacketKind::Protection(measurement) = &packet_def.kind else { - return Err(DecodeError::WrongKind { id: 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` @@ -88,8 +88,8 @@ pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, Encod .packet(packet.id) .ok_or(EncodeError::UnknownId(packet.id))?; - let PacketKind::Protection(measurement) = &packet_def.kind else { - return Err(EncodeError::WrongKind { id: 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(); @@ -136,7 +136,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "mock_protection".to_string(), - kind: PacketKind::Protection(measurement), + packet_type: PacketType::Protection(measurement), }; let board = Board { id: BoardId(1), @@ -219,7 +219,7 @@ mod tests { } #[test] - fn a_non_protection_packet_at_that_id_is_the_wrong_kind() { + fn a_non_protection_packet_at_that_id_is_the_wrong_packet_type() { let measurement = sample_measurement( 200, MeasurementKind::Numeric { @@ -231,7 +231,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "mock_data".to_string(), - kind: PacketKind::Data(vec![measurement]), + packet_type: PacketType::Data(vec![measurement]), }; let board = Board { id: BoardId(1), @@ -247,7 +247,7 @@ mod tests { assert!(matches!( decode(Cursor::new(vec![]), Some(compound_id), &index), - Err(DecodeError::WrongKind { id }) if id == compound_id + Err(DecodeError::WrongPacketType { id }) if id == compound_id )); } @@ -364,7 +364,7 @@ mod tests { } #[test] - fn encode_fails_on_wrong_kind() { + fn encode_fails_on_wrong_packet_type() { let measurement = sample_measurement( 200, MeasurementKind::Numeric { @@ -376,7 +376,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "mock_data".to_string(), - kind: PacketKind::Data(vec![measurement]), + packet_type: PacketType::Data(vec![measurement]), }; let board = Board { id: BoardId(1), @@ -402,9 +402,9 @@ mod tests { }; let Err(err) = encode(&packet, &index) else { - unreachable!("encoding the wrong packet kind should fail"); + unreachable!("encoding the wrong packet type should fail"); }; - assert_eq!(err, EncodeError::WrongKind { id: compound_id }); + assert_eq!(err, EncodeError::WrongPacketType { id: compound_id }); } #[test] diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index a2d645944..d4becf3fb 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -3,7 +3,7 @@ //! orders to it. Never a `Data` packet — see the module doc on //! [`super`]. //! -//! Both functions here are thin: the actual per-kind decoding lives in +//! 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 @@ -35,8 +35,9 @@ pub enum TcpFrame { /// 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`/`WrongKind` if it isn't actually -/// a declared message), so there's no separate lookup to do here first. +/// 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)?; @@ -60,7 +61,7 @@ mod tests { use super::*; use crate::model::{ AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, MessageLevel, NumericKind, - PacketDef, PacketKind, PodData, ShortTimestamp, Value, + PacketDef, PacketType, PodData, ShortTimestamp, Value, }; use std::collections::HashMap; use std::io::Cursor; @@ -84,7 +85,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "temp_protection".to_string(), - kind: PacketKind::Protection(measurement), + packet_type: PacketType::Protection(measurement), }; let board = Board { id: BoardId(1),