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/README.md b/README.md index 144bc8d..94d86fe 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,60 @@ 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" }, +] +``` + +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 **Run all checks:** + ```bash ./ci-rust.sh # or explicitly @@ -44,17 +72,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 +97,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 +108,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 +130,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/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/src/config/mod.rs b/src/config/mod.rs index 3c04e19..a9b2e5f 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..9d2dc8d 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::Mapping::load_mapping_from_path(&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..b826613 --- /dev/null +++ b/src/nodes/mapping.rs @@ -0,0 +1,802 @@ +use anyhow::{Context, bail, ensure}; +use liquidcan::payloads::{CanDataType, CanDataValue}; +use serde::Deserialize; +use std::{ + collections::{BTreeMap, HashSet}, + fs, + path::Path, +}; +use toml::Value; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Mapping { + pub mapping: BTreeMap>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MappingEntry { + pub name: String, + #[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 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: LogicalRange, + pub value: Value, + #[serde(default)] + pub color: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LogicalRange { + /// Inclusive lower bound by default. If omitted, the range is unbounded below. + #[serde(default = "default_unbounded_min")] + pub min: f64, + /// Exclusive upper bound by default. If omitted, the range is unbounded above. + #[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 +} + +#[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, +} + +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()); + } + + 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) + .map_err(|err| anyhow::anyhow!("Failed to parse mapping config: {}", err))?; + + 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 (node, fields) in &self.mapping { + ensure!( + !node.trim().is_empty(), + "mapping contains an entry with an empty node 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 + ); + } + + if !names.insert(field.name.as_str()) { + anyhow::bail!("Duplicate mapping name '{}'", field.name); + } + } + } + + Ok(()) + } + + 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( + &self, + node: &str, + field: &str, + field_type: FieldType, + ) -> 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.name.trim().is_empty(), + "mapping name must be non-empty", + ); + + ensure!( + !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", + 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() { + if !rule.range.is_non_empty() { + bail!( + "Logical rule {} for mapping {} has an empty range {}", + index + 1, + self.name, + rule.range.describe() + ); + } + + for (covered_index, covered_range) in covered_ranges.iter().enumerate() { + if let Some(overlap) = rule.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(rule.range.clone()); + } + + 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.contains(mapped_value) + } +} + +impl LogicalRange { + /// The complete domain + fn all() -> Self { + Self { + min: f64::NEG_INFINITY, + max: f64::INFINITY, + min_inclusive: false, + max_inclusive: false, + } + } + + fn contains(&self, value: f64) -> bool { + let above_lower = if self.min_inclusive { + value >= self.min + } else { + value > self.min + }; + 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 max_cmp = self.max.partial_cmp(&other.max).unwrap(); + let min_cmp = self.min.partial_cmp(&other.min).unwrap(); + + 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.clone()]; + }; + + let mut remaining = Vec::new(); + + 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); + } + + 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 { + if self.max > self.min { + return true; + } + + if self.max == self.min { + return self.min_inclusive && self.max_inclusive; + } + + false + } + + fn describe(&self) -> String { + format!( + "{}{}, {}{}", + if self.min_inclusive { "[" } else { "(" }, + self.min, + self.max, + if self.max_inclusive { "]" } else { ")" } + ) + } +} + +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(); + 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, Mapping}; + + #[test] + fn parses_and_applies_mapping_schema() { + let mapping = Mapping::parse_mapping( + r##" +[[mapping.ECU]] +name = "tank_pressure" +type = "telemetry" +raw_field = "pressure_adc" +value = { slope = 0.5, offset = 1.0, unit = "bar" } + +[[mapping.ECU.logical]] +range = { min = 100 } +value = "High" +color = "#ff0000" + +[[mapping.ECU.logical]] +range = { max = 100 } +value = "Normal" +"##, + ) + .expect("mapping should parse"); + + let lookup = mapping + .get_mapping_for_name("tank_pressure") + .expect("entry should exist"); + + 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!( + lookup.mapping_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 = Mapping::parse_mapping( + r#" +[[mapping.node1]] +name = "duplicate" +raw_field = "field1" +type = "telemetry" +value = { slope = 1.0, offset = 0.0 } + +[[mapping.node1]] +name = "duplicate" +raw_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 = Mapping::parse_mapping( + r#" +[[mapping.ECU]] +name = "valve_opening" +type = "parameter" +raw_field = "valve_raw" +value = { slope = 0.5, offset = 10.0, unit = "%" } +"#, + ) + .expect("mapping should parse"); + + 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() { + Mapping::load_mapping_from_file("tests/mapping/example1.toml") + .expect("example mapping should be valid"); + } + + #[test] + fn rejects_non_exhaustive_logical_rules() { + let error = Mapping::parse_mapping( + r#" +[[mapping.ECU]] +name = "temperature" +type = "telemetry" +raw_field = "temperature" + +[[mapping.ECU.logical]] +range = { max = 10 } +value = "Cold" + +[[mapping.ECU.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 = Mapping::parse_mapping( + r#" +[[mapping.ECU]] +name = "temperature" +type = "telemetry" +raw_field = "temperature" + +[[mapping.ECU.logical]] +range = { max = 100 } +value = "Low" + +[[mapping.ECU.logical]] +range = { max = 50 } +value = "Very low" + +[[mapping.ECU.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() { + Mapping::parse_mapping( + r#" +[[mapping.ECU]] +name = "temperature" +type = "telemetry" +raw_field = "temperature" + +[[mapping.ECU.logical]] +range = { max = 10 } +value = "Cold" + +[[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/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..639485c 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, Mapping, MappingLookupResult}; use crate::{db::FieldLog, events}; use super::can_node::{CanNode, FieldInfo, RegistrationInfo, TelemetryGroupDefinition}; pub struct NodeManager<'a> { + mapping: Mapping, 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: Mapping) -> Self { Self { + mapping, can_nodes: DashMap::new(), registering_nodes: Mutex::new(HashMap::new()), event_dispatcher, @@ -211,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 {}", @@ -235,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, @@ -370,4 +374,363 @@ 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 (_, target) = self.resolve_mapping_by_name(mapped_name)?; + + Ok(self.latest_raw_value(&target)) + } + + /// 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 (mapping, target) = self.resolve_mapping_by_name(mapped_name)?; + let Some(raw_value) = self.latest_raw_value(&target) else { + return Ok(None); + }; + + Ok(Some(mapping.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_lookup = self.lookup_mapping(mapped_name)?; + + Ok(mapping_lookup + .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 (_, target) = self.resolve_mapping_by_name(mapped_name)?; + + 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_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"); + } + + 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_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}")) + } + + 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_lookup) + .with_context(|| format!("mapped field {mapped_name} is not registered"))?; + + 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, + message: CanMessage::ParameterSetReq { + payload: ParameterSetReqPayload { + parameter_id: target.field_id, + value: raw_value, + }, + }, + }); + } + + /// 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_lookup_result: &MappingLookupResult, + ) -> Option { + self.can_nodes.iter().find_map(|node| { + if node.registration_info.device_name != mapping_lookup_result.node_name { + return None; + } + + 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_lookup_result.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"); + + assert_eq!( + receive_parameter_set(&rx), + (5, 20, CanDataValue::UInt8(100)) + ); + } + + #[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] + 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() -> Mapping { + Mapping::parse_mapping( + r##" +[[mapping.ECU]] +name = "tank_pressure" +type = "telemetry" +raw_field = "pressure_adc" +value = { slope = 0.5, offset = 1.0, unit = "bar" } + +[[mapping.ECU.logical]] +range = { min = 100 } +value = "High" +color = "#ff0000" + +[[mapping.ECU.logical]] +range = { max = 100 } +value = "Normal" + +[[mapping.ECU]] +name = "valve_opening" +type = "parameter" +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, + 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/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" diff --git a/tests/emulator.rs b/tests/emulator.rs index 334829b..0623760 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::Mapping; 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, Mapping::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, Mapping::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..e044bfa --- /dev/null +++ b/tests/mapping/example1.toml @@ -0,0 +1,36 @@ +[[mapping.node1]] +name = "Example Mapping 1" +type = "telemetry" +raw_field = "field1" +value.unit = "mAh" +value.slope = 0.5 +value.offset = 1.0 + +[[mapping.node1.logical]] +range = { min = 100 } +value = "High" +color = "#ff0000" + +[[mapping.node1.logical]] +range = { min = 50, max = 100 } +value = "Normal" + +[[mapping.node1.logical]] +range = { max = 50 } +value = "Low" + + +# alternatively + +[[mapping.node1]] +name = "Example Mapping 2" +type = "parameter" +raw_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" }, +] 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" }