diff --git a/crates/m-bus-application-layer/src/value_information.rs b/crates/m-bus-application-layer/src/value_information.rs index 03f8b2d..7857777 100644 --- a/crates/m-bus-application-layer/src/value_information.rs +++ b/crates/m-bus-application-layer/src/value_information.rs @@ -587,44 +587,47 @@ impl TryFrom<&ValueInformationBlock> for ValueInformation { match first_vife_data & 0x7F { 0b0 => populate!(Watt / h, 3, dec: 5, Energy), 0b000_0001 => populate!(Watt / h, 3, dec: 6, Energy), - 0b000_0010 => populate!(ReactiveWatt * h, 1, dec: 3, Energy), - 0b000_0011 => populate!(ReactiveWatt * h, 1, dec: 4, Energy), + 0b000_0010 => populate!(ReactiveWatt * h, 1, dec: 3, ReactiveEnergy), + 0b000_0011 => populate!(ReactiveWatt * h, 1, dec: 4, ReactiveEnergy), + 0b000_0100 => populate!(ApparentWatt * h, 1, dec: 3, ApparentEnergy), + 0b000_0101 => populate!(ApparentWatt * h, 1, dec: 4, ApparentEnergy), + 0b000_0110 => { + labels.push(CoefficientOfPerformance); + decimal_scale_exponent = -1; + } 0b000_1000 => populate!(Joul, 1, dec: 8, Energy), 0b000_1001 => populate!(Joul, 1, dec: 9, Energy), - 0b000_1100 => populate!(Joul, 1, dec: 5, Energy), - 0b000_1101 => populate!(Joul, 1, dec: 6, Energy), - 0b000_1110 => populate!(Joul, 1, dec: 7, Energy), - 0b000_1111 => populate!(Joul, 1, dec: 8, Energy), - 0b001_0000 => populate!(Meter, 3, dec: 2), - 0b001_0001 => populate!(Meter, 3, dec: 3), - 0b001_0100 => populate!(ReactiveWatt, 1, dec: -3), - 0b001_0101 => populate!(ReactiveWatt, 1, dec: -2), - 0b001_0110 => populate!(ReactiveWatt, 1, dec: -1), - 0b001_0111 => populate!(ReactiveWatt, 1, dec: 0), - 0b001_1000 => populate!(Tonne, 1, dec: 2), - 0b001_1001 => populate!(Tonne, 1, dec: 3), + 0b000_1100 => populate!(Calorie, 1, dec: 5, Energy), + 0b000_1101 => populate!(Calorie, 1, dec: 6, Energy), + 0b000_1110 => populate!(Calorie, 1, dec: 7, Energy), + 0b000_1111 => populate!(Calorie, 1, dec: 8, Energy), + 0b001_0000 => populate!(Meter, 3, dec: 2, Volume), + 0b001_0001 => populate!(Meter, 3, dec: 3, Volume), + 0b001_0100 => populate!(ReactiveWatt, 1, dec: 0, ReactivePower), + 0b001_0101 => populate!(ReactiveWatt, 1, dec: 1, ReactivePower), + 0b001_0110 => populate!(ReactiveWatt, 1, dec: 2, ReactivePower), + 0b001_0111 => populate!(ReactiveWatt, 1, dec: 3, ReactivePower), + 0b001_1000 => populate!(Tonne, 1, dec: 2, Mass), + 0b001_1001 => populate!(Tonne, 1, dec: 3, Mass), 0b001_1010 => populate!(Percent, 1, dec: -1, RelativeHumidity), 0b001_1011 => populate!(Percent, 1, dec: 0, RelativeHumidity), - 0b010_0001 => populate!(Feet, 3, dec: -1), - 0b010_0010 => populate!(AmericanGallon, 1, dec: -1), - 0b010_0011 => populate!(AmericanGallon, 1, dec: 0), - 0b010_0100 => populate!(AmericanGallon / min, 1, dec: -3), - 0b010_0101 => populate!(AmericanGallon / min, 1, dec: 0), - 0b010_0110 => populate!(AmericanGallon / h, 1, dec: 0), - 0b010_1000 => populate!(Watt, 1, dec: 5), - 0b010_1001 => populate!(Watt, 1, dec: 6), + 0b010_0000 => populate!(Feet, 3, dec: 0, Volume), + 0b010_0001 => populate!(Feet, 3, dec: -1, Volume), + 0b010_0011 => populate!(Degree, 1, dec: -1, PhaseItoU), + 0b010_1000 => populate!(Watt, 1, dec: 5, Power), + 0b010_1001 => populate!(Watt, 1, dec: 6, Power), 0b010_1010 => populate!(Degree, 1, dec: -1, PhaseUtoU), 0b010_1011 => populate!(Degree, 1, dec: -1, PhaseUtoI), - 0b010_1100 => populate!(Hertz, 1, dec: -3), - 0b010_1101 => populate!(Hertz, 1, dec: -2), - 0b010_1110 => populate!(Hertz, 1, dec: -1), - 0b010_1111 => populate!(Hertz, 1, dec: 0), - 0b011_0000 => populate!(Joul / h, 1, dec: -8), - 0b011_0001 => populate!(Joul / h, 1, dec: -7), - 0b011_0100 => populate!(ApparentWatt / h, 1, dec: 0), - 0b011_0101 => populate!(ApparentWatt / h, 1, dec: 1), - 0b011_0110 => populate!(ApparentWatt / h, 1, dec: 2), - 0b011_0111 => populate!(ApparentWatt / h, 1, dec: 3), + 0b010_1100 => populate!(Hertz, 1, dec: -3, Frequency), + 0b010_1101 => populate!(Hertz, 1, dec: -2, Frequency), + 0b010_1110 => populate!(Hertz, 1, dec: -1, Frequency), + 0b010_1111 => populate!(Hertz, 1, dec: 0, Frequency), + 0b011_0000 => populate!(Joul / h, 1, dec: 8, Power), + 0b011_0001 => populate!(Joul / h, 1, dec: 9, Power), + 0b011_0100 => populate!(ApparentWatt, 1, dec: 0, ApparentPower), + 0b011_0101 => populate!(ApparentWatt, 1, dec: 1, ApparentPower), + 0b011_0110 => populate!(ApparentWatt, 1, dec: 2, ApparentPower), + 0b011_0111 => populate!(ApparentWatt, 1, dec: 3, ApparentPower), 0b101_1000 => populate!(Fahrenheit, 1, dec: -3, FlowTemperature), 0b101_1001 => populate!(Fahrenheit, 1, dec: -2, FlowTemperature), 0b101_1010 => populate!(Fahrenheit, 1, dec: -1, FlowTemperature), @@ -1160,6 +1163,12 @@ pub enum ValueLabel { DataContainerForManufacturerSpecificProtocol, CurrentlySelectedApplication, Energy, + ReactiveEnergy, + ApparentEnergy, + CoefficientOfPerformance, + ReactivePower, + Frequency, + ApparentPower, AtPhaseL1, AtPhaseL2, AtPhaseL3, @@ -1181,6 +1190,7 @@ pub enum ValueLabel { MoistureLevel, PhaseUtoU, PhaseUtoI, + PhaseItoU, ColdWarmTemperatureLimit, CumulativeMaximumOfActivePower, ResultingRatingFactor, @@ -1299,6 +1309,7 @@ pub enum UnitName { HCAUnit, Fahrenheit, AmericanGallon, + Calorie, } #[cfg(feature = "std")] @@ -1345,6 +1356,7 @@ impl fmt::Display for UnitName { UnitName::HCAUnit => write!(f, "HCAUnit"), UnitName::Fahrenheit => write!(f, "°F"), UnitName::AmericanGallon => write!(f, "UsGal"), + UnitName::Calorie => write!(f, "cal"), } } } @@ -1630,13 +1642,24 @@ mod tests { use crate::value_information::UnitName; use crate::value_information::{ValueInformation, ValueInformationBlock, ValueLabel}; - // VIF=0xFB VIFE=0x22: US gallon, 10^-1 + // VIF=0xFB VIFE=0x20 (E010 0000): ft³, dec: 0 let vi = ValueInformation::try_from( - &ValueInformationBlock::try_from([0xFB, 0x22].as_slice()).unwrap(), + &ValueInformationBlock::try_from([0xFB, 0x20].as_slice()).unwrap(), + ) + .unwrap(); + assert_eq!(vi.units[0].name, UnitName::Feet); + assert_eq!(vi.units[0].exponent, 3); + assert_eq!(vi.decimal_scale_exponent, 0); + assert!(vi.labels.contains(&ValueLabel::Volume)); + + // VIF=0xFB VIFE=0x23 (E010 0011): Phase angle I-U, 0.1° + let vi = ValueInformation::try_from( + &ValueInformationBlock::try_from([0xFB, 0x23].as_slice()).unwrap(), ) .unwrap(); - assert_eq!(vi.units[0].name, UnitName::AmericanGallon); + assert_eq!(vi.units[0].name, UnitName::Degree); assert_eq!(vi.decimal_scale_exponent, -1); + assert!(vi.labels.contains(&ValueLabel::PhaseItoU)); // VIF=0xFB VIFE=0x70: °F cold/warm temp limit, 10^-3 let vi = ValueInformation::try_from( @@ -1646,9 +1669,9 @@ mod tests { assert_eq!(vi.units[0].name, UnitName::Fahrenheit); assert_eq!(vi.decimal_scale_exponent, -3); - // VIF=0xFB VIFE=0x20 (E010 0000): Reserved per EN 13757-3 — should not error + // VIF=0xFB VIFE=0x22 (E010 0010): Reserved — should not error let vi = ValueInformation::try_from( - &ValueInformationBlock::try_from([0xFB, 0x20].as_slice()).unwrap(), + &ValueInformationBlock::try_from([0xFB, 0x22].as_slice()).unwrap(), ) .unwrap(); assert!(vi.labels.contains(&ValueLabel::Reserved)); diff --git a/tests/test_other_meters.rs b/tests/test_other_meters.rs new file mode 100644 index 0000000..6cf1338 --- /dev/null +++ b/tests/test_other_meters.rs @@ -0,0 +1,114 @@ +#![cfg(all(feature = "std", feature = "serde"))] +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + +use m_bus_parser::mbus_data::MbusData; +use m_bus_parser::WiredFrame; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_electricity_meter() { + let input = "68 65 65 68 08 00 72 78 56 34 12 74 52 C7 02 2A 00 00 00 04 05 73 00 00 00 04 FB 82 75 00 00 00 00 04 2A 8F 00 00 00 04 FB 97 72 AF FF FF FF 04 FB B7 72 A5 00 00 00 02 FD BA 73 64 03 84 80 80 40 FD 48 00 00 00 00 04 FD 48 F7 02 00 00 84 40 FD 59 77 00 00 00 84 80 40 FD 59 00 00 00 00 84 C0 40 FD 59 00 00 00 00 1F F9 16"; + let bytes: Vec = input + .split_whitespace() + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + + let mbus_data = MbusData::::try_from(bytes.as_slice()).unwrap(); + let json = serde_json::to_value(&mbus_data).unwrap(); + + let header = + &json["user_data"]["VariableDataStructureWithLongTplHeader"]["long_tpl_header"]; + assert_eq!(header["device_type"], "ElectricityMeter"); + assert_eq!(header["identification_number"]["number"], 12345678); + assert_eq!(header["version"], 199); + assert_eq!(header["manufacturer"]["Ok"]["code"][0], "T"); + assert_eq!(header["manufacturer"]["Ok"]["code"][1], "S"); + assert_eq!(header["manufacturer"]["Ok"]["code"][2], "T"); + assert_eq!(header["short_tpl_header"]["access_number"], 42); + + let records = json["data_records"].as_array().unwrap(); + assert_eq!(records.len(), 11); + + // (label, units, scale_exp, value, storage, tariff, device) + let expected: &[(&str, &[(&str, i64)], i64, f64, u64, u64, u64)] = &[ + ("Energy", &[("Watt", 1), ("Hour", 1)], 2, 115.0, 0, 0, 0), + ( + "ReactiveEnergy", + &[("ReactiveWatt", 1), ("Hour", 1)], + 2, + 0.0, + 0, + 0, + 0, + ), + ("Power", &[("Watt", 1)], -1, 143.0, 0, 0, 0), + ("ReactivePower", &[("ReactiveWatt", 1)], -1, -81.0, 0, 0, 0), + ("ApparentPower", &[("ApparentWatt", 1)], -1, 165.0, 0, 0, 0), + ("Dimensionless", &[], -3, 868.0, 0, 0, 0), + ("Voltage", &[("Volt", 1)], -1, 0.0, 0, 0, 4), + ("Voltage", &[("Volt", 1)], -1, 759.0, 0, 0, 0), + ("Current", &[("Ampere", 1)], -3, 119.0, 0, 0, 1), + ("Current", &[("Ampere", 1)], -3, 0.0, 0, 0, 2), + ("Current", &[("Ampere", 1)], -3, 0.0, 0, 0, 3), + ]; + + for (i, (label, units, exponent, value, storage, tariff, device)) in + expected.iter().enumerate() + { + let rec = &records[i]; + let hdr = &rec["data_record_header"]["processed_data_record_header"]; + let vi = &hdr["value_information"]; + let di = &hdr["data_information"]; + + assert_eq!( + vi["labels"][0].as_str().unwrap(), + *label, + "record {i} label" + ); + assert_eq!( + vi["decimal_scale_exponent"].as_i64().unwrap(), + *exponent, + "record {i} exponent" + ); + assert_eq!( + rec["data"]["value"]["Number"].as_f64().unwrap(), + *value, + "record {i} value" + ); + assert_eq!( + di["function_field"], "InstantaneousValue", + "record {i} function" + ); + assert_eq!( + di["storage_number"].as_u64().unwrap(), + *storage, + "record {i} storage" + ); + assert_eq!(di["tariff"].as_u64().unwrap(), *tariff, "record {i} tariff"); + assert_eq!(di["device"].as_u64().unwrap(), *device, "record {i} device"); + + let json_units = vi["units"].as_array().unwrap(); + assert_eq!(json_units.len(), units.len(), "record {i} unit count"); + for (j, (name, exp)) in units.iter().enumerate() { + assert_eq!( + json_units[j]["name"].as_str().unwrap(), + *name, + "record {i} unit {j} name" + ); + assert_eq!( + json_units[j]["exponent"].as_i64().unwrap(), + *exp, + "record {i} unit {j} exponent" + ); + } + } + } +}