From 3754f22137fc82e90572d4781e73065802326391 Mon Sep 17 00:00:00 2001 From: Raffael Rott Date: Sun, 12 Apr 2026 16:20:24 +0200 Subject: [PATCH 1/5] Added basic config --- src/config/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/mod.rs b/src/config/mod.rs index 3c04e19..f47e448 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,3 +20,4 @@ pub fn load_config(path: &str) -> Result { .try_deserialize() .with_context(|| format!("Failed to deserialize config from {}", path)) } + From 227e4f7ad0daa1fc6deee66de873686649d6b506 Mon Sep 17 00:00:00 2001 From: Michael Debertol Date: Mon, 4 May 2026 03:47:03 +0200 Subject: [PATCH 2/5] add mapping.toml support Supports specifying: - a unique name for fields - a slope + offset conversion for a "mapped value" - a range-based conversion for a "logical value", that also includes a color code for the frontend The NodeManager now supports reading raw, mapped, and logical values by mapping name, requesting mapped fields from nodes, and writing mapped parameter values back as raw CAN values. --- Cargo.lock | 9 + Cargo.toml | 1 + src/config/mod.rs | 1 + src/lib.rs | 4 +- src/nodes/mapping.rs | 725 ++++++++++++++++++++++++++++++++++++ src/nodes/mod.rs | 1 + src/nodes/node_manager.rs | 349 ++++++++++++++++- tests/emulator.rs | 6 +- tests/mapping/example1.toml | 41 ++ 9 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 src/nodes/mapping.rs create mode 100644 tests/mapping/example1.toml diff --git a/Cargo.lock b/Cargo.lock index af7b163..fb619bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,6 +686,7 @@ dependencies = [ "serde_json", "socketcan", "testcontainers", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -2547,10 +2548,12 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.1", ] @@ -2581,6 +2584,12 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.5" diff --git a/Cargo.toml b/Cargo.toml index f024f09..f6b371a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ serde = { version = "1.0.228", features = ["derive"] } # Test-only helper binary dependencies (enabled via feature) caps = { version = "0.5", optional = true } libc = { version = "0.2", optional = true } +toml = "1.1.2" [features] # Enables the `ferroflow-vcan` helper binary used by integration tests. diff --git a/src/config/mod.rs b/src/config/mod.rs index f47e448..06a48e7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,6 +9,7 @@ pub struct Config { pub can_bus_interfaces: Vec, pub heartbeat_period: u64, pub database_url: String, + pub mapping_path: String, } pub fn load_config(path: &str) -> Result { diff --git a/src/lib.rs b/src/lib.rs index f75ccd3..3593e12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,9 @@ pub mod socket; pub fn run_with_config(config: Config) -> anyhow::Result<()> { let event_dispatcher = events::EventDispatcher::new(); - let node_manager = nodes::NodeManager::new(&event_dispatcher); + let mapping = nodes::mapping::NodeMapping::load_mapping_from_file(&config.mapping_path)?; + + let node_manager = nodes::NodeManager::new(&event_dispatcher, mapping); run_with_dependencies(&event_dispatcher, &node_manager, config) } diff --git a/src/nodes/mapping.rs b/src/nodes/mapping.rs new file mode 100644 index 0000000..b335b8b --- /dev/null +++ b/src/nodes/mapping.rs @@ -0,0 +1,725 @@ +use anyhow::{Context, bail, ensure}; +use liquidcan::payloads::{CanDataType, CanDataValue}; +use serde::Deserialize; +use std::collections::HashSet; +use toml::Value; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct NodeMapping { + pub id: String, + pub description: String, + pub mapping: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MappingEntry { + pub name: String, + pub raw: RawField, + #[serde(rename = "type")] + pub field_type: FieldType, + #[serde(default)] + pub value: ValueParams, + #[serde(default)] + pub logical: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RawField { + pub node: String, + pub field: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ValueParams { + pub slope: f64, + pub offset: f64, + #[serde(default)] + pub unit: String, +} + +impl Default for ValueParams { + fn default() -> Self { + Self { + slope: 1.0, + offset: 0.0, + unit: "".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LogicalRule { + pub range: LogicalRangeConfig, + pub value: Value, + #[serde(default)] + pub color: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LogicalRangeConfig { + /// Inclusive lower bound by default. If omitted, the range is unbounded below. + #[serde(default)] + pub min: Option, + /// Exclusive upper bound by default. If omitted, the range is unbounded above. + #[serde(default)] + pub max: Option, + #[serde(default = "default_min_inclusive")] + pub min_inclusive: bool, + #[serde(default)] + pub max_inclusive: bool, +} + +fn default_min_inclusive() -> bool { + true +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FieldType { + Telemetry, + Parameter, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LogicalValue { + pub value: Value, + pub color: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MappedValue { + pub value: f64, + pub unit: String, +} + +impl NodeMapping { + pub fn load_mapping_from_file(path: &str) -> anyhow::Result { + if path.is_empty() { + return Ok(Self::default()); + } + + let toml_str = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read mapping config file at {}", path))?; + Self::parse_mapping(&toml_str) + } + + pub fn parse_mapping(toml_str: &str) -> anyhow::Result { + let config = toml::from_str::(toml_str) + .map_err(|err| anyhow::anyhow!("Failed to parse mapping config: {}", err))?; + + config.validate().with_context(|| { + format!( + "Mapping validation failed for config with id: {}", + config.id + ) + })?; + + Ok(config) + } + + pub fn validate(&self) -> anyhow::Result<()> { + let mut names = HashSet::new(); + let mut raw_fields = HashSet::new(); + for mapping in &self.mapping { + // check name is not empty and unique + ensure!( + !mapping.name.trim().is_empty(), + "mapping name must not be empty" + ); + if !names.insert(mapping.name.as_str()) { + anyhow::bail!("Duplicate mapping name: {}", mapping.name); + } + + // check raw.node and raw.field are not empty and unique in combination + let raw_id = (mapping.raw.node.as_str(), mapping.raw.field.as_str()); + ensure!( + !raw_id.0.trim().is_empty(), + "mapping {} has empty raw.node", + mapping.name + ); + ensure!( + !raw_id.1.trim().is_empty(), + "mapping {} has empty raw.field", + mapping.name + ); + if !raw_fields.insert(raw_id) { + anyhow::bail!( + "Duplicate raw field mapping for node '{}' field '{}'", + mapping.raw.node, + mapping.raw.field + ); + } + mapping.validate()?; + } + + Ok(()) + } + + pub fn get_mapping_for_name(&self, name: &str) -> Option<&MappingEntry> { + self.mapping.iter().find(|m| m.name == name) + } + + pub fn get_mapping_for_raw( + &self, + node: &str, + field: &str, + field_type: FieldType, + ) -> Option<&MappingEntry> { + self.mapping.iter().find(|mapping| { + mapping.raw.node == node + && mapping.raw.field == field + && mapping.field_type == field_type + }) + } +} + +impl MappingEntry { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + !self.raw.node.trim().is_empty(), + "mapping {} must specify raw.node", + self.name + ); + ensure!( + !self.raw.field.trim().is_empty(), + "mapping {} must specify raw.field", + self.name + ); + + ensure!( + self.value.slope.is_finite(), + "mapping {} has a non-finite slope", + self.name + ); + ensure!( + self.value.slope != 0.0, + "mapping {} has a slope of zero, which is not allowed", + self.name + ); + ensure!( + self.value.offset.is_finite(), + "mapping {} has a non-finite offset", + self.name + ); + + self.validate_logical_rules()?; + + Ok(()) + } + + /// Validates that logical rules form an unambiguous partition of all mapped values. + /// + /// Empty logical rules are allowed. Once any logical rule is present, the ranges must be + /// non-empty, non-overlapping, and exhaustive over `(-inf, inf)` so every mapped value has + /// exactly one logical value. + fn validate_logical_rules(&self) -> anyhow::Result<()> { + if self.logical.is_empty() { + return Ok(()); + } + + let mut covered_ranges = Vec::new(); + + for (index, rule) in self.logical.iter().enumerate() { + let range = rule.range.to_logical_range().with_context(|| { + format!( + "Logical rule {} for mapping {} has an invalid range", + index + 1, + self.name + ) + })?; + + if !range.is_non_empty() { + bail!( + "Logical rule {} for mapping {} has an empty range {}", + index + 1, + self.name, + range.describe() + ); + } + + for (covered_index, covered_range) in covered_ranges.iter().enumerate() { + if let Some(overlap) = range.intersection(covered_range) { + bail!( + "Logical rule {} for mapping {} overlaps with rule {} in {}; overlapping ranges are ambiguous", + index + 1, + self.name, + covered_index + 1, + overlap.describe() + ); + } + } + + covered_ranges.push(range); + } + + let uncovered_ranges = covered_ranges.iter().fold( + vec![LogicalRange::all()], + |remaining_uncovered_ranges, covered_range| { + remaining_uncovered_ranges + .into_iter() + .flat_map(|range| range.difference(covered_range)) + .collect::>() + }, + ); + + if let Some(uncovered_range) = uncovered_ranges.first() { + bail!( + "Logical rules for mapping {} are not exhaustive; values in {} are not matched", + self.name, + uncovered_range.describe() + ); + } + + Ok(()) + } + + /// Applies the linear mapping `mapped = raw * slope + offset`. + pub fn mapped_value(&self, raw_value: &CanDataValue) -> anyhow::Result { + let numeric_raw_value = can_data_value_to_f64(raw_value)?; + + Ok(MappedValue { + unit: self.value.unit.clone(), + value: numeric_raw_value * self.value.slope + self.value.offset, + }) + } + + /// Inverts the linear mapping and converts the result to the concrete CAN data type. + pub fn raw_value_from_mapped( + &self, + mapped_value: f64, + data_type: CanDataType, + ) -> anyhow::Result { + ensure!( + mapped_value.is_finite(), + "mapped value for {} must be finite", + self.name + ); + + ensure!( + self.value.slope != 0.0, + "cannot invert mapping {} because slope is zero", + self.name + ); + + can_data_value_from_f64( + (mapped_value - self.value.offset) / self.value.slope, + data_type, + ) + } + + pub fn logical_value(&self, mapped_value: f64) -> Option { + self.logical + .iter() + .find(|rule| rule.matches(mapped_value)) + .map(|rule| LogicalValue { + value: rule.value.clone(), + color: rule.color.clone(), + }) + } +} + +impl LogicalRule { + fn matches(&self, mapped_value: f64) -> bool { + self.range + .to_logical_range() + .is_ok_and(|range| range.contains(mapped_value)) + } +} + +impl LogicalRangeConfig { + /// Converts the TOML range representation into the internal interval type + fn to_logical_range(&self) -> anyhow::Result { + if let Some(min) = self.min { + ensure!(min.is_finite(), "range min must be finite"); + } + if let Some(max) = self.max { + ensure!(max.is_finite(), "range max must be finite"); + } + + Ok(LogicalRange::new( + RangeBound { + value: self.min, + inclusive: self.min_inclusive, + }, + RangeBound { + value: self.max, + inclusive: self.max_inclusive, + }, + )) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct LogicalRange { + lower: RangeBound, + upper: RangeBound, +} + +impl LogicalRange { + /// The complete domain + fn all() -> Self { + Self::new( + RangeBound::negative_infinity(), + RangeBound::positive_infinity(), + ) + } + + fn new(lower: RangeBound, upper: RangeBound) -> Self { + Self { lower, upper } + } + + fn contains(&self, value: f64) -> bool { + let above_lower = match self.lower.value { + Some(lower) if self.lower.inclusive => value >= lower, + Some(lower) => value > lower, + None => true, + }; + let below_upper = match self.upper.value { + Some(upper) if self.upper.inclusive => value <= upper, + Some(upper) => value < upper, + None => true, + }; + + above_lower && below_upper + } + + fn intersection(&self, other: &Self) -> Option { + let intersection = Self::new( + RangeBound::max_lower(self.lower, other.lower), + RangeBound::min_upper(self.upper, other.upper), + ); + + intersection.is_non_empty().then_some(intersection) + } + + fn difference(&self, other: &Self) -> Vec { + let Some(intersection) = self.intersection(other) else { + return vec![*self]; + }; + + let mut remaining = Vec::new(); + + if intersection.lower.value.is_some() { + let left = Self::new( + self.lower, + RangeBound { + value: intersection.lower.value, + inclusive: !intersection.lower.inclusive, + }, + ); + if left.is_non_empty() { + remaining.push(left); + } + } + + if intersection.upper.value.is_some() { + let right = Self::new( + RangeBound { + value: intersection.upper.value, + inclusive: !intersection.upper.inclusive, + }, + self.upper, + ); + if right.is_non_empty() { + remaining.push(right); + } + } + + remaining + } + + fn is_non_empty(&self) -> bool { + match (self.lower.value, self.upper.value) { + (Some(lower), Some(upper)) if lower > upper => false, + (Some(lower), Some(upper)) if lower == upper => { + self.lower.inclusive && self.upper.inclusive + } + _ => true, + } + } + + fn describe(&self) -> String { + let lower = match self.lower.value { + Some(value) if self.lower.inclusive => format!("[{value}"), + Some(value) => format!("({value}"), + None => "(-inf".to_string(), + }; + let upper = match self.upper.value { + Some(value) if self.upper.inclusive => format!("{value}]"), + Some(value) => format!("{value})"), + None => "inf)".to_string(), + }; + + format!("{lower}, {upper}") + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct RangeBound { + value: Option, + inclusive: bool, +} + +impl RangeBound { + fn negative_infinity() -> Self { + Self { + value: None, + inclusive: false, + } + } + + fn positive_infinity() -> Self { + Self { + value: None, + inclusive: false, + } + } + + fn finite(value: f64, inclusive: bool) -> Self { + Self { + value: Some(value), + inclusive, + } + } + + fn max_lower(left: Self, right: Self) -> Self { + match (left.value, right.value) { + (None, _) => right, + (_, None) => left, + (Some(left_value), Some(right_value)) if left_value > right_value => left, + (Some(left_value), Some(right_value)) if left_value < right_value => right, + (Some(value), Some(_)) => Self::finite(value, left.inclusive && right.inclusive), + } + } + + fn min_upper(left: Self, right: Self) -> Self { + match (left.value, right.value) { + (None, _) => right, + (_, None) => left, + (Some(left_value), Some(right_value)) if left_value < right_value => left, + (Some(left_value), Some(right_value)) if left_value > right_value => right, + (Some(value), Some(_)) => Self::finite(value, left.inclusive && right.inclusive), + } + } +} + +fn can_data_value_to_f64(value: &CanDataValue) -> anyhow::Result { + match value { + CanDataValue::Float32(value) => Ok(*value as f64), + CanDataValue::Int32(value) => Ok(*value as f64), + CanDataValue::Int16(value) => Ok(*value as f64), + CanDataValue::Int8(value) => Ok(*value as f64), + CanDataValue::UInt32(value) => Ok(*value as f64), + CanDataValue::UInt16(value) => Ok(*value as f64), + CanDataValue::UInt8(value) => Ok(*value as f64), + CanDataValue::Boolean(value) => Ok(if *value { 1.0 } else { 0.0 }), + CanDataValue::Raw(_) => bail!("raw CAN data must be decoded before applying a mapping"), + } +} + +/// Converts a mapped numeric value back into a typed CAN payload value. +fn can_data_value_from_f64(value: f64, data_type: CanDataType) -> anyhow::Result { + ensure!(value.is_finite(), "raw value must be finite"); + + match data_type { + CanDataType::Float32 => Ok(CanDataValue::Float32(value as f32)), + CanDataType::Int32 => Ok(CanDataValue::Int32(checked_integer::(value)?)), + CanDataType::Int16 => Ok(CanDataValue::Int16(checked_integer::(value)?)), + CanDataType::Int8 => Ok(CanDataValue::Int8(checked_integer::(value)?)), + CanDataType::UInt32 => Ok(CanDataValue::UInt32(checked_integer::(value)?)), + CanDataType::UInt16 => Ok(CanDataValue::UInt16(checked_integer::(value)?)), + CanDataType::UInt8 => Ok(CanDataValue::UInt8(checked_integer::(value)?)), + CanDataType::Boolean => { + if (value - 0.0).abs() < f64::EPSILON { + Ok(CanDataValue::Boolean(false)) + } else if (value - 1.0).abs() < f64::EPSILON { + Ok(CanDataValue::Boolean(true)) + } else { + bail!("boolean raw values must map back to 0 or 1, got {value}") + } + } + } +} + +/// Checks that a floating-point inverse-mapped value can be represented as an integer CAN type. +fn checked_integer(value: f64) -> anyhow::Result +where + T: TryFrom, + >::Error: std::fmt::Debug, +{ + let rounded = value.round(); + + T::try_from(rounded as i128).map_err(|_| anyhow::anyhow!("raw value {rounded} is out of range")) +} + +#[cfg(test)] +mod tests { + use liquidcan::payloads::{CanDataType, CanDataValue}; + use toml::Value; + + use super::{LogicalValue, NodeMapping}; + + #[test] + fn parses_and_applies_mapping_schema() { + let mapping = NodeMapping::parse_mapping( + r##" +id = "example" + +[[mapping]] +name = "tank_pressure" +type = "telemetry" +raw = { node = "ECU", field = "pressure_adc" } +value = { slope = 0.5, offset = 1.0, unit = "bar" } + +[[mapping.logical]] +range = { min = 100 } +value = "High" +color = "#ff0000" + +[[mapping.logical]] +range = { max = 100 } +value = "Normal" +"##, + ) + .expect("mapping should parse"); + + let entry = mapping + .get_mapping_for_name("tank_pressure") + .expect("entry should exist"); + + let mapped = entry + .mapped_value(&CanDataValue::UInt16(198)) + .expect("raw value should map"); + assert_eq!(mapped.value, 100.0); + assert_eq!(mapped.unit, "bar"); + + assert_eq!( + entry.logical_value(mapped.value), + Some(LogicalValue { + value: Value::String("High".to_string()), + color: Some("#ff0000".to_string()), + }) + ); + } + + #[test] + fn rejects_duplicate_mapping_names() { + let error = NodeMapping::parse_mapping( + r#" +[[mapping]] +name = "duplicate" +raw = { node = "node1", field = "field1" } +type = "telemetry" +value = { slope = 1.0, offset = 0.0 } + +[[mapping]] +name = "duplicate" +raw = { node = "node1", field = "field2" } +type = "telemetry" +value = { slope = 1.0, offset = 0.0 } +"#, + ) + .expect_err("duplicate names should fail validation"); + + assert!(format!("{error:#}").contains("Duplicate mapping name")); + } + + #[test] + fn converts_mapped_value_back_to_raw_parameter_type() { + let mapping = NodeMapping::parse_mapping( + r#" +[[mapping]] +name = "valve_opening" +type = "parameter" +raw = { node = "ECU", field = "valve_raw" } +value = { slope = 0.5, offset = 10.0, unit = "%" } +"#, + ) + .expect("mapping should parse"); + + let entry = mapping.get_mapping_for_name("valve_opening").unwrap(); + let raw = entry + .raw_value_from_mapped(60.0, CanDataType::UInt8) + .expect("mapped value should invert to raw"); + + assert_eq!(raw, CanDataValue::UInt8(100)); + } + + #[test] + fn checked_in_example_mapping_is_valid() { + NodeMapping::load_mapping_from_file("tests/mapping/example1.toml") + .expect("example mapping should be valid"); + } + + #[test] + fn rejects_non_exhaustive_logical_rules() { + let error = NodeMapping::parse_mapping( + r#" +[[mapping]] +name = "temperature" +type = "telemetry" +raw = { node = "ECU", field = "temperature" } + +[[mapping.logical]] +range = { max = 10 } +value = "Cold" + +[[mapping.logical]] +range = { min = 10, min_inclusive = false } +value = "Hot" +"#, + ) + .expect_err("rules should miss exactly 10"); + + assert!(format!("{error:#}").contains("not exhaustive")); + } + + #[test] + fn rejects_overlapping_logical_rules() { + let error = NodeMapping::parse_mapping( + r#" +[[mapping]] +name = "temperature" +type = "telemetry" +raw = { node = "ECU", field = "temperature" } + +[[mapping.logical]] +range = { max = 100 } +value = "Low" + +[[mapping.logical]] +range = { max = 50 } +value = "Very low" + +[[mapping.logical]] +range = { min = 100 } +value = "High" +"#, + ) + .expect_err("second rule should overlap with the first"); + + assert!(format!("{error:#}").contains("overlaps")); + } + + #[test] + fn accepts_adjacent_ranges() { + NodeMapping::parse_mapping( + r#" +[[mapping]] +name = "temperature" +type = "telemetry" +raw = { node = "ECU", field = "temperature" } + +[[mapping.logical]] +range = { max = 10 } +value = "Cold" + +[[mapping.logical]] +range = { min = 10 } +value = "Hot" +"#, + ) + .expect("adjacent ranges should cover the threshold exactly once"); + } +} diff --git a/src/nodes/mod.rs b/src/nodes/mod.rs index b4d9876..2657f56 100644 --- a/src/nodes/mod.rs +++ b/src/nodes/mod.rs @@ -1,6 +1,7 @@ //! Contains code for managing the CAN nodes that are connected to FerroFlow, their fields and data types. mod can_node; +pub mod mapping; mod node_manager; pub use node_manager::NodeManager; diff --git a/src/nodes/node_manager.rs b/src/nodes/node_manager.rs index 9b95b9c..03b0452 100644 --- a/src/nodes/node_manager.rs +++ b/src/nodes/node_manager.rs @@ -7,16 +7,19 @@ use dashmap::DashMap; use liquidcan::{ CanMessage, CanMessageId, payloads::{ - CanDataValue, FieldGetResPayload, FieldRegistrationPayload, HeartbeatPayload, - NodeInfoResPayload, TelemetryGroupDefinitionPayload, TelemetryGroupUpdatePayload, + CanDataType, CanDataValue, FieldGetReqPayload, FieldGetResPayload, + FieldRegistrationPayload, HeartbeatPayload, NodeInfoResPayload, ParameterSetReqPayload, + TelemetryGroupDefinitionPayload, TelemetryGroupUpdatePayload, }, }; +use crate::nodes::mapping::{self, LogicalValue, MappedValue, MappingEntry, NodeMapping}; use crate::{db::FieldLog, events}; use super::can_node::{CanNode, FieldInfo, RegistrationInfo, TelemetryGroupDefinition}; pub struct NodeManager<'a> { + mapping: NodeMapping, can_nodes: DashMap, // Nodes that did not yet receive all their field registrations. @@ -25,8 +28,9 @@ pub struct NodeManager<'a> { } impl<'a> NodeManager<'a> { - pub fn new(event_dispatcher: &'a events::EventDispatcher) -> Self { + pub fn new(event_dispatcher: &'a events::EventDispatcher, mapping: NodeMapping) -> Self { Self { + mapping, can_nodes: DashMap::new(), registering_nodes: Mutex::new(HashMap::new()), event_dispatcher, @@ -370,4 +374,343 @@ impl<'a> NodeManager<'a> { CanDataValue::Raw(items) => serde_json::json!(items), } } + + /// Returns the latest cached raw CAN value for a mapped field name. + /// + /// This does not send a CAN request. Call `request_value` first if a fresh value is needed. + /// + /// Use this `try_` variant to distinguish missing values from invalid mappings or fields + /// that have not registered yet. + pub fn try_get_raw_value(&self, mapped_name: &str) -> Result> { + let mapping_entry = self + .mapping + .get_mapping_for_name(mapped_name) + .with_context(|| format!("no mapping exists for {mapped_name}"))?; + let target = self + .resolve_mapping_target(mapping_entry) + .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + + Ok(self.can_nodes.get(&target.node_id).and_then(|node| { + node.values + .get(&target.field_id) + .map(|value| value.1.clone()) + })) + } + + /// Convenience wrapper around `try_get_raw_value` that treats errors as missing values. + pub fn get_raw_value(&self, mapped_name: &str) -> Option { + self.try_get_raw_value(mapped_name).ok().flatten() + } + + /// Returns the latest cached value after applying the mapping's slope/offset conversion. + /// + /// `Ok(None)` means the mapping and raw field exist, but no value has been received yet. + pub fn try_get_mapped_value(&self, mapped_name: &str) -> Result> { + let Some(raw_value) = self.try_get_raw_value(mapped_name)? else { + return Ok(None); + }; + + let mapping_entry = self + .mapping + .get_mapping_for_name(mapped_name) + .with_context(|| format!("no mapping exists for {mapped_name}"))?; + + Ok(Some(mapping_entry.mapped_value(&raw_value)?)) + } + + /// Convenience wrapper around `try_get_mapped_value` that treats errors as missing values. + pub fn get_mapped_value(&self, mapped_name: &str) -> Option { + self.try_get_mapped_value(mapped_name).ok().flatten() + } + + /// Returns the logical value associated with the current mapped value. + /// + /// Logical values are derived from the configured range table. If the mapping has no logical + /// rules, this returns `Ok(None)` even when a mapped numeric value is available. + pub fn try_get_logical_value(&self, mapped_name: &str) -> Result> { + let Some(mapped_value) = self.try_get_mapped_value(mapped_name)? else { + return Ok(None); + }; + + let mapping_entry = self + .mapping + .get_mapping_for_name(mapped_name) + .with_context(|| format!("no mapping exists for {mapped_name}"))?; + + Ok(mapping_entry.logical_value(mapped_value.value)) + } + + /// Convenience wrapper around `try_get_logical_value` that treats errors as missing values. + pub fn get_logical_value(&self, mapped_name: &str) -> Option { + self.try_get_logical_value(mapped_name).ok().flatten() + } + + /// Sends a `FieldGetReq` for the raw field behind a mapped name. + /// + /// The response is processed asynchronously by the normal CAN message handler and updates the + /// cached value read by `get_raw_value`, `get_mapped_value`, and `get_logical_value`. + pub fn request_value(&self, mapped_name: &str) -> Result<()> { + let mapping_entry = self + .mapping + .get_mapping_for_name(mapped_name) + .with_context(|| format!("no mapping exists for {mapped_name}"))?; + let target = self + .resolve_mapping_target(mapping_entry) + .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + + self.event_dispatcher + .dispatch(events::Event::SendCanMessage { + receiver_node_id: target.node_id, + message: CanMessage::FieldGetReq { + payload: FieldGetReqPayload { + field_id: target.field_id, + }, + }, + }); + + Ok(()) + } + + /// Writes a mapped value to a mapped parameter field. + /// + /// The value is converted back to the raw CAN type using the inverse of the configured linear + /// mapping, then sent as a `ParameterSetReq`. + pub fn set_mapped_value(&self, mapped_name: &str, mapped_value: f64) -> Result<()> { + let mapping_entry = self + .mapping + .get_mapping_for_name(mapped_name) + .with_context(|| format!("no mapping exists for {mapped_name}"))?; + let target = self + .resolve_mapping_target(mapping_entry) + .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + + if mapping_entry.field_type != mapping::FieldType::Parameter { + bail!("mapped field {mapped_name} is not writable because it is not a parameter"); + } + + let raw_value = mapping_entry.raw_value_from_mapped(mapped_value, target.data_type)?; + self.set_raw_value(mapped_name, raw_value) + } + + /// Writes a raw CAN value to a mapped parameter field. + pub fn set_raw_value(&self, mapped_name: &str, raw_value: CanDataValue) -> Result<()> { + let mapping_entry = self + .mapping + .get_mapping_for_name(mapped_name) + .with_context(|| format!("no mapping exists for {mapped_name}"))?; + let target = self + .resolve_mapping_target(mapping_entry) + .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + + if mapping_entry.field_type != mapping::FieldType::Parameter { + bail!("mapped field {mapped_name} is not writable because it is not a parameter"); + } + + self.event_dispatcher + .dispatch(events::Event::SendCanMessage { + receiver_node_id: target.node_id, + message: CanMessage::ParameterSetReq { + payload: ParameterSetReqPayload { + parameter_id: target.field_id, + value: raw_value, + }, + }, + }); + + Ok(()) + } + + /// Resolves a mapping entry to the currently registered node id, field id, and field type. + /// + /// Mappings are written against stable device/field names, but LiquidCAN requests need numeric + /// ids learned during node registration. + fn resolve_mapping_target( + &self, + mapping_entry: &MappingEntry, + ) -> Option { + self.can_nodes.iter().find_map(|node| { + if node.registration_info.device_name != mapping_entry.raw.node { + return None; + } + + let fields = match mapping_entry.field_type { + mapping::FieldType::Telemetry => &node.telemetry_fields, + mapping::FieldType::Parameter => &node.parameter_fields, + }; + + fields + .iter() + .find(|(_, field)| field.name == mapping_entry.raw.field) + .map(|(field_id, field)| ResolvedMappingTarget { + node_id: *node.key(), + field_id: *field_id, + data_type: field.data_type, + }) + }) + } +} + +struct ResolvedMappingTarget { + node_id: u8, + field_id: u8, + data_type: CanDataType, +} + +#[cfg(test)] +mod tests { + use std::{sync::mpsc, time::Duration}; + + use chrono::Utc; + use liquidcan::payloads::{CanDataType, CanDataValue}; + use toml::Value; + + use crate::events::{Event, EventDispatcher, EventKind}; + + use super::*; + + #[test] + fn reads_raw_mapped_and_logical_values_by_mapping_name() { + let dispatcher = EventDispatcher::new(); + let manager = NodeManager::new(&dispatcher, test_mapping()); + insert_test_node(&manager); + + assert_eq!( + manager.get_raw_value("tank_pressure"), + Some(CanDataValue::UInt16(198)) + ); + + let mapped = manager + .get_mapped_value("tank_pressure") + .expect("mapped value should be available"); + assert_eq!(mapped.value, 100.0); + assert_eq!(mapped.unit, "bar"); + + let logical = manager + .get_logical_value("tank_pressure") + .expect("logical value should be available"); + assert_eq!(logical.value, Value::String("High".to_string())); + assert_eq!(logical.color, Some("#ff0000".to_string())); + } + + #[test] + fn writes_mapped_parameter_values_as_raw_can_values() { + let dispatcher = EventDispatcher::new(); + let (tx, rx) = mpsc::channel(); + dispatcher.subscribe(tx, vec![EventKind::SendCanMessage], "test-send-listener"); + + let manager = NodeManager::new(&dispatcher, test_mapping()); + insert_test_node(&manager); + + manager + .set_mapped_value("valve_opening", 60.0) + .expect("mapped parameter should be writable"); + + let event = rx + .recv_timeout(Duration::from_millis(200)) + .expect("send event should be dispatched"); + + match event { + Event::SendCanMessage { + receiver_node_id, + message: + CanMessage::ParameterSetReq { + payload: + ParameterSetReqPayload { + parameter_id, + value, + }, + }, + } => { + assert_eq!(receiver_node_id, 5); + assert_eq!(parameter_id, 20); + assert_eq!(value, CanDataValue::UInt8(100)); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn requests_field_get_for_mapped_values() { + let dispatcher = EventDispatcher::new(); + let (tx, rx) = mpsc::channel(); + dispatcher.subscribe(tx, vec![EventKind::SendCanMessage], "test-send-listener"); + + let manager = NodeManager::new(&dispatcher, test_mapping()); + insert_test_node(&manager); + + manager + .request_value("tank_pressure") + .expect("mapped field should be requestable"); + + let event = rx + .recv_timeout(Duration::from_millis(200)) + .expect("send event should be dispatched"); + + match event { + Event::SendCanMessage { + receiver_node_id, + message: CanMessage::FieldGetReq { payload }, + } => { + assert_eq!(receiver_node_id, 5); + assert_eq!(payload.field_id, 10); + } + other => panic!("unexpected event: {other:?}"), + } + } + + fn test_mapping() -> NodeMapping { + NodeMapping::parse_mapping( + r##" +[[mapping]] +name = "tank_pressure" +type = "telemetry" +raw = { node = "ECU", field = "pressure_adc" } +value = { slope = 0.5, offset = 1.0, unit = "bar" } + +[[mapping.logical]] +range = { min = 100 } +value = "High" +color = "#ff0000" + +[[mapping.logical]] +range = { max = 100 } +value = "Normal" + +[[mapping]] +name = "valve_opening" +type = "parameter" +raw = { node = "ECU", field = "valve_raw" } +value = { slope = 0.5, offset = 10.0, unit = "%" } +"##, + ) + .expect("mapping should parse") + } + + fn insert_test_node(manager: &NodeManager<'_>) { + let mut node = CanNode::new(RegistrationInfo { + telemetry_count: 1, + parameter_count: 1, + firmware_hash: 0, + protocol_hash: 0, + device_name: "ECU".to_string(), + }); + node.telemetry_fields.insert( + 10, + FieldInfo { + data_type: CanDataType::UInt16, + name: "pressure_adc".to_string(), + }, + ); + node.parameter_fields.insert( + 20, + FieldInfo { + data_type: CanDataType::UInt8, + name: "valve_raw".to_string(), + }, + ); + node.values + .insert(10, (Utc::now(), CanDataValue::UInt16(198))); + + manager.can_nodes.insert(5, node); + } } diff --git a/tests/emulator.rs b/tests/emulator.rs index 334829b..7b247f1 100644 --- a/tests/emulator.rs +++ b/tests/emulator.rs @@ -3,6 +3,7 @@ mod common; use crate::common::ShutdownGuard; use chrono::{DateTime, Utc}; use ferro_flow::config::Config; +use ferro_flow::nodes::mapping::NodeMapping; use ferro_flow::{events, nodes, run_with_dependencies}; use liquidcan::payloads::CanDataType; use std::{io::Write, time::Instant}; @@ -17,7 +18,7 @@ fn test_node_registration() { let emulator_config = ecuemulator_test_config_toml(&vcan_iface); let event_dispatcher = events::EventDispatcher::new(); - let node_manager = nodes::NodeManager::new(&event_dispatcher); + let node_manager = nodes::NodeManager::new(&event_dispatcher, NodeMapping::default()); let config = build_test_config(&vcan_iface); std::thread::scope(|s| { @@ -109,7 +110,7 @@ fn test_telemetry_group_updates() { let emulator_config = ecuemulator_test_config_toml(&vcan_iface); let event_dispatcher = events::EventDispatcher::new(); - let node_manager = nodes::NodeManager::new(&event_dispatcher); + let node_manager = nodes::NodeManager::new(&event_dispatcher, NodeMapping::default()); let config = build_test_config(&vcan_iface); println!("Starting application with test config: {:?}", config); @@ -185,6 +186,7 @@ fn build_test_config(can_iface: &str) -> Config { can_bus_interfaces: vec![can_iface.to_string()], heartbeat_period: 1, database_url: "".to_string(), + mapping_path: "".to_string(), } } diff --git a/tests/mapping/example1.toml b/tests/mapping/example1.toml new file mode 100644 index 0000000..49b7c77 --- /dev/null +++ b/tests/mapping/example1.toml @@ -0,0 +1,41 @@ +id = "example1" +description = "This is an example mapping file." + +[[mapping]] +raw.node = "node1" +raw.field = "field1" +name = "Example Mapping 1" +type = "telemetry" +value.unit = "mAh" +value.slope = 0.5 +value.offset = 1.0 + +[[mapping.logical]] +range = { min = 100 } +value = "High" +color = "#ff0000" + +[[mapping.logical]] +range = { min = 50, max = 100 } +value = "Normal" + +[[mapping.logical]] +range = { max = 50 } +value = "Low" + + +# alternatively + +[[mapping]] +name = "Example Mapping 2" +type = "parameter" + +raw = { node = "node1", field = "field2" } + +value = { slope = 0.5, offset = 1.0, unit = "mAh" } + +logical = [ + { range = { min = 100 }, value = "High", color = "#ff0000" }, + { range = { min = 50, max = 100 }, value = "Normal" }, + { range = { max = 50 }, value = "Low" }, +] From 1410b7cb3a2ae2855f7e332b62a172ec612be122 Mon Sep 17 00:00:00 2001 From: Michael Debertol Date: Thu, 7 May 2026 17:29:57 +0200 Subject: [PATCH 3/5] implement suggestions - load mappings from a directory - change mapping to be grouped by node - simplify some code --- README.md | 37 +- src/config/mod.rs | 1 - src/lib.rs | 2 +- src/nodes/mapping.rs | 597 ++++++++++++++++++-------------- src/nodes/node_manager.rs | 224 ++++++------ tests/emulator.rs | 6 +- tests/mapping/example1.toml | 19 +- tests/mapping/split/engine.toml | 10 + tests/mapping/split/fuel.toml | 5 + 9 files changed, 520 insertions(+), 381 deletions(-) create mode 100644 tests/mapping/split/engine.toml create mode 100644 tests/mapping/split/fuel.toml diff --git a/README.md b/README.md index 144bc8d..e8e2f19 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Ferroflow -Ferroflow is the new control software for all Liquid Rocketry projects at the TU Wien Space Team. + +Ferroflow is the new control software for all Liquid Rocketry projects at the TU Wien Space Team. It interfaces with our custom Engine Control Units ECUs, through our custom [LiquidCAN protocol](https://github.com/SpaceTeam/LiquidCAN/). On the other end, it provides a high-level API for our [ECUI](https://github.com/SpaceTeam/web_ecui_houbolt), which is the user interface for our ECUs. @@ -10,33 +11,58 @@ On the other end, it provides a high-level API for our [ECUI](https://github.com Some integration tests talk to the ECUemulator over SocketCAN. For that you use a virtual CAN interface. ### Test helper: `ferroflow-vcan` + For test environments, this repo provides a small helper binary that can be granted `CAP_NET_ADMIN` once via `setcap`. Integration tests will automatically use it (if it’s available on `PATH`) to create/delete `vcan` interfaces without sudo. Build the helper (feature-gated; not part of normal builds): + ```bash cargo build --release --features test-vcan --bin ferroflow-vcan ``` + Put it on PATH (recommended for tests): + ```bash install -m 0755 ./target/release/ferroflow-vcan ~/.local/bin/ferroflow-vcan sudo setcap cap_net_admin+ep ~/.local/bin/ferroflow-vcan ``` Manual usage: + ```bash ferroflow-vcan up vcan0 ferroflow-vcan down vcan0 ``` - ## Development +### Mapping Configuration + +`mapping_path` in `config.yml` points to a directory containing `.toml` files, which are loaded in sorted order and validated together. + +Mappings are grouped by node name: + +```toml +[[mapping.FuelECU]] +name = "fuel_level" +type = "telemetry" +raw_field = "level_adc" +value = { slope = 0.5, offset = 1.0, unit = "mAh" } + +logical = [ + { range = { min = 100 }, value = "High", color = "#ff0000" }, + { range = { min = 50, max = 100 }, value = "Normal" }, + { range = { max = 50 }, value = "Low" }, +] +``` + ### Running CI Checks The repository includes a CI script (`ci-rust.sh`) that runs all quality checks on the Rust implementation. This script is used both locally and in GitHub Actions **Run all checks:** + ```bash ./ci-rust.sh # or explicitly @@ -44,17 +70,20 @@ The repository includes a CI script (`ci-rust.sh`) that runs all quality checks ``` **Run individual checks:** + ```bash ./ci-rust.sh build # Build the project ./ci-rust.sh test # Run tests ./ci-rust.sh fmt # Check code formatting ./ci-rust.sh clippy # Run clippy linter ``` + You can fix formatting or linter issues by adding the -fix suffix to the command. e.g: `./ci-rust.sh clippy-fix` ### Running `fmt` and `clippy` as a pre-commit hook A pre-commit hook script is available in `.githooks`, which executes the CI script with `fmt` and `clippy` only and without the `fix` option. To setup the hook, configure git to use the `.githooks` directory and make the `pre-commit` file executable. + ```bash git config core.hooksPath .githooks chmod u+x .githooks/pre-commit @@ -66,6 +95,7 @@ chmod u+x .githooks/pre-commit We use TimescaleDB, which is an extension of PostgreSQL optimized for time-series data. You can install it by following the instructions on the [TimescaleDB installation page](https://docs.timescale.com/install/latest/). Using docker is recommended for local development (if you already have another instance of postgres running, use e.g. `-p 5433:5432` instead of `-p 5432:5432`): + ```bash docker run -d --name timescaledb -p 5432:5432 -e POSTGRES_PASSWORD=yourpassword timescale/timescaledb:latest-pg18 ``` @@ -76,6 +106,7 @@ The project uses Diesel for database interactions. Diesel CLI is recommended for **Running Diesel CLI** Here's some common commands: + ```bash export DATABASE_URL=postgres://postgres:yourpassword@localhost:5432/ferroflow # Set the database URL diesel setup # Set up the database @@ -97,4 +128,4 @@ Database tests use `testcontainers` to start a temporary TimescaleDB/PostgreSQL There are two examples in the repository: - a unit test in `src/db/mod.rs` -- an integration test in `tests/db_logging.rs` \ No newline at end of file +- an integration test in `tests/db_logging.rs` diff --git a/src/config/mod.rs b/src/config/mod.rs index 06a48e7..a9b2e5f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -21,4 +21,3 @@ pub fn load_config(path: &str) -> Result { .try_deserialize() .with_context(|| format!("Failed to deserialize config from {}", path)) } - diff --git a/src/lib.rs b/src/lib.rs index 3593e12..9d2dc8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ pub mod socket; pub fn run_with_config(config: Config) -> anyhow::Result<()> { let event_dispatcher = events::EventDispatcher::new(); - let mapping = nodes::mapping::NodeMapping::load_mapping_from_file(&config.mapping_path)?; + let mapping = nodes::mapping::Mapping::load_mapping_from_path(&config.mapping_path)?; let node_manager = nodes::NodeManager::new(&event_dispatcher, mapping); diff --git a/src/nodes/mapping.rs b/src/nodes/mapping.rs index b335b8b..dc55425 100644 --- a/src/nodes/mapping.rs +++ b/src/nodes/mapping.rs @@ -1,35 +1,31 @@ use anyhow::{Context, bail, ensure}; use liquidcan::payloads::{CanDataType, CanDataValue}; use serde::Deserialize; -use std::collections::HashSet; +use std::{ + collections::{BTreeMap, HashSet}, + fs, + path::Path, +}; use toml::Value; #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] -pub struct NodeMapping { - pub id: String, - pub description: String, - pub mapping: Vec, +pub struct Mapping { + pub mapping: BTreeMap>, } #[derive(Debug, Clone, Deserialize)] pub struct MappingEntry { pub name: String, - pub raw: RawField, #[serde(rename = "type")] pub field_type: FieldType, + pub raw_field: String, #[serde(default)] pub value: ValueParams, #[serde(default)] pub logical: Vec, } -#[derive(Debug, Clone, Deserialize)] -pub struct RawField { - pub node: String, - pub field: String, -} - #[derive(Debug, Clone, Deserialize)] pub struct ValueParams { pub slope: f64, @@ -50,26 +46,34 @@ impl Default for ValueParams { #[derive(Debug, Clone, Deserialize)] pub struct LogicalRule { - pub range: LogicalRangeConfig, + pub range: LogicalRange, pub value: Value, #[serde(default)] pub color: Option, } #[derive(Debug, Clone, Deserialize)] -pub struct LogicalRangeConfig { +pub struct LogicalRange { /// Inclusive lower bound by default. If omitted, the range is unbounded below. - #[serde(default)] - pub min: Option, + #[serde(default = "default_unbounded_min")] + pub min: f64, /// Exclusive upper bound by default. If omitted, the range is unbounded above. - #[serde(default)] - pub max: Option, + #[serde(default = "default_unbounded_max")] + pub max: f64, #[serde(default = "default_min_inclusive")] pub min_inclusive: bool, #[serde(default)] pub max_inclusive: bool, } +fn default_unbounded_min() -> f64 { + f64::NEG_INFINITY +} + +fn default_unbounded_max() -> f64 { + f64::INFINITY +} + fn default_min_inclusive() -> bool { true } @@ -93,71 +97,129 @@ pub struct MappedValue { pub unit: String, } -impl NodeMapping { +pub struct MappingLookupResult<'a> { + pub node_name: &'a str, + pub mapping_entry: &'a MappingEntry, +} + +impl Mapping { pub fn load_mapping_from_file(path: &str) -> anyhow::Result { if path.is_empty() { return Ok(Self::default()); } - let toml_str = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read mapping config file at {}", path))?; - Self::parse_mapping(&toml_str) + Self::load_mapping_file(Path::new(path)) + } + + pub fn load_mapping_from_path(path: &str) -> anyhow::Result { + if path.is_empty() { + return Ok(Self::default()); + } + + Self::load_mapping_directory(Path::new(path)) } pub fn parse_mapping(toml_str: &str) -> anyhow::Result { - let config = toml::from_str::(toml_str) + let config = toml::from_str::(toml_str) .map_err(|err| anyhow::anyhow!("Failed to parse mapping config: {}", err))?; - config.validate().with_context(|| { - format!( - "Mapping validation failed for config with id: {}", - config.id - ) - })?; + config.validate()?; Ok(config) } + fn load_mapping_file(path: &Path) -> anyhow::Result { + let toml_str = fs::read_to_string(path) + .with_context(|| format!("Failed to read mapping config file at {}", path.display()))?; + + Self::parse_mapping(&toml_str) + .with_context(|| format!("Failed to load mapping config from {}", path.display())) + } + + fn load_mapping_directory(path: &Path) -> anyhow::Result { + let mut entries = fs::read_dir(path) + .with_context(|| format!("Failed to read mapping directory {}", path.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>() + .with_context(|| format!("Failed to list mapping directory {}", path.display()))?; + + entries.retain(|entry| { + entry.is_file() + && entry + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("toml")) + }); + entries.sort(); + + ensure!( + !entries.is_empty(), + "mapping directory {} contains no TOML files", + path.display() + ); + + let mut combined = Self::default(); + for entry in entries { + let mapping = Self::load_mapping_file(&entry).with_context(|| { + format!("Failed to load mapping config from {}", entry.display()) + })?; + + for (node, fields) in mapping.mapping { + combined + .mapping + .entry(node) + .or_default() + .extend(fields.into_iter()); + } + } + + combined.validate().with_context(|| { + format!("Mapping validation failed for directory {}", path.display()) + })?; + + Ok(combined) + } + pub fn validate(&self) -> anyhow::Result<()> { let mut names = HashSet::new(); let mut raw_fields = HashSet::new(); - for mapping in &self.mapping { - // check name is not empty and unique + for (node, fields) in &self.mapping { ensure!( - !mapping.name.trim().is_empty(), - "mapping name must not be empty" + !node.trim().is_empty(), + "mapping contains an entry with an empty node name" ); - if !names.insert(mapping.name.as_str()) { - anyhow::bail!("Duplicate mapping name: {}", mapping.name); - } + for field in fields { + field.validate().with_context(|| { + format!("mapping for node {} field {} is invalid", node, field.name) + })?; + + let raw_id = (node.as_str(), field.raw_field.as_str()); + if !raw_fields.insert(raw_id) { + anyhow::bail!( + "Duplicate raw field mapping for node '{}' field '{}'", + node, + field.raw_field + ); + } - // check raw.node and raw.field are not empty and unique in combination - let raw_id = (mapping.raw.node.as_str(), mapping.raw.field.as_str()); - ensure!( - !raw_id.0.trim().is_empty(), - "mapping {} has empty raw.node", - mapping.name - ); - ensure!( - !raw_id.1.trim().is_empty(), - "mapping {} has empty raw.field", - mapping.name - ); - if !raw_fields.insert(raw_id) { - anyhow::bail!( - "Duplicate raw field mapping for node '{}' field '{}'", - mapping.raw.node, - mapping.raw.field - ); + if !names.insert(field.name.as_str()) { + anyhow::bail!("Duplicate mapping name '{}'", field.name); + } } - mapping.validate()?; } Ok(()) } - pub fn get_mapping_for_name(&self, name: &str) -> Option<&MappingEntry> { - self.mapping.iter().find(|m| m.name == name) + pub fn get_mapping_for_name(&self, name: &str) -> Option> { + self.mapping.iter().find_map(|(node, fields)| { + fields + .iter() + .find(|field| field.name == name) + .map(|field| MappingLookupResult { + node_name: node.as_str(), + mapping_entry: field, + }) + }) } pub fn get_mapping_for_raw( @@ -165,28 +227,33 @@ impl NodeMapping { node: &str, field: &str, field_type: FieldType, - ) -> Option<&MappingEntry> { - self.mapping.iter().find(|mapping| { - mapping.raw.node == node - && mapping.raw.field == field - && mapping.field_type == field_type - }) + ) -> Option> { + self.mapping + .get_key_value(node) + .and_then(|(node, mapping_entries)| { + mapping_entries + .iter() + .find(|mapping| mapping.raw_field == field && mapping.field_type == field_type) + .map(|mapping| MappingLookupResult { + node_name: node, + mapping_entry: mapping, + }) + }) } } impl MappingEntry { fn validate(&self) -> anyhow::Result<()> { ensure!( - !self.raw.node.trim().is_empty(), - "mapping {} must specify raw.node", - self.name + !self.name.trim().is_empty(), + "mapping name must be non-empty", ); + ensure!( - !self.raw.field.trim().is_empty(), - "mapping {} must specify raw.field", + !self.raw_field.trim().is_empty(), + "mapping {} has an empty raw_field", self.name ); - ensure!( self.value.slope.is_finite(), "mapping {} has a non-finite slope", @@ -221,25 +288,17 @@ impl MappingEntry { let mut covered_ranges = Vec::new(); for (index, rule) in self.logical.iter().enumerate() { - let range = rule.range.to_logical_range().with_context(|| { - format!( - "Logical rule {} for mapping {} has an invalid range", - index + 1, - self.name - ) - })?; - - if !range.is_non_empty() { + if !rule.range.is_non_empty() { bail!( "Logical rule {} for mapping {} has an empty range {}", index + 1, self.name, - range.describe() + rule.range.describe() ); } for (covered_index, covered_range) in covered_ranges.iter().enumerate() { - if let Some(overlap) = range.intersection(covered_range) { + if let Some(overlap) = rule.range.intersection(covered_range) { bail!( "Logical rule {} for mapping {} overlaps with rule {} in {}; overlapping ranges are ambiguous", index + 1, @@ -250,7 +309,7 @@ impl MappingEntry { } } - covered_ranges.push(range); + covered_ranges.push(rule.range.clone()); } let uncovered_ranges = covered_ranges.iter().fold( @@ -321,186 +380,117 @@ impl MappingEntry { impl LogicalRule { fn matches(&self, mapped_value: f64) -> bool { - self.range - .to_logical_range() - .is_ok_and(|range| range.contains(mapped_value)) + self.range.contains(mapped_value) } } -impl LogicalRangeConfig { - /// Converts the TOML range representation into the internal interval type - fn to_logical_range(&self) -> anyhow::Result { - if let Some(min) = self.min { - ensure!(min.is_finite(), "range min must be finite"); - } - if let Some(max) = self.max { - ensure!(max.is_finite(), "range max must be finite"); - } - - Ok(LogicalRange::new( - RangeBound { - value: self.min, - inclusive: self.min_inclusive, - }, - RangeBound { - value: self.max, - inclusive: self.max_inclusive, - }, - )) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct LogicalRange { - lower: RangeBound, - upper: RangeBound, -} - impl LogicalRange { /// The complete domain fn all() -> Self { - Self::new( - RangeBound::negative_infinity(), - RangeBound::positive_infinity(), - ) - } - - fn new(lower: RangeBound, upper: RangeBound) -> Self { - Self { lower, upper } + Self { + min: f64::NEG_INFINITY, + max: f64::INFINITY, + min_inclusive: false, + max_inclusive: false, + } } fn contains(&self, value: f64) -> bool { - let above_lower = match self.lower.value { - Some(lower) if self.lower.inclusive => value >= lower, - Some(lower) => value > lower, - None => true, + let above_lower = if self.min_inclusive { + value >= self.min + } else { + value > self.min }; - let below_upper = match self.upper.value { - Some(upper) if self.upper.inclusive => value <= upper, - Some(upper) => value < upper, - None => true, + let below_upper = if self.max_inclusive { + value <= self.max + } else { + value < self.max }; - above_lower && below_upper } fn intersection(&self, other: &Self) -> Option { - let intersection = Self::new( - RangeBound::max_lower(self.lower, other.lower), - RangeBound::min_upper(self.upper, other.upper), - ); + let max_cmp = self.max.partial_cmp(&other.max).unwrap(); + let min_cmp = self.min.partial_cmp(&other.min).unwrap(); - intersection.is_non_empty().then_some(intersection) + let max = self.max.min(other.max); + let min = self.min.max(other.min); + + let min_inclusive = match min_cmp { + std::cmp::Ordering::Less => other.min_inclusive, + std::cmp::Ordering::Greater => self.min_inclusive, + std::cmp::Ordering::Equal => self.min_inclusive && other.min_inclusive, + }; + + let max_inclusive = match max_cmp { + std::cmp::Ordering::Less => self.max_inclusive, + std::cmp::Ordering::Greater => other.max_inclusive, + std::cmp::Ordering::Equal => self.max_inclusive && other.max_inclusive, + }; + + let intersection = Self { + min, + max, + min_inclusive, + max_inclusive, + }; + if intersection.is_non_empty() { + Some(intersection) + } else { + None + } } fn difference(&self, other: &Self) -> Vec { let Some(intersection) = self.intersection(other) else { - return vec![*self]; + return vec![self.clone()]; }; let mut remaining = Vec::new(); - if intersection.lower.value.is_some() { - let left = Self::new( - self.lower, - RangeBound { - value: intersection.lower.value, - inclusive: !intersection.lower.inclusive, - }, - ); - if left.is_non_empty() { - remaining.push(left); - } + let left = Self { + min: self.min, + max: intersection.min, + min_inclusive: self.min_inclusive, + max_inclusive: !intersection.min_inclusive, + }; + if left.is_non_empty() { + remaining.push(left); } - if intersection.upper.value.is_some() { - let right = Self::new( - RangeBound { - value: intersection.upper.value, - inclusive: !intersection.upper.inclusive, - }, - self.upper, - ); - if right.is_non_empty() { - remaining.push(right); - } + let right = Self { + min: intersection.max, + max: self.max, + min_inclusive: !intersection.max_inclusive, + max_inclusive: self.max_inclusive, + }; + if right.is_non_empty() { + remaining.push(right); } remaining } fn is_non_empty(&self) -> bool { - match (self.lower.value, self.upper.value) { - (Some(lower), Some(upper)) if lower > upper => false, - (Some(lower), Some(upper)) if lower == upper => { - self.lower.inclusive && self.upper.inclusive - } - _ => true, + if self.max > self.min { + return true; } - } - fn describe(&self) -> String { - let lower = match self.lower.value { - Some(value) if self.lower.inclusive => format!("[{value}"), - Some(value) => format!("({value}"), - None => "(-inf".to_string(), - }; - let upper = match self.upper.value { - Some(value) if self.upper.inclusive => format!("{value}]"), - Some(value) => format!("{value})"), - None => "inf)".to_string(), - }; - - format!("{lower}, {upper}") - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct RangeBound { - value: Option, - inclusive: bool, -} - -impl RangeBound { - fn negative_infinity() -> Self { - Self { - value: None, - inclusive: false, - } - } - - fn positive_infinity() -> Self { - Self { - value: None, - inclusive: false, - } - } - - fn finite(value: f64, inclusive: bool) -> Self { - Self { - value: Some(value), - inclusive, + if self.max == self.min { + return self.min_inclusive && self.max_inclusive; } - } - fn max_lower(left: Self, right: Self) -> Self { - match (left.value, right.value) { - (None, _) => right, - (_, None) => left, - (Some(left_value), Some(right_value)) if left_value > right_value => left, - (Some(left_value), Some(right_value)) if left_value < right_value => right, - (Some(value), Some(_)) => Self::finite(value, left.inclusive && right.inclusive), - } + false } - fn min_upper(left: Self, right: Self) -> Self { - match (left.value, right.value) { - (None, _) => right, - (_, None) => left, - (Some(left_value), Some(right_value)) if left_value < right_value => left, - (Some(left_value), Some(right_value)) if left_value > right_value => right, - (Some(value), Some(_)) => Self::finite(value, left.inclusive && right.inclusive), - } + fn describe(&self) -> String { + format!( + "{}{}, {}{}", + if self.min_inclusive { "[" } else { "(" }, + self.min, + self.max, + if self.max_inclusive { "]" } else { ")" } + ) } } @@ -549,53 +539,60 @@ where >::Error: std::fmt::Debug, { let rounded = value.round(); + ensure!( + (value - rounded).abs() <= 1e-9, + "raw value {value} is not an integer" + ); T::try_from(rounded as i128).map_err(|_| anyhow::anyhow!("raw value {rounded} is out of range")) } #[cfg(test)] mod tests { + use std::{fs, path::PathBuf}; + use liquidcan::payloads::{CanDataType, CanDataValue}; use toml::Value; - use super::{LogicalValue, NodeMapping}; + use super::{LogicalValue, Mapping}; #[test] fn parses_and_applies_mapping_schema() { - let mapping = NodeMapping::parse_mapping( + let mapping = Mapping::parse_mapping( r##" id = "example" -[[mapping]] +[[mapping.ECU]] name = "tank_pressure" type = "telemetry" -raw = { node = "ECU", field = "pressure_adc" } +raw_field = "pressure_adc" value = { slope = 0.5, offset = 1.0, unit = "bar" } -[[mapping.logical]] +[[mapping.ECU.logical]] range = { min = 100 } value = "High" color = "#ff0000" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { max = 100 } value = "Normal" "##, ) .expect("mapping should parse"); - let entry = mapping + let lookup = mapping .get_mapping_for_name("tank_pressure") .expect("entry should exist"); - let mapped = entry + let mapped = lookup + .mapping_entry .mapped_value(&CanDataValue::UInt16(198)) .expect("raw value should map"); assert_eq!(mapped.value, 100.0); assert_eq!(mapped.unit, "bar"); assert_eq!( - entry.logical_value(mapped.value), + lookup.mapping_entry.logical_value(mapped.value), Some(LogicalValue { value: Value::String("High".to_string()), color: Some("#ff0000".to_string()), @@ -605,17 +602,17 @@ value = "Normal" #[test] fn rejects_duplicate_mapping_names() { - let error = NodeMapping::parse_mapping( + let error = Mapping::parse_mapping( r#" -[[mapping]] +[[mapping.node1]] name = "duplicate" -raw = { node = "node1", field = "field1" } +raw_field = "field1" type = "telemetry" value = { slope = 1.0, offset = 0.0 } -[[mapping]] +[[mapping.node1]] name = "duplicate" -raw = { node = "node1", field = "field2" } +raw_field = "field2" type = "telemetry" value = { slope = 1.0, offset = 0.0 } "#, @@ -627,45 +624,119 @@ value = { slope = 1.0, offset = 0.0 } #[test] fn converts_mapped_value_back_to_raw_parameter_type() { - let mapping = NodeMapping::parse_mapping( + let mapping = Mapping::parse_mapping( r#" -[[mapping]] +[[mapping.ECU]] name = "valve_opening" type = "parameter" -raw = { node = "ECU", field = "valve_raw" } +raw_field = "valve_raw" value = { slope = 0.5, offset = 10.0, unit = "%" } "#, ) .expect("mapping should parse"); - let entry = mapping.get_mapping_for_name("valve_opening").unwrap(); - let raw = entry + let lookup = mapping.get_mapping_for_name("valve_opening").unwrap(); + let raw = lookup + .mapping_entry .raw_value_from_mapped(60.0, CanDataType::UInt8) .expect("mapped value should invert to raw"); assert_eq!(raw, CanDataValue::UInt8(100)); } + #[test] + fn rejects_fractional_raw_values_for_integer_parameters() { + let mapping = Mapping::parse_mapping( + r#" +[[mapping.ECU]] +name = "valve_opening" +type = "parameter" +raw_field = "valve_raw" +value = { slope = 1.0, offset = 0.0 } +"#, + ) + .expect("mapping should parse"); + + let lookup = mapping.get_mapping_for_name("valve_opening").unwrap(); + let error = lookup + .mapping_entry + .raw_value_from_mapped(10.2, CanDataType::UInt8) + .expect_err("fractional integer raw values should fail"); + + assert!(format!("{error:#}").contains("is not an integer")); + } + + #[test] + fn rejects_duplicate_raw_fields_across_mapping_files() { + let dir = temp_mapping_dir("duplicate_raw"); + fs::write( + dir.join("a.toml"), + r#" +[[mapping.ECU]] +name = "first" +type = "telemetry" +raw_field = "pressure" +"#, + ) + .unwrap(); + fs::write( + dir.join("b.toml"), + r#" +[[mapping.ECU]] +name = "second" +type = "telemetry" +raw_field = "pressure" +"#, + ) + .unwrap(); + + let error = Mapping::load_mapping_from_path(dir.to_str().unwrap()) + .expect_err("duplicate raw fields across files should fail"); + + assert!(format!("{error:#}").contains("Duplicate raw field")); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn loads_mapping_directory() { + let mapping = Mapping::load_mapping_from_path("tests/mapping/split") + .expect("split mapping directory should be valid"); + + assert!(mapping.get_mapping_for_name("fuel_level").is_some()); + assert!(mapping.get_mapping_for_name("throttle_state").is_some()); + } + + #[test] + fn rejects_empty_mapping_directory() { + let dir = temp_mapping_dir("empty"); + + let error = Mapping::load_mapping_from_path(dir.to_str().unwrap()) + .expect_err("empty mapping directories should fail"); + + assert!(format!("{error:#}").contains("contains no TOML files")); + let _ = fs::remove_dir_all(dir); + } + #[test] fn checked_in_example_mapping_is_valid() { - NodeMapping::load_mapping_from_file("tests/mapping/example1.toml") + Mapping::load_mapping_from_file("tests/mapping/example1.toml") .expect("example mapping should be valid"); } #[test] fn rejects_non_exhaustive_logical_rules() { - let error = NodeMapping::parse_mapping( + let error = Mapping::parse_mapping( r#" -[[mapping]] +[[mapping.ECU]] name = "temperature" type = "telemetry" -raw = { node = "ECU", field = "temperature" } +raw_field = "temperature" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { max = 10 } value = "Cold" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { min = 10, min_inclusive = false } value = "Hot" "#, @@ -677,22 +748,22 @@ value = "Hot" #[test] fn rejects_overlapping_logical_rules() { - let error = NodeMapping::parse_mapping( + let error = Mapping::parse_mapping( r#" -[[mapping]] +[[mapping.ECU]] name = "temperature" type = "telemetry" -raw = { node = "ECU", field = "temperature" } +raw_field = "temperature" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { max = 100 } value = "Low" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { max = 50 } value = "Very low" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { min = 100 } value = "High" "#, @@ -704,22 +775,30 @@ value = "High" #[test] fn accepts_adjacent_ranges() { - NodeMapping::parse_mapping( + Mapping::parse_mapping( r#" -[[mapping]] +[[mapping.ECU]] name = "temperature" type = "telemetry" -raw = { node = "ECU", field = "temperature" } +raw_field = "temperature" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { max = 10 } value = "Cold" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { min = 10 } value = "Hot" "#, ) .expect("adjacent ranges should cover the threshold exactly once"); } + + fn temp_mapping_dir(name: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("ferro_flow_mapping_{name}_{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + path + } } diff --git a/src/nodes/node_manager.rs b/src/nodes/node_manager.rs index 03b0452..639485c 100644 --- a/src/nodes/node_manager.rs +++ b/src/nodes/node_manager.rs @@ -13,13 +13,13 @@ use liquidcan::{ }, }; -use crate::nodes::mapping::{self, LogicalValue, MappedValue, MappingEntry, NodeMapping}; +use crate::nodes::mapping::{self, LogicalValue, MappedValue, Mapping, MappingLookupResult}; use crate::{db::FieldLog, events}; use super::can_node::{CanNode, FieldInfo, RegistrationInfo, TelemetryGroupDefinition}; pub struct NodeManager<'a> { - mapping: NodeMapping, + mapping: Mapping, can_nodes: DashMap, // Nodes that did not yet receive all their field registrations. @@ -28,7 +28,7 @@ pub struct NodeManager<'a> { } impl<'a> NodeManager<'a> { - pub fn new(event_dispatcher: &'a events::EventDispatcher, mapping: NodeMapping) -> Self { + pub fn new(event_dispatcher: &'a events::EventDispatcher, mapping: Mapping) -> Self { Self { mapping, can_nodes: DashMap::new(), @@ -215,22 +215,24 @@ impl<'a> NodeManager<'a> { ) })?; - let field_infos = field_ids.iter().map(|id| { - node.telemetry_fields - .get(id) - .with_context(|| { - format!( - "received telemetry group update for node {} and group {} but field {} is not defined", - node_id, group_id, id - ) + let field_infos = field_ids + .iter() + .map(|id| { + node.telemetry_fields.get(id).with_context(|| { + format!( + "received telemetry group update for node {} and group {} but field {} is not defined", + node_id, group_id, id + ) + }) }) - }).collect::>>()?; + .collect::>>()?; + + let raw_values = group_update + .values + .unpack(field_infos.iter().map(|info| info.data_type)) + .collect::>(); - for (&id, value) in field_ids.iter().zip( - group_update - .values - .unpack(field_infos.iter().map(|info| info.data_type)), - ) { + for ((&id, field_info), value) in field_ids.iter().zip(field_infos).zip(raw_values) { let value = value.with_context(|| { format!( "failed to unpack value for node {} group {} field {}", @@ -239,8 +241,6 @@ impl<'a> NodeManager<'a> { })?; node.values.insert(id, (timestamp, value.clone())); - let field_info = node.telemetry_fields.get(&id).unwrap(); - let telemetry_log = FieldLog { timestamp, node_id: node_id as i16, @@ -382,19 +382,9 @@ impl<'a> NodeManager<'a> { /// Use this `try_` variant to distinguish missing values from invalid mappings or fields /// that have not registered yet. pub fn try_get_raw_value(&self, mapped_name: &str) -> Result> { - let mapping_entry = self - .mapping - .get_mapping_for_name(mapped_name) - .with_context(|| format!("no mapping exists for {mapped_name}"))?; - let target = self - .resolve_mapping_target(mapping_entry) - .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + let (_, target) = self.resolve_mapping_by_name(mapped_name)?; - Ok(self.can_nodes.get(&target.node_id).and_then(|node| { - node.values - .get(&target.field_id) - .map(|value| value.1.clone()) - })) + Ok(self.latest_raw_value(&target)) } /// Convenience wrapper around `try_get_raw_value` that treats errors as missing values. @@ -406,16 +396,12 @@ impl<'a> NodeManager<'a> { /// /// `Ok(None)` means the mapping and raw field exist, but no value has been received yet. pub fn try_get_mapped_value(&self, mapped_name: &str) -> Result> { - let Some(raw_value) = self.try_get_raw_value(mapped_name)? else { + let (mapping, target) = self.resolve_mapping_by_name(mapped_name)?; + let Some(raw_value) = self.latest_raw_value(&target) else { return Ok(None); }; - let mapping_entry = self - .mapping - .get_mapping_for_name(mapped_name) - .with_context(|| format!("no mapping exists for {mapped_name}"))?; - - Ok(Some(mapping_entry.mapped_value(&raw_value)?)) + Ok(Some(mapping.mapping_entry.mapped_value(&raw_value)?)) } /// Convenience wrapper around `try_get_mapped_value` that treats errors as missing values. @@ -432,12 +418,11 @@ impl<'a> NodeManager<'a> { return Ok(None); }; - let mapping_entry = self - .mapping - .get_mapping_for_name(mapped_name) - .with_context(|| format!("no mapping exists for {mapped_name}"))?; + let mapping_lookup = self.lookup_mapping(mapped_name)?; - Ok(mapping_entry.logical_value(mapped_value.value)) + Ok(mapping_lookup + .mapping_entry + .logical_value(mapped_value.value)) } /// Convenience wrapper around `try_get_logical_value` that treats errors as missing values. @@ -450,13 +435,7 @@ impl<'a> NodeManager<'a> { /// The response is processed asynchronously by the normal CAN message handler and updates the /// cached value read by `get_raw_value`, `get_mapped_value`, and `get_logical_value`. pub fn request_value(&self, mapped_name: &str) -> Result<()> { - let mapping_entry = self - .mapping - .get_mapping_for_name(mapped_name) - .with_context(|| format!("no mapping exists for {mapped_name}"))?; - let target = self - .resolve_mapping_target(mapping_entry) - .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + let (_, target) = self.resolve_mapping_by_name(mapped_name)?; self.event_dispatcher .dispatch(events::Event::SendCanMessage { @@ -476,36 +455,60 @@ impl<'a> NodeManager<'a> { /// The value is converted back to the raw CAN type using the inverse of the configured linear /// mapping, then sent as a `ParameterSetReq`. pub fn set_mapped_value(&self, mapped_name: &str, mapped_value: f64) -> Result<()> { - let mapping_entry = self - .mapping - .get_mapping_for_name(mapped_name) - .with_context(|| format!("no mapping exists for {mapped_name}"))?; - let target = self - .resolve_mapping_target(mapping_entry) - .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + let (mapping_lookup, target) = self.resolve_mapping_by_name(mapped_name)?; - if mapping_entry.field_type != mapping::FieldType::Parameter { + if mapping_lookup.mapping_entry.field_type != mapping::FieldType::Parameter { bail!("mapped field {mapped_name} is not writable because it is not a parameter"); } - let raw_value = mapping_entry.raw_value_from_mapped(mapped_value, target.data_type)?; - self.set_raw_value(mapped_name, raw_value) + let raw_value = mapping_lookup + .mapping_entry + .raw_value_from_mapped(mapped_value, target.data_type)?; + self.dispatch_parameter_set(target, raw_value); + + Ok(()) } /// Writes a raw CAN value to a mapped parameter field. pub fn set_raw_value(&self, mapped_name: &str, raw_value: CanDataValue) -> Result<()> { - let mapping_entry = self - .mapping + let (mapping_lookup, target) = self.resolve_mapping_by_name(mapped_name)?; + + if mapping_lookup.mapping_entry.field_type != mapping::FieldType::Parameter { + bail!("mapped field {mapped_name} is not writable because it is not a parameter"); + } + + self.dispatch_parameter_set(target, raw_value); + + Ok(()) + } + + fn lookup_mapping(&self, mapped_name: &str) -> Result> { + self.mapping .get_mapping_for_name(mapped_name) - .with_context(|| format!("no mapping exists for {mapped_name}"))?; + .with_context(|| format!("no mapping exists for {mapped_name}")) + } + + fn resolve_mapping_by_name( + &self, + mapped_name: &str, + ) -> Result<(MappingLookupResult<'_>, ResolvedMappingTarget)> { + let mapping_lookup = self.lookup_mapping(mapped_name)?; let target = self - .resolve_mapping_target(mapping_entry) + .resolve_mapping_target(&mapping_lookup) .with_context(|| format!("mapped field {mapped_name} is not registered"))?; - if mapping_entry.field_type != mapping::FieldType::Parameter { - bail!("mapped field {mapped_name} is not writable because it is not a parameter"); - } + Ok((mapping_lookup, target)) + } + + fn latest_raw_value(&self, target: &ResolvedMappingTarget) -> Option { + self.can_nodes.get(&target.node_id).and_then(|node| { + node.values + .get(&target.field_id) + .map(|value| value.1.clone()) + }) + } + fn dispatch_parameter_set(&self, target: ResolvedMappingTarget, raw_value: CanDataValue) { self.event_dispatcher .dispatch(events::Event::SendCanMessage { receiver_node_id: target.node_id, @@ -516,8 +519,6 @@ impl<'a> NodeManager<'a> { }, }, }); - - Ok(()) } /// Resolves a mapping entry to the currently registered node id, field id, and field type. @@ -526,21 +527,21 @@ impl<'a> NodeManager<'a> { /// ids learned during node registration. fn resolve_mapping_target( &self, - mapping_entry: &MappingEntry, + mapping_lookup_result: &MappingLookupResult, ) -> Option { self.can_nodes.iter().find_map(|node| { - if node.registration_info.device_name != mapping_entry.raw.node { + if node.registration_info.device_name != mapping_lookup_result.node_name { return None; } - let fields = match mapping_entry.field_type { + let fields = match mapping_lookup_result.mapping_entry.field_type { mapping::FieldType::Telemetry => &node.telemetry_fields, mapping::FieldType::Parameter => &node.parameter_fields, }; fields .iter() - .find(|(_, field)| field.name == mapping_entry.raw.field) + .find(|(_, field)| field.name == mapping_lookup_result.mapping_entry.raw_field) .map(|(field_id, field)| ResolvedMappingTarget { node_id: *node.key(), field_id: *field_id, @@ -605,28 +606,26 @@ mod tests { .set_mapped_value("valve_opening", 60.0) .expect("mapped parameter should be writable"); - let event = rx - .recv_timeout(Duration::from_millis(200)) - .expect("send event should be dispatched"); + assert_eq!( + receive_parameter_set(&rx), + (5, 20, CanDataValue::UInt8(100)) + ); + } - match event { - Event::SendCanMessage { - receiver_node_id, - message: - CanMessage::ParameterSetReq { - payload: - ParameterSetReqPayload { - parameter_id, - value, - }, - }, - } => { - assert_eq!(receiver_node_id, 5); - assert_eq!(parameter_id, 20); - assert_eq!(value, CanDataValue::UInt8(100)); - } - other => panic!("unexpected event: {other:?}"), - } + #[test] + fn writes_raw_parameter_values() { + let dispatcher = EventDispatcher::new(); + let (tx, rx) = mpsc::channel(); + dispatcher.subscribe(tx, vec![EventKind::SendCanMessage], "test-send-listener"); + + let manager = NodeManager::new(&dispatcher, test_mapping()); + insert_test_node(&manager); + + manager + .set_raw_value("valve_opening", CanDataValue::UInt8(42)) + .expect("raw parameter should be writable"); + + assert_eq!(receive_parameter_set(&rx), (5, 20, CanDataValue::UInt8(42))); } #[test] @@ -658,34 +657,55 @@ mod tests { } } - fn test_mapping() -> NodeMapping { - NodeMapping::parse_mapping( + fn test_mapping() -> Mapping { + Mapping::parse_mapping( r##" -[[mapping]] +[[mapping.ECU]] name = "tank_pressure" type = "telemetry" -raw = { node = "ECU", field = "pressure_adc" } +raw_field = "pressure_adc" value = { slope = 0.5, offset = 1.0, unit = "bar" } -[[mapping.logical]] +[[mapping.ECU.logical]] range = { min = 100 } value = "High" color = "#ff0000" -[[mapping.logical]] +[[mapping.ECU.logical]] range = { max = 100 } value = "Normal" -[[mapping]] +[[mapping.ECU]] name = "valve_opening" type = "parameter" -raw = { node = "ECU", field = "valve_raw" } +raw_field = "valve_raw" value = { slope = 0.5, offset = 10.0, unit = "%" } "##, ) .expect("mapping should parse") } + fn receive_parameter_set(rx: &mpsc::Receiver) -> (u8, u8, CanDataValue) { + let event = rx + .recv_timeout(Duration::from_millis(200)) + .expect("send event should be dispatched"); + + match event { + Event::SendCanMessage { + receiver_node_id, + message: + CanMessage::ParameterSetReq { + payload: + ParameterSetReqPayload { + parameter_id, + value, + }, + }, + } => (receiver_node_id, parameter_id, value), + other => panic!("unexpected event: {other:?}"), + } + } + fn insert_test_node(manager: &NodeManager<'_>) { let mut node = CanNode::new(RegistrationInfo { telemetry_count: 1, diff --git a/tests/emulator.rs b/tests/emulator.rs index 7b247f1..0623760 100644 --- a/tests/emulator.rs +++ b/tests/emulator.rs @@ -3,7 +3,7 @@ mod common; use crate::common::ShutdownGuard; use chrono::{DateTime, Utc}; use ferro_flow::config::Config; -use ferro_flow::nodes::mapping::NodeMapping; +use ferro_flow::nodes::mapping::Mapping; use ferro_flow::{events, nodes, run_with_dependencies}; use liquidcan::payloads::CanDataType; use std::{io::Write, time::Instant}; @@ -18,7 +18,7 @@ fn test_node_registration() { let emulator_config = ecuemulator_test_config_toml(&vcan_iface); let event_dispatcher = events::EventDispatcher::new(); - let node_manager = nodes::NodeManager::new(&event_dispatcher, NodeMapping::default()); + let node_manager = nodes::NodeManager::new(&event_dispatcher, Mapping::default()); let config = build_test_config(&vcan_iface); std::thread::scope(|s| { @@ -110,7 +110,7 @@ fn test_telemetry_group_updates() { let emulator_config = ecuemulator_test_config_toml(&vcan_iface); let event_dispatcher = events::EventDispatcher::new(); - let node_manager = nodes::NodeManager::new(&event_dispatcher, NodeMapping::default()); + let node_manager = nodes::NodeManager::new(&event_dispatcher, Mapping::default()); let config = build_test_config(&vcan_iface); println!("Starting application with test config: {:?}", config); diff --git a/tests/mapping/example1.toml b/tests/mapping/example1.toml index 49b7c77..e044bfa 100644 --- a/tests/mapping/example1.toml +++ b/tests/mapping/example1.toml @@ -1,36 +1,31 @@ -id = "example1" -description = "This is an example mapping file." - -[[mapping]] -raw.node = "node1" -raw.field = "field1" +[[mapping.node1]] name = "Example Mapping 1" type = "telemetry" +raw_field = "field1" value.unit = "mAh" value.slope = 0.5 value.offset = 1.0 -[[mapping.logical]] +[[mapping.node1.logical]] range = { min = 100 } value = "High" color = "#ff0000" -[[mapping.logical]] +[[mapping.node1.logical]] range = { min = 50, max = 100 } value = "Normal" -[[mapping.logical]] +[[mapping.node1.logical]] range = { max = 50 } value = "Low" # alternatively -[[mapping]] +[[mapping.node1]] name = "Example Mapping 2" type = "parameter" - -raw = { node = "node1", field = "field2" } +raw_field = "field2" value = { slope = 0.5, offset = 1.0, unit = "mAh" } diff --git a/tests/mapping/split/engine.toml b/tests/mapping/split/engine.toml new file mode 100644 index 0000000..6d80406 --- /dev/null +++ b/tests/mapping/split/engine.toml @@ -0,0 +1,10 @@ +[[mapping.EngineECU]] +name = "throttle_state" +type = "parameter" +raw_field = "throttle_raw" +value = { slope = 0.25, offset = 0.0, unit = "%" } +logical = [ + { range = { min = 100 }, value = "High", color = "#ff0000" }, + { range = { min = 50, max = 100 }, value = "Normal" }, + { range = { max = 50 }, value = "Low" }, +] diff --git a/tests/mapping/split/fuel.toml b/tests/mapping/split/fuel.toml new file mode 100644 index 0000000..eb72155 --- /dev/null +++ b/tests/mapping/split/fuel.toml @@ -0,0 +1,5 @@ +[[mapping.FuelECU]] +name = "fuel_level" +type = "telemetry" +raw_field = "level_adc" +value = { slope = 0.5, offset = 1.0, unit = "mAh" } From 7445262c8579bdf058c8c426db256e560e0c52ef Mon Sep 17 00:00:00 2001 From: Michael Debertol Date: Thu, 7 May 2026 20:28:52 +0200 Subject: [PATCH 4/5] remove id from mapping --- src/nodes/mapping.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/nodes/mapping.rs b/src/nodes/mapping.rs index dc55425..b826613 100644 --- a/src/nodes/mapping.rs +++ b/src/nodes/mapping.rs @@ -560,8 +560,6 @@ mod tests { fn parses_and_applies_mapping_schema() { let mapping = Mapping::parse_mapping( r##" -id = "example" - [[mapping.ECU]] name = "tank_pressure" type = "telemetry" From 808aff365828d14cd52c96c5d681733c55f4d473 Mon Sep 17 00:00:00 2001 From: Michael Debertol Date: Thu, 7 May 2026 20:30:17 +0200 Subject: [PATCH 5/5] add schema --- README.md | 2 + schemas/mapping.schema.json | 121 ++++++++++++++++++++++++++++++++++++ taplo.toml | 5 ++ 3 files changed, 128 insertions(+) create mode 100644 schemas/mapping.schema.json create mode 100644 taplo.toml diff --git a/README.md b/README.md index e8e2f19..94d86fe 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ logical = [ ] ``` +The repository includes [schemas/mapping.schema.json](schemas/mapping.schema.json) and [taplo.toml](taplo.toml) so Taplo-compatible editors, including VS Code with Even Better TOML, can validate mapping files before the application loads them. The schema is associated with `mapping.toml` files and TOML files under `mapping/` or `mappings/` directories. + ### Running CI Checks The repository includes a CI script (`ci-rust.sh`) that runs all quality checks on the Rust implementation. This script is used both locally and in GitHub Actions diff --git a/schemas/mapping.schema.json b/schemas/mapping.schema.json new file mode 100644 index 0000000..533d48a --- /dev/null +++ b/schemas/mapping.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spaceteam.at/ferroflow/schemas/mapping.schema.json", + "title": "FerroFlow Mapping", + "description": "TOML schema for FerroFlow node mapping files.", + "type": "object", + "required": ["mapping"], + "additionalProperties": false, + "properties": { + "mapping": { + "type": "object", + "description": "Mappings grouped by LiquidCAN node/device name.", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + ".+": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/mappingEntry" + } + } + } + } + }, + "$defs": { + "mappingEntry": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type", "raw_field"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique application-facing mapping name." + }, + "type": { + "type": "string", + "enum": ["telemetry", "parameter"], + "description": "Whether the raw field is telemetry or a writable parameter." + }, + "raw_field": { + "type": "string", + "minLength": 1, + "description": "Raw LiquidCAN field name on the enclosing node." + }, + "value": { + "$ref": "#/$defs/valueParams" + }, + "logical": { + "type": "array", + "description": "Logical labels for mapped numeric ranges. Runtime validation requires these ranges to be non-overlapping and exhaustive when present.", + "items": { + "$ref": "#/$defs/logicalRule" + } + } + } + }, + "valueParams": { + "type": "object", + "additionalProperties": false, + "required": ["slope", "offset"], + "properties": { + "slope": { + "type": "number", + "not": { "const": 0 }, + "default": 1.0, + "description": "Linear conversion slope: mapped = raw * slope + offset." + }, + "offset": { + "type": "number", + "default": 0.0, + "description": "Linear conversion offset: mapped = raw * slope + offset." + }, + "unit": { + "type": "string", + "default": "" + } + } + }, + "logicalRule": { + "type": "object", + "additionalProperties": false, + "required": ["range", "value"], + "properties": { + "range": { + "$ref": "#/$defs/logicalRange" + }, + "value": { + "description": "Logical value returned when the mapped numeric value is inside the range." + }, + "color": { + "type": "string", + "description": "Optional display color, commonly a hex color such as #ff0000." + } + } + }, + "logicalRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "min": { + "type": "number", + "description": "Lower bound. Omit for an unbounded lower range." + }, + "max": { + "type": "number", + "description": "Upper bound. Omit for an unbounded upper range." + }, + "min_inclusive": { + "type": "boolean", + "default": true + }, + "max_inclusive": { + "type": "boolean", + "default": false + } + } + } + } +} diff --git a/taplo.toml b/taplo.toml new file mode 100644 index 0000000..cc11e9b --- /dev/null +++ b/taplo.toml @@ -0,0 +1,5 @@ +[[rule]] +include = ["**/mapping.toml", "**/mapping/**/*.toml", "**/mappings/**/*.toml"] + +[rule.schema] +path = "./schemas/mapping.schema.json"