use std::fmt;
use std::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::ParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "&str", into = "String"))]
pub struct ObisCode {
pub a: u8,
pub b: u8,
pub c: u8,
pub d: u8,
pub e: u8,
pub f: u8,
}
impl ObisCode {
pub const STROM_BEZUG_TOTAL: Self = Self {
a: 1,
b: 0,
c: 1,
d: 8,
e: 0,
f: 255,
};
pub const STROM_BEZUG_HT: Self = Self {
a: 1,
b: 0,
c: 1,
d: 8,
e: 1,
f: 255,
};
pub const STROM_BEZUG_NT: Self = Self {
a: 1,
b: 0,
c: 1,
d: 8,
e: 2,
f: 255,
};
pub const STROM_EINSPEISUNG_TOTAL: Self = Self {
a: 1,
b: 0,
c: 2,
d: 8,
e: 0,
f: 255,
};
pub const STROM_EINSPEISUNG_HT: Self = Self {
a: 1,
b: 0,
c: 2,
d: 8,
e: 1,
f: 255,
};
pub const STROM_EINSPEISUNG_NT: Self = Self {
a: 1,
b: 0,
c: 2,
d: 8,
e: 2,
f: 255,
};
pub const STROM_DEMAND_INTERVAL: Self = Self {
a: 1,
b: 0,
c: 1,
d: 29,
e: 0,
f: 255,
};
pub const STROM_REACTIVE_INDUCTIVE: Self = Self {
a: 1,
b: 0,
c: 3,
d: 8,
e: 0,
f: 255,
};
pub const STROM_REACTIVE_CAPACITIVE: Self = Self {
a: 1,
b: 0,
c: 4,
d: 8,
e: 0,
f: 255,
};
pub const STROM_REACTIVE_INDUCTIVE_EXPORT: Self = Self {
a: 1,
b: 0,
c: 3,
d: 9,
e: 0,
f: 255,
};
pub const STROM_REACTIVE_CAPACITIVE_EXPORT: Self = Self {
a: 1,
b: 0,
c: 4,
d: 9,
e: 0,
f: 255,
};
pub const GAS_VOLUME_M3: Self = Self {
a: 7,
b: 0,
c: 3,
d: 0,
e: 0,
f: 255,
};
pub const WAERME_ENERGY: Self = Self {
a: 6,
b: 0,
c: 1,
d: 0,
e: 0,
f: 255,
};
pub const KAELTE_ENERGY: Self = Self {
a: 5,
b: 0,
c: 1,
d: 0,
e: 0,
f: 255,
};
pub const WASSER_KALT_VOLUME: Self = Self {
a: 8,
b: 0,
c: 1,
d: 0,
e: 0,
f: 255,
};
pub const WASSER_WARM_VOLUME: Self = Self {
a: 9,
b: 0,
c: 1,
d: 0,
e: 0,
f: 255,
};
#[must_use]
pub fn is_electricity(&self) -> bool {
self.a == 1
}
#[must_use]
pub fn is_gas(&self) -> bool {
self.a == 7
}
#[must_use]
pub fn is_heat(&self) -> bool {
matches!(self.a, 5 | 6)
}
#[must_use]
pub fn is_water(&self) -> bool {
matches!(self.a, 8 | 9)
}
#[must_use]
pub fn is_heat_cost_allocator(&self) -> bool {
self.a == 4
}
#[must_use]
pub fn is_reactive(&self) -> bool {
matches!(self.c, 3 | 4)
}
#[must_use]
pub fn is_demand(&self) -> bool {
self.c == 1 && self.d == 29
}
#[must_use]
pub fn is_import(&self) -> bool {
self.d == 8 && self.c == 1
}
#[must_use]
pub fn is_export(&self) -> bool {
self.c == 2 && self.d == 8
}
#[must_use]
pub fn is_einspeisung(&self) -> bool {
self.is_export()
}
#[must_use]
pub fn tariff_register(&self) -> Option<u8> {
match self.e {
0 => None,
n => Some(n),
}
}
#[must_use]
pub fn is_total_register(&self) -> bool {
self.e == 0
}
#[must_use]
pub fn is_ht(&self) -> bool {
self.e == 1
}
#[must_use]
pub fn is_nt(&self) -> bool {
self.e == 2
}
#[must_use]
pub fn default_resolution(&self) -> Option<crate::resolution::IntervalResolution> {
use crate::resolution::IntervalResolution;
if self.a == 1 && (self.c == 1 || self.c == 2) && self.d == 8 {
return Some(IntervalResolution::QuarterHour);
}
if self.a == 1 && self.d == 29 {
return Some(IntervalResolution::QuarterHour);
}
if self.a == 1 && (self.c == 3 || self.c == 4) {
return Some(IntervalResolution::QuarterHour);
}
if self.a == 7 {
return Some(IntervalResolution::Hour);
}
if matches!(self.a, 5 | 6) {
return Some(IntervalResolution::Hour);
}
if matches!(self.a, 8 | 9) {
return Some(IntervalResolution::Day);
}
None
}
pub fn parse(s: &str) -> Result<Self, ParseError> {
s.parse()
}
}
impl fmt::Display for ObisCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}-{}:{}.{}.{}*{}",
self.a, self.b, self.c, self.d, self.e, self.f
)
}
}
impl From<ObisCode> for String {
fn from(o: ObisCode) -> String {
o.to_string()
}
}
const OBIS_FORMAT: &str = "A-B:C.D.E*F, e.g. 1-0:1.8.0*255 (the *F suffix is optional)";
impl FromStr for ObisCode {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_obis(s).ok_or_else(|| ParseError::format("ObisCode", s, OBIS_FORMAT))
}
}
impl TryFrom<&str> for ObisCode {
type Error = ParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
fn parse_obis(s: &str) -> Option<ObisCode> {
let (a_b, rest) = s.split_once('-')?;
let (b_str, cd_ef) = rest.split_once(':')?;
let a: u8 = a_b.parse().ok()?;
let b: u8 = b_str.parse().ok()?;
let (cde_str, f_str) = match cd_ef.split_once('*') {
Some((l, r)) => (l, r),
None => (cd_ef, "255"),
};
let f: u8 = f_str.parse().ok()?;
let parts: Vec<&str> = cde_str.splitn(3, '.').collect();
if parts.len() != 3 {
return None;
}
let c: u8 = parts[0].parse().ok()?;
let d: u8 = parts[1].parse().ok()?;
let e: u8 = parts[2].parse().ok()?;
Some(ObisCode { a, b, c, d, e, f })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_standard_strom_bezug() {
let code: ObisCode = "1-0:1.8.0*255".parse().unwrap();
assert_eq!(code, ObisCode::STROM_BEZUG_TOTAL);
assert!(code.is_electricity());
assert!(code.is_import(), "1-0:1.8.0 C=1,D=8 should be import");
assert!(code.is_total_register());
assert!(!code.is_export());
}
#[test]
fn parse_ht_register() {
let code: ObisCode = "1-0:1.8.1*255".parse().unwrap();
assert_eq!(code, ObisCode::STROM_BEZUG_HT);
assert!(code.is_ht());
assert!(!code.is_nt());
assert_eq!(code.tariff_register(), Some(1));
}
#[test]
fn parse_without_storage_number() {
let code: ObisCode = "1-0:2.8.0".parse().unwrap();
assert_eq!(code.f, 255);
assert_eq!(code.c, 2);
assert_eq!(code.d, 8);
assert!(
code.is_export(),
"1-0:2.8.0 should be Einspeisung/export (C=2, D=8)"
);
assert!(code.is_einspeisung(), "should alias is_export()");
assert_eq!(code, ObisCode::STROM_EINSPEISUNG_TOTAL);
}
#[test]
fn parse_gas_volume() {
let code: ObisCode = "7-0:3.0.0*255".parse().unwrap();
assert_eq!(code, ObisCode::GAS_VOLUME_M3);
assert!(code.is_gas());
assert!(!code.is_electricity());
}
#[test]
fn display_round_trip() {
let s = "1-0:1.8.2*255";
let code: ObisCode = s.parse().unwrap();
assert_eq!(code.to_string(), s);
}
#[test]
fn invalid_code_returns_error() {
assert!("not-an-obis".parse::<ObisCode>().is_err());
assert!("1-0:1.8".parse::<ObisCode>().is_err());
assert!("".parse::<ObisCode>().is_err());
}
#[test]
fn constants_are_correct() {
assert_eq!(ObisCode::STROM_BEZUG_TOTAL.to_string(), "1-0:1.8.0*255");
assert_eq!(ObisCode::STROM_BEZUG_HT.to_string(), "1-0:1.8.1*255");
assert_eq!(ObisCode::STROM_BEZUG_NT.to_string(), "1-0:1.8.2*255");
assert_eq!(
ObisCode::STROM_EINSPEISUNG_TOTAL.to_string(),
"1-0:2.8.0*255"
);
assert_eq!(ObisCode::GAS_VOLUME_M3.to_string(), "7-0:3.0.0*255");
}
}
#[cfg(test)]
mod media_group_tests {
use super::*;
#[test]
fn medium_group_a_follows_the_dlms_media_list() {
let hca: ObisCode = "4-0:1.0.0".parse().unwrap();
let cooling: ObisCode = "5-0:1.0.0".parse().unwrap();
let heat: ObisCode = "6-0:1.0.0".parse().unwrap();
let gas: ObisCode = "7-0:3.0.0".parse().unwrap();
let cold_water: ObisCode = "8-0:1.0.0".parse().unwrap();
let hot_water: ObisCode = "9-0:1.0.0".parse().unwrap();
assert!(hca.is_heat_cost_allocator());
assert!(
!hca.is_heat(),
"an HCA measures Verbrauchseinheiten, not kWh"
);
assert!(cooling.is_heat() && heat.is_heat());
assert!(gas.is_gas() && !gas.is_heat());
assert!(cold_water.is_water() && hot_water.is_water());
assert!(!cold_water.is_heat(), "A=8 is cold water");
}
#[test]
fn named_constants_match_their_medium_predicate() {
assert!(ObisCode::WAERME_ENERGY.is_heat(), "heat is medium 6");
assert!(!ObisCode::WAERME_ENERGY.is_water());
assert_eq!(ObisCode::WAERME_ENERGY.to_string(), "6-0:1.0.0*255");
assert!(ObisCode::KAELTE_ENERGY.is_heat(), "cooling is medium 5");
assert!(ObisCode::WASSER_KALT_VOLUME.is_water());
assert!(!ObisCode::WASSER_KALT_VOLUME.is_heat());
assert!(ObisCode::WASSER_WARM_VOLUME.is_water());
assert!(ObisCode::GAS_VOLUME_M3.is_gas());
assert!(ObisCode::STROM_BEZUG_TOTAL.is_electricity());
use crate::resolution::IntervalResolution;
assert_eq!(
ObisCode::WAERME_ENERGY.default_resolution(),
Some(IntervalResolution::Hour),
"a heat meter is read hourly, not daily like a water submeter"
);
assert_eq!(
ObisCode::WASSER_KALT_VOLUME.default_resolution(),
Some(IntervalResolution::Day)
);
}
#[test]
fn water_defaults_to_daily_resolution() {
use crate::resolution::IntervalResolution;
let cold_water: ObisCode = "8-0:1.0.0".parse().unwrap();
let heat: ObisCode = "6-0:1.0.0".parse().unwrap();
assert_eq!(
cold_water.default_resolution(),
Some(IntervalResolution::Day)
);
assert_eq!(heat.default_resolution(), Some(IntervalResolution::Hour));
}
}