diff --git a/Cargo.lock b/Cargo.lock index 59553a5..7491fe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,12 +35,68 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -86,6 +142,37 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lantern-app" version = "0.1.0" @@ -108,6 +195,15 @@ name = "lantern-profile" version = "0.1.0" dependencies = [ "lantern-domain", + "rust_decimal", + "schemars", + "serde", + "serde_jcs", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "toml", ] [[package]] @@ -155,6 +251,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "num-traits" version = "0.2.19" @@ -272,6 +374,26 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -313,6 +435,133 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "syn" version = "2.0.119" @@ -368,6 +617,51 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unarray" version = "0.1.4" @@ -425,6 +719,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -450,3 +750,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/lantern-profile/Cargo.toml b/crates/lantern-profile/Cargo.toml index 48356a5..83f3211 100644 --- a/crates/lantern-profile/Cargo.toml +++ b/crates/lantern-profile/Cargo.toml @@ -10,6 +10,15 @@ publish = false [dependencies] lantern-domain.workspace = true +rust_decimal.workspace = true +schemars.workspace = true +serde.workspace = true +serde_jcs.workspace = true +serde_json.workspace = true +serde_path_to_error.workspace = true +sha2.workspace = true +thiserror.workspace = true +toml.workspace = true [lints] workspace = true diff --git a/crates/lantern-profile/src/canonical.rs b/crates/lantern-profile/src/canonical.rs new file mode 100644 index 0000000..e4e20e2 --- /dev/null +++ b/crates/lantern-profile/src/canonical.rs @@ -0,0 +1,163 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::ProfileError; + +/// Fully materialized semantic representation used to compute `profile_hash`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalProfileV1 { + pub canonical_schema_version: u32, + pub schema_version: u32, + pub profile_id: String, + pub revision: u32, + pub vendor: String, + pub family: String, + pub model: String, + pub sources: Vec, + pub safety_notes: Vec, + pub hardware_verification: CanonicalHardwareVerification, + pub protocol: CanonicalProtocol, + pub identification_probes: Vec, + pub aliases: BTreeMap, + pub parameters: Vec, + pub presentation_order: Vec, + pub groups: Vec, + pub faults: Vec, + pub telemetry_presets: Vec, + pub restore_order: Vec, +} + +impl CanonicalProfileV1 { + /// Serializes the model with RFC 8785 JCS. + pub fn to_jcs_bytes(&self) -> Result, ProfileError> { + serde_jcs::to_vec(self).map_err(|error| ProfileError::Canonicalization(error.to_string())) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalHardwareVerification { + pub status: String, + pub firmware: Vec, + pub manual_revision: Option, + pub qualification_report_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalProtocol { + pub allowed_baud_rates: Vec, + pub default_baud_rate: u32, + pub allowed_parity: Vec, + pub default_parity: String, + pub data_bits: u8, + pub stop_bits: u8, + pub response_timeout_ms: u64, + pub min_inter_frame_delay_us: u64, + pub rs485_mode: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalProbe { + pub id: String, + pub description: String, + pub table: String, + pub address_pdu: u16, + pub count: u16, + pub expected_raw: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalParameter { + pub id: String, + pub code: String, + pub name: String, + pub description: String, + pub table: String, + pub address_pdu: u16, + pub register_count: u16, + pub encoding: String, + pub byte_order: String, + pub word_order: String, + pub quantity_kind: String, + pub custom_quantity_id: Option, + pub unit: String, + pub scale: CanonicalScale, + pub access: String, + pub restore_policy: String, + pub required_drive_state: String, + pub read_back: CanonicalReadBack, + pub write: Option, + pub backup: bool, + pub do_not_bridge: bool, + pub poll_class: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CanonicalScale { + None, + Fixed { + multiplier: String, + divisor: String, + offset: String, + decimal_places: u32, + rounding: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CanonicalReadBack { + ExactRaw, + AcceptedRawSet { + values: Vec>, + documentation: String, + qualification_report_id: String, + }, + FloatExactBits, + FloatAbsRelTolerance { + absolute: String, + relative: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalWritePolicy { + pub function: String, + pub forbidden_raw: Vec>, + pub settle_delay_ms: u64, + pub verification_attempts: u8, + pub verification_interval_ms: u64, + pub max_verification_window_ms: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalGroup { + pub id: String, + pub name: String, + pub parameters: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalFault { + pub id: String, + pub source_parameter: String, + pub representation: String, + pub no_fault_values: Vec, + pub meanings: BTreeMap, + pub freeze_frame: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalFaultMeaning { + pub name: String, + pub description: String, + pub severity: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CanonicalPreset { + pub id: String, + pub name: String, + pub channels: Vec, +} diff --git a/crates/lantern-profile/src/document.rs b/crates/lantern-profile/src/document.rs new file mode 100644 index 0000000..3d5fd6f --- /dev/null +++ b/crates/lantern-profile/src/document.rs @@ -0,0 +1,487 @@ +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Version-one device profile document accepted from TOML or JSON. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileDocumentV1 { + pub schema_version: u32, + pub profile_id: String, + pub revision: u32, + pub vendor: String, + pub family: String, + pub model: String, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub safety_notes: Vec, + #[serde(default)] + pub hardware_verification: HardwareVerificationDocument, + pub protocol: ProtocolDocument, + #[serde(default)] + pub identification_probes: Vec, + #[serde(default)] + pub aliases: BTreeMap, + #[serde(default)] + pub parameters: Vec, + #[serde(default)] + pub presentation_order: Vec, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub faults: Vec, + #[serde(default)] + pub telemetry_presets: Vec, + #[serde(default)] + pub restore_order: Vec, +} + +/// Hardware evidence declared by a profile source. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct HardwareVerificationDocument { + #[serde(default)] + pub status: HardwareVerificationStatusDocument, + #[serde(default)] + pub firmware: Vec, + #[serde(default)] + pub manual_revision: Option, + #[serde(default)] + pub qualification_report_id: Option, +} + +/// Profile hardware-verification state. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HardwareVerificationStatusDocument { + #[default] + Unverified, + Fictional, + Qualified, +} + +/// Allowed and default serial-link settings. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProtocolDocument { + #[serde(default = "default_baud_rates")] + pub allowed_baud_rates: Vec, + #[serde(default = "default_baud_rate")] + pub default_baud_rate: u32, + #[serde(default = "default_parities")] + pub allowed_parity: Vec, + #[serde(default)] + pub default_parity: ParityDocument, + #[serde(default = "default_data_bits")] + pub data_bits: u8, + #[serde(default = "default_stop_bits")] + pub stop_bits: u8, + #[serde(default = "default_response_timeout_ms")] + pub response_timeout_ms: u64, + #[serde(default)] + pub min_inter_frame_delay_us: u64, + #[serde(default)] + pub rs485_mode: Rs485ModeDocument, +} + +fn default_baud_rates() -> Vec { + vec![9_600] +} + +const fn default_baud_rate() -> u32 { + 9_600 +} + +fn default_parities() -> Vec { + vec![ParityDocument::None] +} + +const fn default_data_bits() -> u8 { + 8 +} + +const fn default_stop_bits() -> u8 { + 1 +} + +const fn default_response_timeout_ms() -> u64 { + 500 +} + +/// Serial parity in a profile document. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ParityDocument { + #[default] + None, + Even, + Odd, +} + +/// RS-485 direction control declared by a profile. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Rs485ModeDocument { + #[default] + AdapterManaged, + LinuxIoctl, +} + +/// Explicit manufacturer address notation. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(tag = "notation", rename_all = "snake_case", deny_unknown_fields)] +pub enum AddressDocument { + PduZeroBased { value: u64 }, + ProtocolOneBased { value: u64 }, + Modicon5Digit { value: u64 }, + Modicon6Digit { value: u64 }, +} + +/// Modbus table in a profile document. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TableDocument { + InputRegisters, + HoldingRegisters, +} + +/// Read-only identity probe. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentificationProbeDocument { + pub id: String, + pub description: String, + pub table: TableDocument, + pub address: AddressDocument, + pub count: u16, + pub expected_raw: Vec, +} + +/// One parameter definition. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ParameterDocument { + pub id: String, + pub code: String, + pub name: String, + #[serde(default)] + pub description: String, + pub table: TableDocument, + pub address: AddressDocument, + pub encoding: EncodingDocument, + #[serde(default)] + pub byte_order: ByteOrderDocument, + #[serde(default)] + pub word_order: WordOrderDocument, + pub quantity: QuantityDocument, + pub unit: String, + #[serde(default)] + pub scale: Option, + pub access: AccessDocument, + pub restore_policy: RestorePolicyDocument, + #[serde(default)] + pub required_drive_state: RequiredDriveStateDocument, + #[serde(default)] + pub read_back: ReadBackPolicyDocument, + #[serde(default)] + pub write: Option, + #[serde(default)] + pub backup: bool, + #[serde(default)] + pub do_not_bridge: bool, + #[serde(default)] + pub poll_class: PollClassDocument, +} + +/// Register encoding represented in profile data. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EncodingDocument { + Unsigned16, + Signed16, + Unsigned32, + Signed32, + Unsigned64, + Signed64, + Float32, + Float64, + Bcd16, + Bcd32, + Enum16, + Enum32, + Bitfield16, + Bitfield32, + Bitfield64, +} + +impl EncodingDocument { + #[must_use] + pub const fn register_width(self) -> usize { + match self { + Self::Unsigned16 | Self::Signed16 | Self::Bcd16 | Self::Enum16 | Self::Bitfield16 => 1, + Self::Unsigned32 + | Self::Signed32 + | Self::Float32 + | Self::Bcd32 + | Self::Enum32 + | Self::Bitfield32 => 2, + Self::Unsigned64 | Self::Signed64 | Self::Float64 | Self::Bitfield64 => 4, + } + } + + #[must_use] + pub const fn is_float(self) -> bool { + matches!(self, Self::Float32 | Self::Float64) + } + + #[must_use] + pub const fn supports_fixed_scale(self) -> bool { + matches!( + self, + Self::Unsigned16 + | Self::Signed16 + | Self::Unsigned32 + | Self::Signed32 + | Self::Unsigned64 + | Self::Signed64 + | Self::Bcd16 + | Self::Bcd32 + ) + } +} + +/// Byte order within one register. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ByteOrderDocument { + #[default] + BigEndian, + LittleEndian, +} + +/// Word order for multi-register values. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WordOrderDocument { + #[default] + MostSignificantFirst, + LeastSignificantFirst, +} + +/// Physical quantity in profile data. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum QuantityDocument { + Frequency, + RotationalSpeed, + Current, + Voltage, + Power, + Energy, + Torque, + Temperature, + Time, + Ratio, + Pressure, + Flow, + Count, + DigitalState, + Unitless, + Custom { id: String }, +} + +/// Exact fixed-point scale expressed as decimal strings. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ScaleDocument { + #[serde(default = "decimal_one")] + pub multiplier: String, + #[serde(default = "decimal_one")] + pub divisor: String, + #[serde(default = "decimal_zero")] + pub offset: String, + #[serde(default)] + pub decimal_places: u32, + #[serde(default)] + pub rounding: RoundingDocument, +} + +fn decimal_one() -> String { + "1".to_owned() +} + +fn decimal_zero() -> String { + "0".to_owned() +} + +/// Rounding rule for engineering-to-raw conversion. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RoundingDocument { + #[default] + MidpointNearestEven, + MidpointAwayFromZero, + TowardZero, + AwayFromZero, + TowardPositiveInfinity, + TowardNegativeInfinity, +} + +/// Parameter access class. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AccessDocument { + ReadOnly, + WritableWhenStopped, + Commissioning, + Dangerous, +} + +/// Explicit restore classification. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RestorePolicyDocument { + Normal, + LinkCritical, + RestartRequired, + ManualOnly, +} + +/// Fresh drive-state requirement for a write. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RequiredDriveStateDocument { + #[default] + Any, + Stopped, + Faulted, +} + +/// Read-back success policy. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ReadBackPolicyDocument { + #[default] + ExactRaw, + AcceptedRawSet { + values: Vec>, + documentation: String, + qualification_report_id: String, + }, + FloatExactBits, + FloatAbsRelTolerance { + absolute: String, + relative: String, + }, +} + +/// Guarded write function and bounded delayed verification. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WritePolicyDocument { + pub function: WriteFunctionDocument, + #[serde(default)] + pub forbidden_raw: Vec>, + #[serde(default)] + pub settle_delay_ms: u64, + #[serde(default = "default_verification_attempts")] + pub verification_attempts: u8, + #[serde(default)] + pub verification_interval_ms: u64, + #[serde(default = "default_verification_window_ms")] + pub max_verification_window_ms: u64, +} + +const fn default_verification_attempts() -> u8 { + 1 +} + +const fn default_verification_window_ms() -> u64 { + 1_000 +} + +/// Write function allowed for a parameter. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WriteFunctionDocument { + WriteSingleRegister, + WriteMultipleRegisters, +} + +/// Profile-approved polling class. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PollClassDocument { + Fast, + #[default] + Normal, + Slow, + OnDemand, +} + +/// Presentation group with an explicit parameter order. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GroupDocument { + pub id: String, + pub name: String, + #[serde(default)] + pub parameters: Vec, +} + +/// Profile-defined fault source and meanings. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FaultDocument { + pub id: String, + pub source_parameter: String, + pub representation: FaultRepresentationDocument, + #[serde(default)] + pub no_fault_values: Vec, + #[serde(default)] + pub meanings: BTreeMap, + #[serde(default)] + pub freeze_frame: Vec, +} + +/// Fault source representation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FaultRepresentationDocument { + ScalarCode, + BitSet, +} + +/// Human-readable fault definition. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FaultMeaningDocument { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub severity: FaultSeverityDocument, +} + +/// Fault severity used only for presentation and filtering. +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FaultSeverityDocument { + Info, + Warning, + #[default] + Fault, + Critical, +} + +/// Named telemetry preset with a meaningful channel order. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TelemetryPresetDocument { + pub id: String, + pub name: String, + #[serde(default)] + pub channels: Vec, +} diff --git a/crates/lantern-profile/src/hash.rs b/crates/lantern-profile/src/hash.rs new file mode 100644 index 0000000..260b998 --- /dev/null +++ b/crates/lantern-profile/src/hash.rs @@ -0,0 +1,84 @@ +use std::fmt; + +use sha2::{Digest, Sha256}; + +/// SHA-256 of the exact source bytes loaded from storage. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SourceHash([u8; 32]); + +/// SHA-256 of the semantic `CanonicalProfileV1` representation. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ProfileHash([u8; 32]); + +impl SourceHash { + #[must_use] + pub(crate) fn digest(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + /// Returns the raw digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Returns lowercase hexadecimal text. + #[must_use] + pub fn to_hex(self) -> String { + encode_hex(&self.0) + } +} + +impl ProfileHash { + #[must_use] + pub(crate) fn digest(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + /// Returns the raw digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Returns lowercase hexadecimal text. + #[must_use] + pub fn to_hex(self) -> String { + encode_hex(&self.0) + } +} + +impl fmt::Display for SourceHash { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&encode_hex(&self.0)) + } +} + +impl fmt::Display for ProfileHash { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&encode_hex(&self.0)) + } +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + result.push(char::from(HEX[usize::from(byte >> 4)])); + result.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + result +} + +#[cfg(test)] +mod tests { + use super::{ProfileHash, SourceHash}; + + #[test] + fn hash_types_are_not_interchangeable() { + let source = SourceHash::digest(b"value"); + let profile = ProfileHash::digest(b"value"); + assert_eq!(source.to_hex(), profile.to_hex()); + assert_eq!(source.to_hex().len(), 64); + } +} diff --git a/crates/lantern-profile/src/lib.rs b/crates/lantern-profile/src/lib.rs index f316e29..a8ea164 100644 --- a/crates/lantern-profile/src/lib.rs +++ b/crates/lantern-profile/src/lib.rs @@ -1,47 +1,476 @@ -//! Device-profile parsing and validation boundary. +//! Versioned device-profile parsing, canonicalization, and validation. #![forbid(unsafe_code)] -use lantern_domain::ProfileId; - -/// Immutable profile accepted by the application layer. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ValidatedDeviceProfile { - profile_id: ProfileId, - vendor: String, - model: String, -} - -impl ValidatedDeviceProfile { - /// Creates a profile from values already checked by the profile validator. - #[must_use] - pub fn from_validated_parts( - profile_id: ProfileId, - vendor: impl Into, - model: impl Into, - ) -> Self { - Self { - profile_id, - vendor: vendor.into(), - model: model.into(), +mod canonical; +pub mod document; +mod hash; +mod validated; +mod validation; + +use std::{fmt, str}; + +pub use canonical::{ + CanonicalFault, CanonicalFaultMeaning, CanonicalGroup, CanonicalHardwareVerification, + CanonicalParameter, CanonicalPreset, CanonicalProbe, CanonicalProfileV1, CanonicalProtocol, + CanonicalReadBack, CanonicalScale, CanonicalWritePolicy, +}; +pub use hash::{ProfileHash, SourceHash}; +pub use validated::{ + ValidatedDeviceProfile, ValidatedFault, ValidatedFaultMeaning, ValidatedIdentificationProbe, + ValidatedParameter, ValidatedProtocol, ValidatedReadBackPolicy, ValidatedWritePolicy, +}; +pub use validation::normalize_address; + +use document::ProfileDocumentV1; +use thiserror::Error; + +/// Maximum accepted profile file size. +pub const MAX_PROFILE_BYTES: usize = 4 * 1024 * 1024; +const MAX_STRUCTURAL_DEPTH: usize = 64; + +/// Explicit input syntax. File extensions are resolved by the storage/registry layer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProfileInputFormat { + Toml, + Json, +} + +impl fmt::Display for ProfileInputFormat { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml => formatter.write_str("TOML"), + Self::Json => formatter.write_str("JSON"), + } + } +} + +/// Profile parsing, canonicalization, or semantic validation error. +#[derive(Debug, Error)] +pub enum ProfileError { + #[error("profile has {actual} bytes; maximum is {maximum}")] + InputTooLarge { actual: usize, maximum: usize }, + #[error("profile structural depth exceeds {maximum}")] + NestingTooDeep { maximum: usize }, + #[error("profile is not valid UTF-8: {0}")] + InvalidUtf8(String), + #[error("{format} profile parse error at {path}: {message}")] + Parse { + format: ProfileInputFormat, + path: String, + message: String, + }, + #[error("unsupported profile schema version {0}")] + UnsupportedSchema(u32), + #[error("profile validation error at {path}: {message}")] + Validation { path: String, message: String }, + #[error("profile canonicalization failed: {0}")] + Canonicalization(String), + #[error("profile TOML normalization failed: {0}")] + Normalization(String), + #[error("profile JSON Schema generation failed: {0}")] + Schema(String), +} + +/// Parses untrusted bytes, validates all semantics, and returns the only runtime profile model. +pub fn load_profile( + bytes: &[u8], + format: ProfileInputFormat, +) -> Result { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(ProfileError::InputTooLarge { + actual: bytes.len(), + maximum: MAX_PROFILE_BYTES, + }); + } + check_structural_depth(bytes)?; + let source_hash = SourceHash::digest(bytes); + let source = + str::from_utf8(bytes).map_err(|error| ProfileError::InvalidUtf8(error.to_string()))?; + let document = parse_document(source, format)?; + validation::validate_document(document, source_hash) +} + +/// Serializes the normalized current document as deterministic TOML. +pub fn normalize_to_toml(profile: &ValidatedDeviceProfile) -> Result { + toml::to_string_pretty(profile.normalized_document()) + .map_err(|error| ProfileError::Normalization(error.to_string())) +} + +/// Generates JSON Schema from the same Rust document types used by the parser. +pub fn profile_schema_json() -> Result { + let schema = schemars::schema_for!(ProfileDocumentV1); + serde_json::to_string_pretty(&schema).map_err(|error| ProfileError::Schema(error.to_string())) +} + +fn parse_document( + source: &str, + format: ProfileInputFormat, +) -> Result { + match format { + ProfileInputFormat::Json => { + let mut deserializer = serde_json::Deserializer::from_str(source); + serde_path_to_error::deserialize(&mut deserializer).map_err(|error| { + ProfileError::Parse { + format, + path: error.path().to_string(), + message: error.inner().to_string(), + } + }) + } + ProfileInputFormat::Toml => { + let deserializer = + toml::Deserializer::parse(source).map_err(|error| ProfileError::Parse { + format, + path: "".to_owned(), + message: error.to_string(), + })?; + serde_path_to_error::deserialize(deserializer).map_err(|error| ProfileError::Parse { + format, + path: error.path().to_string(), + message: error.inner().to_string(), + }) + } + } +} + +fn check_structural_depth(bytes: &[u8]) -> Result<(), ProfileError> { + let mut depth = 0_usize; + let mut in_string = false; + let mut escaped = false; + let mut quote = 0_u8; + + for byte in bytes { + if in_string { + if escaped { + escaped = false; + } else if *byte == b'\\' { + escaped = true; + } else if *byte == quote { + in_string = false; + } + continue; } + if matches!(*byte, b'"' | b'\'') { + in_string = true; + quote = *byte; + continue; + } + match *byte { + b'{' | b'[' => { + depth += 1; + if depth > MAX_STRUCTURAL_DEPTH { + return Err(ProfileError::NestingTooDeep { + maximum: MAX_STRUCTURAL_DEPTH, + }); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + MAX_PROFILE_BYTES, ProfileError, ProfileInputFormat, load_profile, normalize_to_toml, + profile_schema_json, + }; + use crate::document::{AddressDocument, TableDocument}; + + const TOML_PROFILE: &str = r#" +schema_version = 1 +profile_id = "example.fictional-vfd-1000" +revision = 1 +vendor = "Example Devices" +family = "Fictional" +model = "VFD 1000" +sources = ["Fictional interoperability fixture"] +safety_notes = ["Never use fictional data on real equipment"] +presentation_order = ["status.output_frequency", "status.fault_code", "config.accel_time"] +restore_order = ["config.accel_time"] +aliases = { "status.output_frequency" = "status.output_frequency", "status.fault_code" = "status.fault_code" } + +[hardware_verification] +status = "fictional" +firmware = ["demo-1"] + +[protocol] +allowed_baud_rates = [9600, 19200] +default_baud_rate = 9600 +allowed_parity = ["none", "even"] +default_parity = "none" +data_bits = 8 +stop_bits = 1 +response_timeout_ms = 500 +min_inter_frame_delay_us = 0 +rs485_mode = "adapter_managed" + +[[identification_probes]] +id = "model-word" +description = "Fictional model word" +table = "holding_registers" +address = { notation = "modicon_5_digit", value = 40001 } +count = 1 +expected_raw = [4660] + +[[parameters]] +id = "status.output_frequency" +code = "S0.01" +name = "Output frequency" +table = "input_registers" +address = { notation = "modicon_5_digit", value = 30001 } +encoding = "unsigned16" +quantity = { kind = "frequency" } +unit = "hz" +scale = { multiplier = "1.00", divisor = "100.0", offset = "-0", decimal_places = 2 } +access = "read_only" +restore_policy = "manual_only" +poll_class = "fast" + +[[parameters]] +id = "status.fault_code" +code = "S0.02" +name = "Fault code" +table = "holding_registers" +address = { notation = "protocol_one_based", value = 2 } +encoding = "enum16" +quantity = { kind = "digital_state" } +unit = "bool" +access = "read_only" +restore_policy = "manual_only" +poll_class = "normal" + +[[parameters]] +id = "config.accel_time" +code = "P0.01" +name = "Acceleration time" +table = "holding_registers" +address = { notation = "pdu_zero_based", value = 2 } +encoding = "unsigned16" +quantity = { kind = "time" } +unit = "s" +scale = { multiplier = "1", divisor = "10", offset = "0", decimal_places = 1 } +access = "writable_when_stopped" +restore_policy = "normal" +required_drive_state = "stopped" +read_back = { kind = "exact_raw" } +write = { function = "write_single_register", forbidden_raw = [[65535]], verification_attempts = 2, verification_interval_ms = 50, max_verification_window_ms = 500 } +backup = true +poll_class = "on_demand" + +[[groups]] +id = "status" +name = "Status" +parameters = ["status.output_frequency", "status.fault_code"] + +[[groups]] +id = "configuration" +name = "Configuration" +parameters = ["config.accel_time"] + +[[faults]] +id = "active-fault" +source_parameter = "status.fault_code" +representation = "scalar_code" +no_fault_values = [0] +meanings = { "1" = { name = "Demonstration fault", description = "Fictional fault" } } +freeze_frame = ["status.output_frequency"] + +[[telemetry_presets]] +id = "overview" +name = "Overview" +channels = ["status.output_frequency"] +"#; + + const JSON_PROFILE: &str = r#" +{ + "schema_version": 1, + "profile_id": "example.fictional-vfd-1000", + "revision": 1, + "vendor": "Example Devices", + "family": "Fictional", + "model": "VFD 1000", + "sources": ["Fictional interoperability fixture"], + "safety_notes": ["Never use fictional data on real equipment"], + "hardware_verification": {"status": "fictional", "firmware": ["demo-1"]}, + "protocol": { + "allowed_baud_rates": [19200, 9600], + "default_baud_rate": 9600, + "allowed_parity": ["even", "none"], + "default_parity": "none", + "data_bits": 8, + "stop_bits": 1, + "response_timeout_ms": 500, + "min_inter_frame_delay_us": 0, + "rs485_mode": "adapter_managed" + }, + "identification_probes": [{ + "id": "model-word", + "description": "Fictional model word", + "table": "holding_registers", + "address": {"notation": "modicon_5_digit", "value": 40001}, + "count": 1, + "expected_raw": [4660] + }], + "aliases": { + "status.fault_code": "status.fault_code", + "status.output_frequency": "status.output_frequency" + }, + "parameters": [ + { + "id": "config.accel_time", + "code": "P0.01", + "name": "Acceleration time", + "table": "holding_registers", + "address": {"notation": "pdu_zero_based", "value": 2}, + "encoding": "unsigned16", + "quantity": {"kind": "time"}, + "unit": "s", + "scale": {"multiplier": "1.0", "divisor": "10.00", "offset": "0", "decimal_places": 1}, + "access": "writable_when_stopped", + "restore_policy": "normal", + "required_drive_state": "stopped", + "read_back": {"kind": "exact_raw"}, + "write": {"function": "write_single_register", "forbidden_raw": [[65535]], "verification_attempts": 2, "verification_interval_ms": 50, "max_verification_window_ms": 500}, + "backup": true, + "poll_class": "on_demand" + }, + { + "id": "status.fault_code", + "code": "S0.02", + "name": "Fault code", + "table": "holding_registers", + "address": {"notation": "protocol_one_based", "value": 2}, + "encoding": "enum16", + "quantity": {"kind": "digital_state"}, + "unit": "bool", + "access": "read_only", + "restore_policy": "manual_only", + "poll_class": "normal" + }, + { + "id": "status.output_frequency", + "code": "S0.01", + "name": "Output frequency", + "table": "input_registers", + "address": {"notation": "modicon_5_digit", "value": 30001}, + "encoding": "unsigned16", + "quantity": {"kind": "frequency"}, + "unit": "hz", + "scale": {"multiplier": "1", "divisor": "100", "offset": "0.0", "decimal_places": 2}, + "access": "read_only", + "restore_policy": "manual_only", + "poll_class": "fast" + } + ], + "presentation_order": ["status.output_frequency", "status.fault_code", "config.accel_time"], + "groups": [ + {"id": "status", "name": "Status", "parameters": ["status.output_frequency", "status.fault_code"]}, + {"id": "configuration", "name": "Configuration", "parameters": ["config.accel_time"]} + ], + "faults": [{ + "id": "active-fault", + "source_parameter": "status.fault_code", + "representation": "scalar_code", + "no_fault_values": [0], + "meanings": {"1": {"name": "Demonstration fault", "description": "Fictional fault"}}, + "freeze_frame": ["status.output_frequency"] + }], + "telemetry_presets": [{"id": "overview", "name": "Overview", "channels": ["status.output_frequency"]}], + "restore_order": ["config.accel_time"] +} +"#; + + #[test] + fn equivalent_toml_and_json_have_the_same_semantic_hash() { + let toml = load_profile(TOML_PROFILE.as_bytes(), ProfileInputFormat::Toml).expect("TOML"); + let json = load_profile(JSON_PROFILE.as_bytes(), ProfileInputFormat::Json).expect("JSON"); + assert_ne!(toml.source_hash(), json.source_hash()); + assert_eq!(toml.profile_hash(), json.profile_hash()); + assert_eq!(toml.parameters().len(), 3); + } + + #[test] + fn normalization_is_idempotent_and_keeps_profile_hash() { + let original = + load_profile(TOML_PROFILE.as_bytes(), ProfileInputFormat::Toml).expect("load"); + let normalized = normalize_to_toml(&original).expect("normalize"); + let loaded = load_profile(normalized.as_bytes(), ProfileInputFormat::Toml).expect("reload"); + assert_eq!(original.profile_hash(), loaded.profile_hash()); + assert_eq!(normalized, normalize_to_toml(&loaded).expect("renormalize")); + } + + #[test] + fn all_address_notations_have_explicit_boundaries() { + assert_eq!( + super::normalize_address( + &AddressDocument::ProtocolOneBased { value: 65_536 }, + TableDocument::HoldingRegisters + ) + .expect("address") + .get(), + 65_535 + ); + assert_eq!( + super::normalize_address( + &AddressDocument::Modicon6Digit { value: 465_536 }, + TableDocument::HoldingRegisters + ) + .expect("address") + .get(), + 65_535 + ); + assert!( + super::normalize_address( + &AddressDocument::Modicon5Digit { value: 30_001 }, + TableDocument::HoldingRegisters + ) + .is_err() + ); + } + + #[test] + fn unknown_fields_are_rejected_with_a_path() { + let invalid = JSON_PROFILE.replace( + "\"model\": \"VFD 1000\"", + "\"model\": \"VFD 1000\", \"unexpected\": true", + ); + let error = + load_profile(invalid.as_bytes(), ProfileInputFormat::Json).expect_err("invalid"); + assert!(matches!(error, ProfileError::Parse { .. })); + assert!(error.to_string().contains("unexpected")); } - /// Returns the stable profile identifier. - #[must_use] - pub fn profile_id(&self) -> &ProfileId { - &self.profile_id + #[test] + fn profile_schema_is_generated_from_document_types() { + let schema = profile_schema_json().expect("schema"); + assert!(schema.contains("schema_version")); + assert!(schema.contains("modicon_6_digit")); + assert!(schema.contains("accepted_raw_set")); } - /// Returns the vendor name. - #[must_use] - pub fn vendor(&self) -> &str { - &self.vendor + #[test] + fn input_limit_is_checked_before_parsing() { + let bytes = vec![b' '; MAX_PROFILE_BYTES + 1]; + assert!(matches!( + load_profile(&bytes, ProfileInputFormat::Toml), + Err(ProfileError::InputTooLarge { .. }) + )); } - /// Returns the model name. - #[must_use] - pub fn model(&self) -> &str { - &self.model + #[test] + fn changing_restore_order_changes_the_semantic_hash() { + let original = + load_profile(JSON_PROFILE.as_bytes(), ProfileInputFormat::Json).expect("load"); + let changed_source = JSON_PROFILE.replace( + "\"restore_order\": [\"config.accel_time\"]", + "\"restore_order\": []", + ); + let changed = + load_profile(changed_source.as_bytes(), ProfileInputFormat::Json).expect("load"); + assert_ne!(original.profile_hash(), changed.profile_hash()); } } diff --git a/crates/lantern-profile/src/validated.rs b/crates/lantern-profile/src/validated.rs new file mode 100644 index 0000000..9dcd607 --- /dev/null +++ b/crates/lantern-profile/src/validated.rs @@ -0,0 +1,440 @@ +use std::{collections::BTreeMap, time::Duration}; + +use lantern_domain::{ + ByteOrder, ModbusFunction, ModbusTable, ParameterAccess, ParameterId, ProfileId, QuantityKind, + RegisterAddress, RegisterBlock, RegisterCodec, RegisterEncoding, RequiredDriveState, + RestorePolicy, UnitId, WordOrder, +}; +use rust_decimal::Decimal; + +use crate::{ + CanonicalProfileV1, ProfileHash, SourceHash, + document::{ + AddressDocument, FaultRepresentationDocument, HardwareVerificationStatusDocument, + PollClassDocument, ProfileDocumentV1, + }, +}; + +/// Validated serial and protocol constraints from a profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedProtocol { + pub allowed_baud_rates: Vec, + pub default_baud_rate: u32, + pub allowed_parity: Vec, + pub default_parity: lantern_domain::Parity, + pub data_bits: lantern_domain::DataBits, + pub stop_bits: lantern_domain::StopBits, + pub response_timeout: Duration, + pub min_inter_frame_delay: Duration, + pub rs485_mode: lantern_domain::Rs485Mode, +} + +/// Validated read-back rule used by every write path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ValidatedReadBackPolicy { + ExactRaw, + AcceptedRawSet { + values: Vec>, + documentation: String, + qualification_report_id: String, + }, + FloatExactBits, + FloatAbsRelTolerance { + absolute: Decimal, + relative: Decimal, + }, +} + +/// Validated write and delayed-verification constraints. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedWritePolicy { + pub function: ModbusFunction, + pub forbidden_raw: Vec>, + pub settle_delay: Duration, + pub verification_attempts: u8, + pub verification_interval: Duration, + pub max_verification_window: Duration, +} + +/// One immutable parameter accepted by the semantic validator. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedParameter { + id: ParameterId, + code: String, + name: String, + description: String, + table: ModbusTable, + address: RegisterAddress, + source_address: AddressDocument, + block: RegisterBlock, + encoding: RegisterEncoding, + byte_order: ByteOrder, + word_order: WordOrder, + codec: RegisterCodec, + quantity: QuantityKind, + unit: UnitId, + access: ParameterAccess, + restore_policy: RestorePolicy, + required_drive_state: RequiredDriveState, + read_back: ValidatedReadBackPolicy, + write: Option, + backup: bool, + do_not_bridge: bool, + poll_class: PollClassDocument, +} + +impl ValidatedParameter { + #[must_use] + pub fn id(&self) -> &ParameterId { + &self.id + } + + #[must_use] + pub fn code(&self) -> &str { + &self.code + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + #[must_use] + pub const fn table(&self) -> ModbusTable { + self.table + } + + #[must_use] + pub const fn address(&self) -> RegisterAddress { + self.address + } + + #[must_use] + pub fn source_address(&self) -> &AddressDocument { + &self.source_address + } + + #[must_use] + pub const fn block(&self) -> RegisterBlock { + self.block + } + + #[must_use] + pub const fn encoding(&self) -> RegisterEncoding { + self.encoding + } + + #[must_use] + pub const fn byte_order(&self) -> ByteOrder { + self.byte_order + } + + #[must_use] + pub const fn word_order(&self) -> WordOrder { + self.word_order + } + + #[must_use] + pub const fn codec(&self) -> &RegisterCodec { + &self.codec + } + + #[must_use] + pub fn quantity(&self) -> &QuantityKind { + &self.quantity + } + + #[must_use] + pub fn unit(&self) -> &UnitId { + &self.unit + } + + #[must_use] + pub const fn access(&self) -> ParameterAccess { + self.access + } + + #[must_use] + pub const fn restore_policy(&self) -> RestorePolicy { + self.restore_policy + } + + #[must_use] + pub const fn required_drive_state(&self) -> RequiredDriveState { + self.required_drive_state + } + + #[must_use] + pub const fn read_back(&self) -> &ValidatedReadBackPolicy { + &self.read_back + } + + #[must_use] + pub const fn write(&self) -> Option<&ValidatedWritePolicy> { + self.write.as_ref() + } + + #[must_use] + pub const fn is_in_backup(&self) -> bool { + self.backup + } + + #[must_use] + pub const fn do_not_bridge(&self) -> bool { + self.do_not_bridge + } + + #[must_use] + pub const fn poll_class(&self) -> PollClassDocument { + self.poll_class + } +} + +/// One validated identity probe. Probes are always read-only. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedIdentificationProbe { + pub id: String, + pub description: String, + pub table: ModbusTable, + pub address: RegisterAddress, + pub count: u16, + pub expected_raw: Vec, +} + +/// One validated fault definition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFault { + pub id: String, + pub source_parameter: ParameterId, + pub representation: FaultRepresentationDocument, + pub no_fault_values: Vec, + pub meanings: BTreeMap, + pub freeze_frame: Vec, +} + +/// Fault label accepted after text validation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFaultMeaning { + pub name: String, + pub description: String, + pub severity: String, +} + +/// Immutable profile accepted by the application and transport layers. +#[derive(Clone, Debug)] +pub struct ValidatedDeviceProfile { + profile_id: ProfileId, + revision: u32, + vendor: String, + family: String, + model: String, + hardware_verification: HardwareVerificationStatusDocument, + protocol: ValidatedProtocol, + identification_probes: Vec, + parameters: Vec, + parameter_index: BTreeMap, + aliases: BTreeMap, + faults: Vec, + presentation_order: Vec, + restore_order: Vec, + source_hash: SourceHash, + profile_hash: ProfileHash, + canonical: CanonicalProfileV1, + normalized_document: ProfileDocumentV1, +} + +impl ValidatedDeviceProfile { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + profile_id: ProfileId, + revision: u32, + vendor: String, + family: String, + model: String, + hardware_verification: HardwareVerificationStatusDocument, + protocol: ValidatedProtocol, + identification_probes: Vec, + parameters: Vec, + parameter_index: BTreeMap, + aliases: BTreeMap, + faults: Vec, + presentation_order: Vec, + restore_order: Vec, + source_hash: SourceHash, + profile_hash: ProfileHash, + canonical: CanonicalProfileV1, + normalized_document: ProfileDocumentV1, + ) -> Self { + Self { + profile_id, + revision, + vendor, + family, + model, + hardware_verification, + protocol, + identification_probes, + parameters, + parameter_index, + aliases, + faults, + presentation_order, + restore_order, + source_hash, + profile_hash, + canonical, + normalized_document, + } + } + + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub const fn revision(&self) -> u32 { + self.revision + } + + #[must_use] + pub fn vendor(&self) -> &str { + &self.vendor + } + + #[must_use] + pub fn family(&self) -> &str { + &self.family + } + + #[must_use] + pub fn model(&self) -> &str { + &self.model + } + + #[must_use] + pub const fn hardware_verification(&self) -> HardwareVerificationStatusDocument { + self.hardware_verification + } + + #[must_use] + pub const fn protocol(&self) -> &ValidatedProtocol { + &self.protocol + } + + #[must_use] + pub fn identification_probes(&self) -> &[ValidatedIdentificationProbe] { + &self.identification_probes + } + + #[must_use] + pub fn parameters(&self) -> &[ValidatedParameter] { + &self.parameters + } + + #[must_use] + pub fn parameter(&self, id: &ParameterId) -> Option<&ValidatedParameter> { + self.parameter_index + .get(id) + .and_then(|index| self.parameters.get(*index)) + } + + #[must_use] + pub fn aliases(&self) -> &BTreeMap { + &self.aliases + } + + #[must_use] + pub fn faults(&self) -> &[ValidatedFault] { + &self.faults + } + + #[must_use] + pub fn presentation_order(&self) -> &[ParameterId] { + &self.presentation_order + } + + #[must_use] + pub fn restore_order(&self) -> &[ParameterId] { + &self.restore_order + } + + #[must_use] + pub const fn source_hash(&self) -> SourceHash { + self.source_hash + } + + #[must_use] + pub const fn profile_hash(&self) -> ProfileHash { + self.profile_hash + } + + #[must_use] + pub const fn canonical(&self) -> &CanonicalProfileV1 { + &self.canonical + } + + #[must_use] + pub(crate) const fn normalized_document(&self) -> &ProfileDocumentV1 { + &self.normalized_document + } +} + +pub(crate) struct ParameterParts { + pub id: ParameterId, + pub code: String, + pub name: String, + pub description: String, + pub table: ModbusTable, + pub address: RegisterAddress, + pub source_address: AddressDocument, + pub block: RegisterBlock, + pub encoding: RegisterEncoding, + pub byte_order: ByteOrder, + pub word_order: WordOrder, + pub codec: RegisterCodec, + pub quantity: QuantityKind, + pub unit: UnitId, + pub access: ParameterAccess, + pub restore_policy: RestorePolicy, + pub required_drive_state: RequiredDriveState, + pub read_back: ValidatedReadBackPolicy, + pub write: Option, + pub backup: bool, + pub do_not_bridge: bool, + pub poll_class: PollClassDocument, +} + +impl From for ValidatedParameter { + fn from(parts: ParameterParts) -> Self { + Self { + id: parts.id, + code: parts.code, + name: parts.name, + description: parts.description, + table: parts.table, + address: parts.address, + source_address: parts.source_address, + block: parts.block, + encoding: parts.encoding, + byte_order: parts.byte_order, + word_order: parts.word_order, + codec: parts.codec, + quantity: parts.quantity, + unit: parts.unit, + access: parts.access, + restore_policy: parts.restore_policy, + required_drive_state: parts.required_drive_state, + read_back: parts.read_back, + write: parts.write, + backup: parts.backup, + do_not_bridge: parts.do_not_bridge, + poll_class: parts.poll_class, + } + } +} diff --git a/crates/lantern-profile/src/validation.rs b/crates/lantern-profile/src/validation.rs new file mode 100644 index 0000000..0a6c3a6 --- /dev/null +++ b/crates/lantern-profile/src/validation.rs @@ -0,0 +1,1458 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + str::FromStr, + time::Duration, +}; + +use lantern_domain::{ + ByteOrder, DataBits, FixedScale, ModbusFunction, ModbusTable, ParameterAccess, ParameterId, + Parity, ProfileId, QuantityId, QuantityKind, RegisterAddress, RegisterBlock, RegisterCodec, + RegisterCount, RegisterEncoding, RequiredDriveState, RestorePolicy, RoundingMode, Rs485Mode, + StopBits, UnitId, WordOrder, +}; +use rust_decimal::Decimal; + +use crate::{ + CanonicalFault, CanonicalFaultMeaning, CanonicalGroup, CanonicalHardwareVerification, + CanonicalParameter, CanonicalPreset, CanonicalProbe, CanonicalProfileV1, CanonicalProtocol, + CanonicalReadBack, CanonicalScale, CanonicalWritePolicy, ProfileError, ProfileHash, SourceHash, + document::{ + AccessDocument, AddressDocument, ByteOrderDocument, EncodingDocument, FaultDocument, + FaultRepresentationDocument, FaultSeverityDocument, GroupDocument, + HardwareVerificationStatusDocument, IdentificationProbeDocument, ParameterDocument, + ParityDocument, PollClassDocument, ProfileDocumentV1, QuantityDocument, + ReadBackPolicyDocument, RequiredDriveStateDocument, RestorePolicyDocument, + RoundingDocument, Rs485ModeDocument, ScaleDocument, TableDocument, TelemetryPresetDocument, + WordOrderDocument, WriteFunctionDocument, WritePolicyDocument, + }, + validated::{ + ParameterParts, ValidatedDeviceProfile, ValidatedFault, ValidatedFaultMeaning, + ValidatedIdentificationProbe, ValidatedParameter, ValidatedProtocol, + ValidatedReadBackPolicy, ValidatedWritePolicy, + }, +}; + +pub(crate) const MAX_PARAMETERS: usize = 20_000; +pub(crate) const MAX_FAULTS: usize = 4_096; +pub(crate) const MAX_PRESETS: usize = 256; +const MAX_PROBES: usize = 32; +const MAX_TEXT_BYTES: usize = 16 * 1024; +const MAX_ACCEPTED_RAW: usize = 8; + +pub(crate) fn validate_document( + mut document: ProfileDocumentV1, + source_hash: SourceHash, +) -> Result { + if document.schema_version != 1 { + return Err(ProfileError::UnsupportedSchema(document.schema_version)); + } + if document.revision == 0 { + return Err(invalid("revision", "revision must be non-zero")); + } + if document.parameters.len() > MAX_PARAMETERS { + return Err(invalid( + "parameters", + format!("parameter count exceeds {MAX_PARAMETERS}"), + )); + } + if document.faults.len() > MAX_FAULTS { + return Err(invalid( + "faults", + format!("fault count exceeds {MAX_FAULTS}"), + )); + } + if document.telemetry_presets.len() > MAX_PRESETS { + return Err(invalid( + "telemetry_presets", + format!("preset count exceeds {MAX_PRESETS}"), + )); + } + if document.identification_probes.is_empty() + || document.identification_probes.len() > MAX_PROBES + { + return Err(invalid( + "identification_probes", + format!("profile requires 1..={MAX_PROBES} read-only probes"), + )); + } + + validate_text("vendor", &document.vendor, false)?; + validate_text("family", &document.family, false)?; + validate_text("model", &document.model, false)?; + let profile_id = ProfileId::parse(document.profile_id.clone()) + .map_err(|error| invalid("profile_id", error.to_string()))?; + + normalize_text_set("sources", &mut document.sources)?; + normalize_text_set("safety_notes", &mut document.safety_notes)?; + normalize_text_set( + "hardware_verification.firmware", + &mut document.hardware_verification.firmware, + )?; + validate_optional_text( + "hardware_verification.manual_revision", + document.hardware_verification.manual_revision.as_deref(), + )?; + validate_optional_text( + "hardware_verification.qualification_report_id", + document + .hardware_verification + .qualification_report_id + .as_deref(), + )?; + if matches!( + document.hardware_verification.status, + HardwareVerificationStatusDocument::Qualified + ) && document + .hardware_verification + .qualification_report_id + .is_none() + { + return Err(invalid( + "hardware_verification.qualification_report_id", + "qualified profiles require a qualification report ID", + )); + } + + let (protocol, canonical_protocol) = validate_protocol(&mut document)?; + let (probes, canonical_probes) = validate_probes(&document.identification_probes)?; + + document + .parameters + .sort_by(|left, right| left.id.cmp(&right.id)); + let mut parameter_ids = BTreeSet::new(); + let mut codes = BTreeSet::new(); + let mut parameters = Vec::with_capacity(document.parameters.len()); + let mut canonical_parameters = Vec::with_capacity(document.parameters.len()); + let mut ranges = Vec::with_capacity(document.parameters.len()); + + for (index, parameter) in document.parameters.iter_mut().enumerate() { + let path = format!("parameters[{index}]"); + let (validated, canonical) = validate_parameter(parameter, &path)?; + if !parameter_ids.insert(validated.id().clone()) { + return Err(invalid( + format!("{path}.id"), + format!("duplicate parameter ID {}", validated.id()), + )); + } + if !codes.insert(parameter.code.clone()) { + return Err(invalid( + format!("{path}.code"), + format!("duplicate parameter code {}", parameter.code), + )); + } + ranges.push(( + validated.table(), + validated.block().start().get(), + validated.block().end().get(), + validated.id().clone(), + )); + parameters.push(validated); + canonical_parameters.push(canonical); + } + validate_non_overlapping_ranges(&mut ranges)?; + + let parameter_index: BTreeMap<_, _> = parameters + .iter() + .enumerate() + .map(|(index, parameter)| (parameter.id().clone(), index)) + .collect(); + + let aliases = validate_aliases(&document.aliases, ¶meter_ids)?; + let presentation_order = materialize_order( + "presentation_order", + &mut document.presentation_order, + ¶meter_ids, + true, + )?; + let canonical_groups = validate_groups(&document.groups, ¶meter_ids)?; + let (faults, canonical_faults) = validate_faults( + &mut document.faults, + ¶meters, + ¶meter_index, + ¶meter_ids, + )?; + let canonical_presets = validate_presets(&document.telemetry_presets, ¶meter_ids)?; + let restore_order = validate_restore_order( + &document.restore_order, + ¶meters, + ¶meter_index, + ¶meter_ids, + )?; + + let canonical = CanonicalProfileV1 { + canonical_schema_version: 1, + schema_version: 1, + profile_id: profile_id.as_str().to_owned(), + revision: document.revision, + vendor: document.vendor.clone(), + family: document.family.clone(), + model: document.model.clone(), + sources: document.sources.clone(), + safety_notes: document.safety_notes.clone(), + hardware_verification: CanonicalHardwareVerification { + status: hardware_status_name(document.hardware_verification.status).to_owned(), + firmware: document.hardware_verification.firmware.clone(), + manual_revision: document.hardware_verification.manual_revision.clone(), + qualification_report_id: document + .hardware_verification + .qualification_report_id + .clone(), + }, + protocol: canonical_protocol, + identification_probes: canonical_probes, + aliases: document.aliases.clone(), + parameters: canonical_parameters, + presentation_order: document.presentation_order.clone(), + groups: canonical_groups, + faults: canonical_faults, + telemetry_presets: canonical_presets, + restore_order: document.restore_order.clone(), + }; + let profile_hash = ProfileHash::digest(&canonical.to_jcs_bytes()?); + + Ok(ValidatedDeviceProfile::new( + profile_id, + document.revision, + document.vendor.clone(), + document.family.clone(), + document.model.clone(), + document.hardware_verification.status, + protocol, + probes, + parameters, + parameter_index, + aliases, + faults, + presentation_order, + restore_order, + source_hash, + profile_hash, + canonical, + document, + )) +} + +fn validate_protocol( + document: &mut ProfileDocumentV1, +) -> Result<(ValidatedProtocol, CanonicalProtocol), ProfileError> { + let protocol = &mut document.protocol; + if protocol.allowed_baud_rates.is_empty() { + return Err(invalid( + "protocol.allowed_baud_rates", + "at least one baud rate is required", + )); + } + protocol.allowed_baud_rates.sort_unstable(); + protocol.allowed_baud_rates.dedup(); + if protocol.allowed_baud_rates.iter().any(|rate| *rate == 0) { + return Err(invalid( + "protocol.allowed_baud_rates", + "baud rates must be non-zero", + )); + } + if !protocol + .allowed_baud_rates + .contains(&protocol.default_baud_rate) + { + return Err(invalid( + "protocol.default_baud_rate", + "default baud rate must be in allowed_baud_rates", + )); + } + + if protocol.allowed_parity.is_empty() { + return Err(invalid( + "protocol.allowed_parity", + "at least one parity is required", + )); + } + protocol + .allowed_parity + .sort_by_key(|parity| parity_name(*parity)); + protocol + .allowed_parity + .dedup_by_key(|parity| parity_name(*parity)); + if !protocol.allowed_parity.contains(&protocol.default_parity) { + return Err(invalid( + "protocol.default_parity", + "default parity must be in allowed_parity", + )); + } + + let data_bits = match protocol.data_bits { + 7 => DataBits::Seven, + 8 => DataBits::Eight, + value => { + return Err(invalid( + "protocol.data_bits", + format!("unsupported data bits {value}; expected 7 or 8"), + )); + } + }; + let stop_bits = match protocol.stop_bits { + 1 => StopBits::One, + 2 => StopBits::Two, + value => { + return Err(invalid( + "protocol.stop_bits", + format!("unsupported stop bits {value}; expected 1 or 2"), + )); + } + }; + if protocol.response_timeout_ms == 0 || protocol.response_timeout_ms > 60_000 { + return Err(invalid( + "protocol.response_timeout_ms", + "response timeout must be in 1..=60000 ms", + )); + } + if protocol.min_inter_frame_delay_us > 1_000_000 { + return Err(invalid( + "protocol.min_inter_frame_delay_us", + "minimum inter-frame delay must not exceed one second", + )); + } + + let allowed_parity: Vec<_> = protocol + .allowed_parity + .iter() + .copied() + .map(domain_parity) + .collect(); + let validated = ValidatedProtocol { + allowed_baud_rates: protocol.allowed_baud_rates.clone(), + default_baud_rate: protocol.default_baud_rate, + allowed_parity, + default_parity: domain_parity(protocol.default_parity), + data_bits, + stop_bits, + response_timeout: Duration::from_millis(protocol.response_timeout_ms), + min_inter_frame_delay: Duration::from_micros(protocol.min_inter_frame_delay_us), + rs485_mode: match protocol.rs485_mode { + Rs485ModeDocument::AdapterManaged => Rs485Mode::AdapterManaged, + Rs485ModeDocument::LinuxIoctl => Rs485Mode::LinuxIoctl, + }, + }; + let canonical = CanonicalProtocol { + allowed_baud_rates: protocol.allowed_baud_rates.clone(), + default_baud_rate: protocol.default_baud_rate, + allowed_parity: protocol + .allowed_parity + .iter() + .map(|value| parity_name(*value).to_owned()) + .collect(), + default_parity: parity_name(protocol.default_parity).to_owned(), + data_bits: protocol.data_bits, + stop_bits: protocol.stop_bits, + response_timeout_ms: protocol.response_timeout_ms, + min_inter_frame_delay_us: protocol.min_inter_frame_delay_us, + rs485_mode: rs485_mode_name(protocol.rs485_mode).to_owned(), + }; + Ok((validated, canonical)) +} + +fn validate_probes( + probes: &[IdentificationProbeDocument], +) -> Result<(Vec, Vec), ProfileError> { + let mut ids = BTreeSet::new(); + let mut validated = Vec::with_capacity(probes.len()); + let mut canonical = Vec::with_capacity(probes.len()); + + for (index, probe) in probes.iter().enumerate() { + let path = format!("identification_probes[{index}]"); + validate_portable_id(&format!("{path}.id"), &probe.id)?; + validate_text(&format!("{path}.description"), &probe.description, false)?; + if !ids.insert(probe.id.clone()) { + return Err(invalid( + format!("{path}.id"), + format!("duplicate probe ID {}", probe.id), + )); + } + let count = RegisterCount::new(probe.count) + .map_err(|error| invalid(format!("{path}.count"), error.to_string()))?; + let table = domain_table(probe.table); + let function = read_function(table); + let address = normalize_address(&probe.address, probe.table) + .map_err(|error| invalid(format!("{path}.address"), error.to_string()))?; + RegisterBlock::new(table, address, count, function) + .map_err(|error| invalid(format!("{path}.address"), error.to_string()))?; + if probe.expected_raw.len() != usize::from(probe.count) { + return Err(invalid( + format!("{path}.expected_raw"), + "expected_raw length must equal probe count", + )); + } + validated.push(ValidatedIdentificationProbe { + id: probe.id.clone(), + description: probe.description.clone(), + table, + address, + count: probe.count, + expected_raw: probe.expected_raw.clone(), + }); + canonical.push(CanonicalProbe { + id: probe.id.clone(), + description: probe.description.clone(), + table: table_name(probe.table).to_owned(), + address_pdu: address.get(), + count: probe.count, + expected_raw: probe.expected_raw.clone(), + }); + } + + Ok((validated, canonical)) +} + +fn validate_parameter( + parameter: &mut ParameterDocument, + path: &str, +) -> Result<(ValidatedParameter, CanonicalParameter), ProfileError> { + let id = ParameterId::parse(parameter.id.clone()) + .map_err(|error| invalid(format!("{path}.id"), error.to_string()))?; + validate_portable_id(&format!("{path}.code"), ¶meter.code)?; + validate_text(&format!("{path}.name"), ¶meter.name, false)?; + validate_text(&format!("{path}.description"), ¶meter.description, true)?; + + let table = domain_table(parameter.table); + let address = normalize_address(¶meter.address, parameter.table) + .map_err(|error| invalid(format!("{path}.address"), error.to_string()))?; + let encoding = domain_encoding(parameter.encoding); + let count_value = u16::try_from(parameter.encoding.register_width()) + .map_err(|_| invalid(path, "register width exceeds u16"))?; + let count = RegisterCount::new(count_value) + .map_err(|error| invalid(format!("{path}.encoding"), error.to_string()))?; + let block = RegisterBlock::new(table, address, count, read_function(table)) + .map_err(|error| invalid(format!("{path}.address"), error.to_string()))?; + let byte_order = domain_byte_order(parameter.byte_order); + let word_order = domain_word_order(parameter.word_order); + + let (fixed_scale, canonical_scale) = validate_scale(parameter, path)?; + let quantity = domain_quantity(¶meter.quantity, &format!("{path}.quantity"))?; + let unit = UnitId::new(quantity.clone(), parameter.unit.clone()) + .map_err(|error| invalid(format!("{path}.unit"), error.to_string()))?; + let codec = RegisterCodec::new(encoding, byte_order, word_order, fixed_scale) + .map_err(|error| invalid(format!("{path}.scale"), error.to_string()))?; + let access = domain_access(parameter.access); + let restore_policy = domain_restore_policy(parameter.restore_policy); + let required_drive_state = domain_required_state(parameter.required_drive_state); + let (read_back, canonical_read_back) = validate_read_back( + &mut parameter.read_back, + parameter.encoding, + parameter.encoding.register_width(), + &format!("{path}.read_back"), + )?; + let (write, canonical_write) = validate_write_policy( + parameter.write.as_mut(), + parameter.access, + parameter.table, + parameter.encoding, + &format!("{path}.write"), + )?; + + let (quantity_kind, custom_quantity_id) = canonical_quantity(¶meter.quantity); + let validated = ParameterParts { + id, + code: parameter.code.clone(), + name: parameter.name.clone(), + description: parameter.description.clone(), + table, + address, + source_address: parameter.address.clone(), + block, + encoding, + byte_order, + word_order, + codec, + quantity, + unit, + access, + restore_policy, + required_drive_state, + read_back, + write, + backup: parameter.backup, + do_not_bridge: parameter.do_not_bridge, + poll_class: parameter.poll_class, + } + .into(); + + let canonical = CanonicalParameter { + id: parameter.id.clone(), + code: parameter.code.clone(), + name: parameter.name.clone(), + description: parameter.description.clone(), + table: table_name(parameter.table).to_owned(), + address_pdu: address.get(), + register_count: count_value, + encoding: encoding_name(parameter.encoding).to_owned(), + byte_order: byte_order_name(parameter.byte_order).to_owned(), + word_order: word_order_name(parameter.word_order).to_owned(), + quantity_kind: quantity_kind.to_owned(), + custom_quantity_id, + unit: parameter.unit.clone(), + scale: canonical_scale, + access: access_name(parameter.access).to_owned(), + restore_policy: restore_policy_name(parameter.restore_policy).to_owned(), + required_drive_state: required_state_name(parameter.required_drive_state).to_owned(), + read_back: canonical_read_back, + write: canonical_write, + backup: parameter.backup, + do_not_bridge: parameter.do_not_bridge, + poll_class: poll_class_name(parameter.poll_class).to_owned(), + }; + Ok((validated, canonical)) +} + +fn validate_scale( + parameter: &mut ParameterDocument, + path: &str, +) -> Result<(Option, CanonicalScale), ProfileError> { + if !parameter.encoding.supports_fixed_scale() { + if parameter.scale.is_some() { + return Err(invalid( + format!("{path}.scale"), + "this encoding cannot use a fixed-point scale", + )); + } + return Ok((None, CanonicalScale::None)); + } + + let scale = parameter.scale.get_or_insert_with(|| ScaleDocument { + multiplier: "1".to_owned(), + divisor: "1".to_owned(), + offset: "0".to_owned(), + decimal_places: 0, + rounding: RoundingDocument::MidpointNearestEven, + }); + let multiplier = + parse_canonical_decimal(&format!("{path}.scale.multiplier"), &mut scale.multiplier)?; + let divisor = parse_canonical_decimal(&format!("{path}.scale.divisor"), &mut scale.divisor)?; + let offset = parse_canonical_decimal(&format!("{path}.scale.offset"), &mut scale.offset)?; + let fixed = FixedScale::new( + multiplier, + divisor, + offset, + scale.decimal_places, + domain_rounding(scale.rounding), + ) + .map_err(|error| invalid(format!("{path}.scale"), error.to_string()))?; + Ok(( + Some(fixed), + CanonicalScale::Fixed { + multiplier: scale.multiplier.clone(), + divisor: scale.divisor.clone(), + offset: scale.offset.clone(), + decimal_places: scale.decimal_places, + rounding: rounding_name(scale.rounding).to_owned(), + }, + )) +} + +fn validate_read_back( + policy: &mut ReadBackPolicyDocument, + encoding: EncodingDocument, + width: usize, + path: &str, +) -> Result<(ValidatedReadBackPolicy, CanonicalReadBack), ProfileError> { + match policy { + ReadBackPolicyDocument::ExactRaw if !encoding.is_float() => Ok(( + ValidatedReadBackPolicy::ExactRaw, + CanonicalReadBack::ExactRaw, + )), + ReadBackPolicyDocument::ExactRaw => Err(invalid( + path, + "float read-back must use float_exact_bits or float_abs_rel_tolerance", + )), + ReadBackPolicyDocument::AcceptedRawSet { + values, + documentation, + qualification_report_id, + } => { + if encoding.is_float() { + return Err(invalid( + path, + "accepted_raw_set is not valid for float encodings", + )); + } + if values.is_empty() || values.len() > MAX_ACCEPTED_RAW { + return Err(invalid( + format!("{path}.values"), + format!("accepted_raw_set requires 1..={MAX_ACCEPTED_RAW} values"), + )); + } + for (index, value) in values.iter().enumerate() { + if value.len() != width { + return Err(invalid( + format!("{path}.values[{index}]"), + format!("raw value requires exactly {width} registers"), + )); + } + } + values.sort(); + values.dedup(); + validate_text(&format!("{path}.documentation"), documentation, false)?; + validate_portable_id( + &format!("{path}.qualification_report_id"), + qualification_report_id, + )?; + Ok(( + ValidatedReadBackPolicy::AcceptedRawSet { + values: values.clone(), + documentation: documentation.clone(), + qualification_report_id: qualification_report_id.clone(), + }, + CanonicalReadBack::AcceptedRawSet { + values: values.clone(), + documentation: documentation.clone(), + qualification_report_id: qualification_report_id.clone(), + }, + )) + } + ReadBackPolicyDocument::FloatExactBits if encoding.is_float() => Ok(( + ValidatedReadBackPolicy::FloatExactBits, + CanonicalReadBack::FloatExactBits, + )), + ReadBackPolicyDocument::FloatExactBits => { + Err(invalid(path, "float_exact_bits requires a float encoding")) + } + ReadBackPolicyDocument::FloatAbsRelTolerance { absolute, relative } => { + if !encoding.is_float() { + return Err(invalid(path, "float tolerance requires a float encoding")); + } + let absolute_value = parse_canonical_decimal(&format!("{path}.absolute"), absolute)?; + let relative_value = parse_canonical_decimal(&format!("{path}.relative"), relative)?; + if absolute_value.is_sign_negative() || relative_value.is_sign_negative() { + return Err(invalid(path, "float tolerances must be non-negative")); + } + if absolute_value.is_zero() && relative_value.is_zero() { + return Err(invalid( + path, + "zero absolute and relative tolerance is equivalent to exact bits", + )); + } + Ok(( + ValidatedReadBackPolicy::FloatAbsRelTolerance { + absolute: absolute_value, + relative: relative_value, + }, + CanonicalReadBack::FloatAbsRelTolerance { + absolute: absolute.clone(), + relative: relative.clone(), + }, + )) + } + } +} + +fn validate_write_policy( + policy: Option<&mut WritePolicyDocument>, + access: AccessDocument, + table: TableDocument, + encoding: EncodingDocument, + path: &str, +) -> Result<(Option, Option), ProfileError> { + if access == AccessDocument::ReadOnly { + if policy.is_some() { + return Err(invalid( + path, + "read-only parameters cannot define write policy", + )); + } + return Ok((None, None)); + } + let policy = policy.ok_or_else(|| invalid(path, "writable parameters require write policy"))?; + if table != TableDocument::HoldingRegisters { + return Err(invalid(path, "writes require holding registers")); + } + if !(1..=3).contains(&policy.verification_attempts) { + return Err(invalid( + format!("{path}.verification_attempts"), + "verification_attempts must be in 1..=3", + )); + } + if policy.max_verification_window_ms == 0 { + return Err(invalid( + format!("{path}.max_verification_window_ms"), + "verification window must be non-zero", + )); + } + let minimum_window = policy.settle_delay_ms.saturating_add( + policy + .verification_interval_ms + .saturating_mul(u64::from(policy.verification_attempts.saturating_sub(1))), + ); + if minimum_window > policy.max_verification_window_ms { + return Err(invalid( + path, + "settle delay and verification intervals exceed the maximum window", + )); + } + let width = encoding.register_width(); + for (index, raw) in policy.forbidden_raw.iter().enumerate() { + if raw.len() != width { + return Err(invalid( + format!("{path}.forbidden_raw[{index}]"), + format!("forbidden raw value requires exactly {width} registers"), + )); + } + } + policy.forbidden_raw.sort(); + policy.forbidden_raw.dedup(); + + let function = match policy.function { + WriteFunctionDocument::WriteSingleRegister => ModbusFunction::WriteSingleRegister, + WriteFunctionDocument::WriteMultipleRegisters => ModbusFunction::WriteMultipleRegisters, + }; + let count = RegisterCount::new( + u16::try_from(width).map_err(|_| invalid(path, "register width exceeds u16"))?, + ) + .map_err(|error| invalid(path, error.to_string()))?; + function + .validate_count(count) + .map_err(|error| invalid(format!("{path}.function"), error.to_string()))?; + + let validated = ValidatedWritePolicy { + function, + forbidden_raw: policy.forbidden_raw.clone(), + settle_delay: Duration::from_millis(policy.settle_delay_ms), + verification_attempts: policy.verification_attempts, + verification_interval: Duration::from_millis(policy.verification_interval_ms), + max_verification_window: Duration::from_millis(policy.max_verification_window_ms), + }; + let canonical = CanonicalWritePolicy { + function: write_function_name(policy.function).to_owned(), + forbidden_raw: policy.forbidden_raw.clone(), + settle_delay_ms: policy.settle_delay_ms, + verification_attempts: policy.verification_attempts, + verification_interval_ms: policy.verification_interval_ms, + max_verification_window_ms: policy.max_verification_window_ms, + }; + Ok((Some(validated), Some(canonical))) +} + +fn validate_non_overlapping_ranges( + ranges: &mut [(ModbusTable, u16, u16, ParameterId)], +) -> Result<(), ProfileError> { + ranges.sort_by(|left, right| { + table_sort_key(left.0) + .cmp(&table_sort_key(right.0)) + .then(left.1.cmp(&right.1)) + .then(left.3.cmp(&right.3)) + }); + for pair in ranges.windows(2) { + let [left, right] = pair else { + continue; + }; + if left.0 == right.0 && right.1 <= left.2 { + return Err(invalid( + "parameters", + format!( + "register ranges for {} and {} overlap in {:?}", + left.3, right.3, left.0 + ), + )); + } + } + Ok(()) +} + +fn validate_aliases( + aliases: &BTreeMap, + parameters: &BTreeSet, +) -> Result, ProfileError> { + let mut validated = BTreeMap::new(); + for (alias, target) in aliases { + validate_portable_id(&format!("aliases.{alias}"), alias)?; + let target_id = ParameterId::parse(target.clone()) + .map_err(|error| invalid(format!("aliases.{alias}"), error.to_string()))?; + if !parameters.contains(&target_id) { + return Err(invalid( + format!("aliases.{alias}"), + format!("unknown parameter {target}"), + )); + } + validated.insert(alias.clone(), target_id); + } + Ok(validated) +} + +fn materialize_order( + path: &str, + order: &mut Vec, + parameters: &BTreeSet, + require_all: bool, +) -> Result, ProfileError> { + if order.is_empty() { + *order = parameters + .iter() + .map(|parameter| parameter.as_str().to_owned()) + .collect(); + } + let mut seen = BTreeSet::new(); + let mut validated = Vec::with_capacity(order.len()); + for (index, value) in order.iter().enumerate() { + let id = ParameterId::parse(value.clone()) + .map_err(|error| invalid(format!("{path}[{index}]"), error.to_string()))?; + if !parameters.contains(&id) { + return Err(invalid( + format!("{path}[{index}]"), + format!("unknown parameter {value}"), + )); + } + if !seen.insert(id.clone()) { + return Err(invalid( + format!("{path}[{index}]"), + format!("duplicate parameter {value}"), + )); + } + validated.push(id); + } + if require_all && seen.len() != parameters.len() { + return Err(invalid( + path, + "order must list every parameter exactly once", + )); + } + Ok(validated) +} + +fn validate_groups( + groups: &[GroupDocument], + parameters: &BTreeSet, +) -> Result, ProfileError> { + let mut ids = BTreeSet::new(); + let mut assigned = BTreeSet::new(); + let mut canonical = Vec::with_capacity(groups.len()); + for (index, group) in groups.iter().enumerate() { + let path = format!("groups[{index}]"); + validate_portable_id(&format!("{path}.id"), &group.id)?; + validate_text(&format!("{path}.name"), &group.name, false)?; + if !ids.insert(group.id.clone()) { + return Err(invalid( + format!("{path}.id"), + format!("duplicate group ID {}", group.id), + )); + } + for (parameter_index, parameter) in group.parameters.iter().enumerate() { + let id = ParameterId::parse(parameter.clone()).map_err(|error| { + invalid( + format!("{path}.parameters[{parameter_index}]"), + error.to_string(), + ) + })?; + if !parameters.contains(&id) { + return Err(invalid( + format!("{path}.parameters[{parameter_index}]"), + format!("unknown parameter {parameter}"), + )); + } + if !assigned.insert(id) { + return Err(invalid( + format!("{path}.parameters[{parameter_index}]"), + format!("parameter {parameter} is assigned to more than one group"), + )); + } + } + canonical.push(CanonicalGroup { + id: group.id.clone(), + name: group.name.clone(), + parameters: group.parameters.clone(), + }); + } + Ok(canonical) +} + +fn validate_faults( + faults: &mut [FaultDocument], + parameters: &[ValidatedParameter], + parameter_index: &BTreeMap, + parameter_ids: &BTreeSet, +) -> Result<(Vec, Vec), ProfileError> { + faults.sort_by(|left, right| left.id.cmp(&right.id)); + let mut ids = BTreeSet::new(); + let mut validated = Vec::with_capacity(faults.len()); + let mut canonical = Vec::with_capacity(faults.len()); + for (index, fault) in faults.iter_mut().enumerate() { + let path = format!("faults[{index}]"); + validate_portable_id(&format!("{path}.id"), &fault.id)?; + if !ids.insert(fault.id.clone()) { + return Err(invalid( + format!("{path}.id"), + format!("duplicate fault ID {}", fault.id), + )); + } + let source = ParameterId::parse(fault.source_parameter.clone()) + .map_err(|error| invalid(format!("{path}.source_parameter"), error.to_string()))?; + let source_parameter = parameter_index + .get(&source) + .and_then(|index| parameters.get(*index)) + .ok_or_else(|| { + invalid( + format!("{path}.source_parameter"), + format!("unknown parameter {}", fault.source_parameter), + ) + })?; + match fault.representation { + FaultRepresentationDocument::BitSet + if !matches!( + source_parameter.encoding(), + RegisterEncoding::Bitfield16 + | RegisterEncoding::Bitfield32 + | RegisterEncoding::Bitfield64 + ) => + { + return Err(invalid( + format!("{path}.representation"), + "bit_set fault source requires a bitfield parameter", + )); + } + FaultRepresentationDocument::ScalarCode + if matches!( + source_parameter.encoding(), + RegisterEncoding::Bitfield16 + | RegisterEncoding::Bitfield32 + | RegisterEncoding::Bitfield64 + ) => + { + return Err(invalid( + format!("{path}.representation"), + "scalar_code fault source cannot use a bitfield parameter", + )); + } + _ => {} + } + fault.no_fault_values.sort_unstable(); + fault.no_fault_values.dedup(); + + let mut meanings = BTreeMap::new(); + let mut canonical_meanings = BTreeMap::new(); + for (raw, meaning) in &fault.meanings { + raw.parse::().map_err(|_| { + invalid( + format!("{path}.meanings.{raw}"), + "fault meaning keys must be unsigned decimal integers", + ) + })?; + validate_text(&format!("{path}.meanings.{raw}.name"), &meaning.name, false)?; + validate_text( + &format!("{path}.meanings.{raw}.description"), + &meaning.description, + true, + )?; + let severity = fault_severity_name(meaning.severity).to_owned(); + meanings.insert( + raw.clone(), + ValidatedFaultMeaning { + name: meaning.name.clone(), + description: meaning.description.clone(), + severity: severity.clone(), + }, + ); + canonical_meanings.insert( + raw.clone(), + CanonicalFaultMeaning { + name: meaning.name.clone(), + description: meaning.description.clone(), + severity, + }, + ); + } + let freeze_frame = materialize_ref_list( + &format!("{path}.freeze_frame"), + &fault.freeze_frame, + parameter_ids, + )?; + validated.push(ValidatedFault { + id: fault.id.clone(), + source_parameter: source, + representation: fault.representation, + no_fault_values: fault.no_fault_values.clone(), + meanings, + freeze_frame, + }); + canonical.push(CanonicalFault { + id: fault.id.clone(), + source_parameter: fault.source_parameter.clone(), + representation: fault_representation_name(fault.representation).to_owned(), + no_fault_values: fault.no_fault_values.clone(), + meanings: canonical_meanings, + freeze_frame: fault.freeze_frame.clone(), + }); + } + Ok((validated, canonical)) +} + +fn validate_presets( + presets: &[TelemetryPresetDocument], + parameters: &BTreeSet, +) -> Result, ProfileError> { + let mut ids = BTreeSet::new(); + let mut canonical = Vec::with_capacity(presets.len()); + for (index, preset) in presets.iter().enumerate() { + let path = format!("telemetry_presets[{index}]"); + validate_portable_id(&format!("{path}.id"), &preset.id)?; + validate_text(&format!("{path}.name"), &preset.name, false)?; + if !ids.insert(preset.id.clone()) { + return Err(invalid( + format!("{path}.id"), + format!("duplicate preset ID {}", preset.id), + )); + } + if preset.channels.len() > 8 { + return Err(invalid( + format!("{path}.channels"), + "a telemetry preset may contain at most 8 channels", + )); + } + materialize_ref_list(&format!("{path}.channels"), &preset.channels, parameters)?; + canonical.push(CanonicalPreset { + id: preset.id.clone(), + name: preset.name.clone(), + channels: preset.channels.clone(), + }); + } + Ok(canonical) +} + +fn validate_restore_order( + restore_order: &[String], + parameters: &[ValidatedParameter], + parameter_index: &BTreeMap, + parameter_ids: &BTreeSet, +) -> Result, ProfileError> { + let order = materialize_ref_list("restore_order", restore_order, parameter_ids)?; + let mut seen = BTreeSet::new(); + for (index, parameter) in order.iter().enumerate() { + if !seen.insert(parameter.clone()) { + return Err(invalid( + format!("restore_order[{index}]"), + format!("duplicate parameter {parameter}"), + )); + } + let definition = parameter_index + .get(parameter) + .and_then(|index| parameters.get(*index)) + .ok_or_else(|| invalid("restore_order", "internal parameter index mismatch"))?; + if definition.access() == ParameterAccess::ReadOnly + || definition.restore_policy() != RestorePolicy::Normal + { + return Err(invalid( + format!("restore_order[{index}]"), + "restore order may list only writable Normal parameters", + )); + } + } + Ok(order) +} + +fn materialize_ref_list( + path: &str, + values: &[String], + parameters: &BTreeSet, +) -> Result, ProfileError> { + values + .iter() + .enumerate() + .map(|(index, value)| { + let id = ParameterId::parse(value.clone()) + .map_err(|error| invalid(format!("{path}[{index}]"), error.to_string()))?; + if !parameters.contains(&id) { + return Err(invalid( + format!("{path}[{index}]"), + format!("unknown parameter {value}"), + )); + } + Ok(id) + }) + .collect() +} + +/// Converts an explicit source address into a zero-based Modbus PDU address. +pub fn normalize_address( + address: &AddressDocument, + table: TableDocument, +) -> Result { + let pdu = match address { + AddressDocument::PduZeroBased { value } if *value <= u64::from(u16::MAX) => *value, + AddressDocument::PduZeroBased { value } => { + return Err(invalid( + "address.value", + format!("PDU address {value} exceeds 65535"), + )); + } + AddressDocument::ProtocolOneBased { value } if (1..=65_536).contains(value) => value - 1, + AddressDocument::ProtocolOneBased { value } => { + return Err(invalid( + "address.value", + format!("one-based address {value} is outside 1..=65536"), + )); + } + AddressDocument::Modicon5Digit { value } => match table { + TableDocument::InputRegisters if (30_001..=39_999).contains(value) => value - 30_001, + TableDocument::HoldingRegisters if (40_001..=49_999).contains(value) => value - 40_001, + _ => { + return Err(invalid( + "address.value", + format!("five-digit Modicon address {value} does not match {table:?}"), + )); + } + }, + AddressDocument::Modicon6Digit { value } => match table { + TableDocument::InputRegisters if (300_001..=365_536).contains(value) => value - 300_001, + TableDocument::HoldingRegisters if (400_001..=465_536).contains(value) => { + value - 400_001 + } + _ => { + return Err(invalid( + "address.value", + format!("six-digit Modicon address {value} does not match {table:?}"), + )); + } + }, + }; + let pdu = u16::try_from(pdu) + .map_err(|_| invalid("address.value", "normalized address exceeds 65535"))?; + Ok(RegisterAddress::new(pdu)) +} + +fn normalize_text_set(path: &str, values: &mut Vec) -> Result<(), ProfileError> { + for (index, value) in values.iter().enumerate() { + validate_text(&format!("{path}[{index}]"), value, false)?; + } + values.sort(); + values.dedup(); + Ok(()) +} + +fn validate_optional_text(path: &str, value: Option<&str>) -> Result<(), ProfileError> { + if let Some(value) = value { + validate_text(path, value, false)?; + } + Ok(()) +} + +fn validate_text(path: &str, value: &str, allow_empty: bool) -> Result<(), ProfileError> { + if !allow_empty && value.is_empty() { + return Err(invalid(path, "text must not be empty")); + } + if value.len() > MAX_TEXT_BYTES { + return Err(invalid( + path, + format!("text exceeds {MAX_TEXT_BYTES} bytes"), + )); + } + if let Some((index, character)) = value + .char_indices() + .find(|(_, character)| character.is_control()) + { + return Err(invalid( + path, + format!("control character {character:?} at byte {index} is not allowed"), + )); + } + Ok(()) +} + +fn validate_portable_id(path: &str, value: &str) -> Result<(), ProfileError> { + ParameterId::parse(value.to_owned()) + .map(|_| ()) + .map_err(|error| invalid(path, error.to_string())) +} + +fn parse_canonical_decimal(path: &str, value: &mut String) -> Result { + let decimal = Decimal::from_str(value) + .map_err(|error| invalid(path, format!("invalid decimal: {error}")))?; + *value = canonical_decimal(decimal); + Ok(decimal) +} + +fn canonical_decimal(value: Decimal) -> String { + if value.is_zero() { + "0".to_owned() + } else { + value.normalize().to_string() + } +} + +fn invalid(path: impl Into, message: impl Into) -> ProfileError { + ProfileError::Validation { + path: path.into(), + message: message.into(), + } +} + +fn domain_table(value: TableDocument) -> ModbusTable { + match value { + TableDocument::InputRegisters => ModbusTable::InputRegisters, + TableDocument::HoldingRegisters => ModbusTable::HoldingRegisters, + } +} + +fn read_function(table: ModbusTable) -> ModbusFunction { + match table { + ModbusTable::InputRegisters => ModbusFunction::ReadInputRegisters, + ModbusTable::HoldingRegisters => ModbusFunction::ReadHoldingRegisters, + } +} + +fn domain_encoding(value: EncodingDocument) -> RegisterEncoding { + match value { + EncodingDocument::Unsigned16 => RegisterEncoding::Unsigned16, + EncodingDocument::Signed16 => RegisterEncoding::Signed16, + EncodingDocument::Unsigned32 => RegisterEncoding::Unsigned32, + EncodingDocument::Signed32 => RegisterEncoding::Signed32, + EncodingDocument::Unsigned64 => RegisterEncoding::Unsigned64, + EncodingDocument::Signed64 => RegisterEncoding::Signed64, + EncodingDocument::Float32 => RegisterEncoding::Float32, + EncodingDocument::Float64 => RegisterEncoding::Float64, + EncodingDocument::Bcd16 => RegisterEncoding::Bcd16, + EncodingDocument::Bcd32 => RegisterEncoding::Bcd32, + EncodingDocument::Enum16 => RegisterEncoding::Enum16, + EncodingDocument::Enum32 => RegisterEncoding::Enum32, + EncodingDocument::Bitfield16 => RegisterEncoding::Bitfield16, + EncodingDocument::Bitfield32 => RegisterEncoding::Bitfield32, + EncodingDocument::Bitfield64 => RegisterEncoding::Bitfield64, + } +} + +fn domain_byte_order(value: ByteOrderDocument) -> ByteOrder { + match value { + ByteOrderDocument::BigEndian => ByteOrder::BigEndian, + ByteOrderDocument::LittleEndian => ByteOrder::LittleEndian, + } +} + +fn domain_word_order(value: WordOrderDocument) -> WordOrder { + match value { + WordOrderDocument::MostSignificantFirst => WordOrder::MostSignificantFirst, + WordOrderDocument::LeastSignificantFirst => WordOrder::LeastSignificantFirst, + } +} + +fn domain_quantity(value: &QuantityDocument, path: &str) -> Result { + Ok(match value { + QuantityDocument::Frequency => QuantityKind::Frequency, + QuantityDocument::RotationalSpeed => QuantityKind::RotationalSpeed, + QuantityDocument::Current => QuantityKind::Current, + QuantityDocument::Voltage => QuantityKind::Voltage, + QuantityDocument::Power => QuantityKind::Power, + QuantityDocument::Energy => QuantityKind::Energy, + QuantityDocument::Torque => QuantityKind::Torque, + QuantityDocument::Temperature => QuantityKind::Temperature, + QuantityDocument::Time => QuantityKind::Time, + QuantityDocument::Ratio => QuantityKind::Ratio, + QuantityDocument::Pressure => QuantityKind::Pressure, + QuantityDocument::Flow => QuantityKind::Flow, + QuantityDocument::Count => QuantityKind::Count, + QuantityDocument::DigitalState => QuantityKind::DigitalState, + QuantityDocument::Unitless => QuantityKind::Unitless, + QuantityDocument::Custom { id } => QuantityKind::Custom( + QuantityId::parse(id.clone()).map_err(|error| invalid(path, error.to_string()))?, + ), + }) +} + +fn canonical_quantity(value: &QuantityDocument) -> (&'static str, Option) { + match value { + QuantityDocument::Frequency => ("frequency", None), + QuantityDocument::RotationalSpeed => ("rotational_speed", None), + QuantityDocument::Current => ("current", None), + QuantityDocument::Voltage => ("voltage", None), + QuantityDocument::Power => ("power", None), + QuantityDocument::Energy => ("energy", None), + QuantityDocument::Torque => ("torque", None), + QuantityDocument::Temperature => ("temperature", None), + QuantityDocument::Time => ("time", None), + QuantityDocument::Ratio => ("ratio", None), + QuantityDocument::Pressure => ("pressure", None), + QuantityDocument::Flow => ("flow", None), + QuantityDocument::Count => ("count", None), + QuantityDocument::DigitalState => ("digital_state", None), + QuantityDocument::Unitless => ("unitless", None), + QuantityDocument::Custom { id } => ("custom", Some(id.clone())), + } +} + +fn domain_access(value: AccessDocument) -> ParameterAccess { + match value { + AccessDocument::ReadOnly => ParameterAccess::ReadOnly, + AccessDocument::WritableWhenStopped => ParameterAccess::WritableWhenStopped, + AccessDocument::Commissioning => ParameterAccess::Commissioning, + AccessDocument::Dangerous => ParameterAccess::Dangerous, + } +} + +fn domain_restore_policy(value: RestorePolicyDocument) -> RestorePolicy { + match value { + RestorePolicyDocument::Normal => RestorePolicy::Normal, + RestorePolicyDocument::LinkCritical => RestorePolicy::LinkCritical, + RestorePolicyDocument::RestartRequired => RestorePolicy::RestartRequired, + RestorePolicyDocument::ManualOnly => RestorePolicy::ManualOnly, + } +} + +fn domain_required_state(value: RequiredDriveStateDocument) -> RequiredDriveState { + match value { + RequiredDriveStateDocument::Any => RequiredDriveState::Any, + RequiredDriveStateDocument::Stopped => RequiredDriveState::Stopped, + RequiredDriveStateDocument::Faulted => RequiredDriveState::Faulted, + } +} + +fn domain_rounding(value: RoundingDocument) -> RoundingMode { + match value { + RoundingDocument::MidpointNearestEven => RoundingMode::MidpointNearestEven, + RoundingDocument::MidpointAwayFromZero => RoundingMode::MidpointAwayFromZero, + RoundingDocument::TowardZero => RoundingMode::TowardZero, + RoundingDocument::AwayFromZero => RoundingMode::AwayFromZero, + RoundingDocument::TowardPositiveInfinity => RoundingMode::TowardPositiveInfinity, + RoundingDocument::TowardNegativeInfinity => RoundingMode::TowardNegativeInfinity, + } +} + +fn domain_parity(value: ParityDocument) -> Parity { + match value { + ParityDocument::None => Parity::None, + ParityDocument::Even => Parity::Even, + ParityDocument::Odd => Parity::Odd, + } +} + +fn table_sort_key(table: ModbusTable) -> u8 { + match table { + ModbusTable::InputRegisters => 0, + ModbusTable::HoldingRegisters => 1, + } +} + +fn table_name(value: TableDocument) -> &'static str { + match value { + TableDocument::InputRegisters => "input_registers", + TableDocument::HoldingRegisters => "holding_registers", + } +} + +fn encoding_name(value: EncodingDocument) -> &'static str { + match value { + EncodingDocument::Unsigned16 => "unsigned16", + EncodingDocument::Signed16 => "signed16", + EncodingDocument::Unsigned32 => "unsigned32", + EncodingDocument::Signed32 => "signed32", + EncodingDocument::Unsigned64 => "unsigned64", + EncodingDocument::Signed64 => "signed64", + EncodingDocument::Float32 => "float32", + EncodingDocument::Float64 => "float64", + EncodingDocument::Bcd16 => "bcd16", + EncodingDocument::Bcd32 => "bcd32", + EncodingDocument::Enum16 => "enum16", + EncodingDocument::Enum32 => "enum32", + EncodingDocument::Bitfield16 => "bitfield16", + EncodingDocument::Bitfield32 => "bitfield32", + EncodingDocument::Bitfield64 => "bitfield64", + } +} + +fn byte_order_name(value: ByteOrderDocument) -> &'static str { + match value { + ByteOrderDocument::BigEndian => "big_endian", + ByteOrderDocument::LittleEndian => "little_endian", + } +} + +fn word_order_name(value: WordOrderDocument) -> &'static str { + match value { + WordOrderDocument::MostSignificantFirst => "most_significant_first", + WordOrderDocument::LeastSignificantFirst => "least_significant_first", + } +} + +fn access_name(value: AccessDocument) -> &'static str { + match value { + AccessDocument::ReadOnly => "read_only", + AccessDocument::WritableWhenStopped => "writable_when_stopped", + AccessDocument::Commissioning => "commissioning", + AccessDocument::Dangerous => "dangerous", + } +} + +fn restore_policy_name(value: RestorePolicyDocument) -> &'static str { + match value { + RestorePolicyDocument::Normal => "normal", + RestorePolicyDocument::LinkCritical => "link_critical", + RestorePolicyDocument::RestartRequired => "restart_required", + RestorePolicyDocument::ManualOnly => "manual_only", + } +} + +fn required_state_name(value: RequiredDriveStateDocument) -> &'static str { + match value { + RequiredDriveStateDocument::Any => "any", + RequiredDriveStateDocument::Stopped => "stopped", + RequiredDriveStateDocument::Faulted => "faulted", + } +} + +fn rounding_name(value: RoundingDocument) -> &'static str { + match value { + RoundingDocument::MidpointNearestEven => "midpoint_nearest_even", + RoundingDocument::MidpointAwayFromZero => "midpoint_away_from_zero", + RoundingDocument::TowardZero => "toward_zero", + RoundingDocument::AwayFromZero => "away_from_zero", + RoundingDocument::TowardPositiveInfinity => "toward_positive_infinity", + RoundingDocument::TowardNegativeInfinity => "toward_negative_infinity", + } +} + +fn write_function_name(value: WriteFunctionDocument) -> &'static str { + match value { + WriteFunctionDocument::WriteSingleRegister => "write_single_register", + WriteFunctionDocument::WriteMultipleRegisters => "write_multiple_registers", + } +} + +fn poll_class_name(value: PollClassDocument) -> &'static str { + match value { + PollClassDocument::Fast => "fast", + PollClassDocument::Normal => "normal", + PollClassDocument::Slow => "slow", + PollClassDocument::OnDemand => "on_demand", + } +} + +fn parity_name(value: ParityDocument) -> &'static str { + match value { + ParityDocument::None => "none", + ParityDocument::Even => "even", + ParityDocument::Odd => "odd", + } +} + +fn rs485_mode_name(value: Rs485ModeDocument) -> &'static str { + match value { + Rs485ModeDocument::AdapterManaged => "adapter_managed", + Rs485ModeDocument::LinuxIoctl => "linux_ioctl", + } +} + +fn hardware_status_name(value: HardwareVerificationStatusDocument) -> &'static str { + match value { + HardwareVerificationStatusDocument::Unverified => "unverified", + HardwareVerificationStatusDocument::Fictional => "fictional", + HardwareVerificationStatusDocument::Qualified => "qualified", + } +} + +fn fault_representation_name(value: FaultRepresentationDocument) -> &'static str { + match value { + FaultRepresentationDocument::ScalarCode => "scalar_code", + FaultRepresentationDocument::BitSet => "bit_set", + } +} + +fn fault_severity_name(value: FaultSeverityDocument) -> &'static str { + match value { + FaultSeverityDocument::Info => "info", + FaultSeverityDocument::Warning => "warning", + FaultSeverityDocument::Fault => "fault", + FaultSeverityDocument::Critical => "critical", + } +} diff --git a/profiles/example-vfd.json b/profiles/example-vfd.json deleted file mode 100644 index a63c9d9..0000000 --- a/profiles/example-vfd.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "profile_version": 1, - "vendor": "Example Devices", - "model": "Fictional VFD 1000", - "protocol": { - "default_baud_rate": 9600, - "default_parity": "none", - "register_type": "holding" - }, - "aliases": { - "control.target_frequency": "0x2001", - "status.output_frequency": "0x7000", - "status.output_current": "0x7002", - "status.dc_bus_voltage": "0x7003", - "status.fault_code": "0x8000" - }, - "scaling": { - "frequency": 0.01, - "current": 0.1, - "voltage": 0.1 - }, - "faults": { - "1": { - "code": "DEMO.01", - "name": "Demonstration fault", - "description": "This is fictional data used only to demonstrate the profile format." - } - }, - "parameter_groups": [ - { - "group_name": "D0 - Demonstration parameters", - "base_address": "0x0000", - "registers": { - "0x0001": { - "code": "D0.01", - "name": "Demonstration parameter", - "min": 0, - "max": 100, - "unit": "%", - "scale": 1.0 - } - } - } - ] -} diff --git a/profiles/example-vfd.toml b/profiles/example-vfd.toml new file mode 100644 index 0000000..0035a43 --- /dev/null +++ b/profiles/example-vfd.toml @@ -0,0 +1,102 @@ +schema_version = 1 +profile_id = "example.fictional-vfd-1000" +revision = 1 +vendor = "Example Devices" +family = "Fictional" +model = "VFD 1000" +sources = ["Fictional interoperability fixture"] +safety_notes = ["Never use fictional data on real equipment"] +presentation_order = ["status.output_frequency", "status.fault_code", "config.accel_time"] +restore_order = ["config.accel_time"] +aliases = { "status.output_frequency" = "status.output_frequency", "status.fault_code" = "status.fault_code" } + +[hardware_verification] +status = "fictional" +firmware = ["demo-1"] + +[protocol] +allowed_baud_rates = [9600, 19200] +default_baud_rate = 9600 +allowed_parity = ["none", "even"] +default_parity = "none" +data_bits = 8 +stop_bits = 1 +response_timeout_ms = 500 +min_inter_frame_delay_us = 0 +rs485_mode = "adapter_managed" + +[[identification_probes]] +id = "model-word" +description = "Fictional model word" +table = "holding_registers" +address = { notation = "modicon_5_digit", value = 40001 } +count = 1 +expected_raw = [4660] + +[[parameters]] +id = "status.output_frequency" +code = "S0.01" +name = "Output frequency" +table = "input_registers" +address = { notation = "modicon_5_digit", value = 30001 } +encoding = "unsigned16" +quantity = { kind = "frequency" } +unit = "hz" +scale = { multiplier = "1.00", divisor = "100.0", offset = "-0", decimal_places = 2 } +access = "read_only" +restore_policy = "manual_only" +poll_class = "fast" + +[[parameters]] +id = "status.fault_code" +code = "S0.02" +name = "Fault code" +table = "holding_registers" +address = { notation = "protocol_one_based", value = 2 } +encoding = "enum16" +quantity = { kind = "digital_state" } +unit = "bool" +access = "read_only" +restore_policy = "manual_only" +poll_class = "normal" + +[[parameters]] +id = "config.accel_time" +code = "P0.01" +name = "Acceleration time" +table = "holding_registers" +address = { notation = "pdu_zero_based", value = 2 } +encoding = "unsigned16" +quantity = { kind = "time" } +unit = "s" +scale = { multiplier = "1", divisor = "10", offset = "0", decimal_places = 1 } +access = "writable_when_stopped" +restore_policy = "normal" +required_drive_state = "stopped" +read_back = { kind = "exact_raw" } +write = { function = "write_single_register", forbidden_raw = [[65535]], verification_attempts = 2, verification_interval_ms = 50, max_verification_window_ms = 500 } +backup = true +poll_class = "on_demand" + +[[groups]] +id = "status" +name = "Status" +parameters = ["status.output_frequency", "status.fault_code"] + +[[groups]] +id = "configuration" +name = "Configuration" +parameters = ["config.accel_time"] + +[[faults]] +id = "active-fault" +source_parameter = "status.fault_code" +representation = "scalar_code" +no_fault_values = [0] +meanings = { "1" = { name = "Demonstration fault", description = "Fictional fault" } } +freeze_frame = ["status.output_frequency"] + +[[telemetry_presets]] +id = "overview" +name = "Overview" +channels = ["status.output_frequency"]