use crate::error::IdentifierError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ObisComponents {
pub a: Option<u32>,
pub b: Option<u32>,
pub c: u32,
pub d: u32,
pub e: Option<u32>,
pub f: Option<u32>,
}
fn parse_uint(s: &str) -> Option<(u32, &str)> {
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if end == 0 {
return None;
}
let n = s[..end].parse::<u32>().ok()?;
Some((n, &s[end..]))
}
fn validate_and_parse(s: &str) -> Result<ObisComponents, IdentifierError> {
if s.is_empty() {
return Err(IdentifierError::InvalidFormat {
description: "OBIS code must not be empty".into(),
});
}
let (s, f) = if let Some(idx) = s.rfind(['*', '&']) {
let f_str = &s[idx + 1..];
let f_val = f_str
.parse::<u32>()
.map_err(|_| IdentifierError::InvalidFormat {
description: "F component must be a non-negative integer".into(),
})?;
(&s[..idx], Some(f_val))
} else {
(s, None)
};
let (s, a, b) = if let Some(colon_pos) = s.find(':') {
let prefix = &s[..colon_pos];
let rest = &s[colon_pos + 1..];
if let Some(dash_pos) = prefix.find('-') {
let a =
prefix[..dash_pos]
.parse::<u32>()
.map_err(|_| IdentifierError::InvalidFormat {
description: "A component must be a non-negative integer".into(),
})?;
let b = prefix[dash_pos + 1..].parse::<u32>().map_err(|_| {
IdentifierError::InvalidFormat {
description: "B component must be a non-negative integer".into(),
}
})?;
(rest, Some(a), Some(b))
} else {
let a = prefix
.parse::<u32>()
.map_err(|_| IdentifierError::InvalidFormat {
description: "A component must be a non-negative integer".into(),
})?;
(rest, Some(a), None)
}
} else {
(s, None, None)
};
let (c, rest) = parse_uint(s).ok_or(IdentifierError::InvalidFormat {
description: "C component (mandatory) must be a non-negative integer".into(),
})?;
if c == 0 {
return Err(IdentifierError::InvalidFormat {
description:
"C component (physical quantity) must be ≥ 1 per IEC 62056-61 / BDEW specification"
.into(),
});
}
if !rest.starts_with('.') {
return Err(IdentifierError::InvalidFormat {
description: "expected '.' separator between C and D".into(),
});
}
let rest = &rest[1..];
let (d, rest) = parse_uint(rest).ok_or(IdentifierError::InvalidFormat {
description: "D component (mandatory) must be a non-negative integer".into(),
})?;
let e = if let Some(rest) = rest.strip_prefix('.') {
let (e_val, remainder) = parse_uint(rest).ok_or(IdentifierError::InvalidFormat {
description: "E component must be a non-negative integer after '.'".into(),
})?;
if !remainder.is_empty() {
return Err(IdentifierError::InvalidFormat {
description: "unexpected trailing characters after E component".into(),
});
}
Some(e_val)
} else if rest.is_empty() {
None
} else {
return Err(IdentifierError::InvalidFormat {
description: "unexpected trailing characters after D component".into(),
});
};
Ok(ObisComponents { a, b, c, d, e, f })
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "validate", derive(garde::Validate))]
#[cfg_attr(feature = "validate", garde(allow_unvalidated))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub struct ObisCode(#[cfg_attr(feature = "validate", garde(custom(check_obis_code)))] Box<str>);
#[cfg(feature = "validate")]
fn check_obis_code(value: &str, _: &()) -> Result<(), garde::Error> {
validate_and_parse(value)
.map(|_| ())
.map_err(garde::Error::from)
}
impl ObisCode {
#[must_use = "the validated identifier is returned; ignoring it discards the result"]
pub fn new(s: &str) -> Result<Self, IdentifierError> {
validate_and_parse(s)?;
let normalised: Box<str> = if s.contains('&') {
s.replace('&', "*").into()
} else {
Box::from(s)
};
Ok(Self(normalised))
}
pub fn components(&self) -> ObisComponents {
validate_and_parse(&self.0)
.expect("ObisCode invariant violated: stored string is not a valid OBIS identifier")
}
}
impl TryFrom<String> for ObisCode {
type Error = IdentifierError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(&s)
}
}
impl TryFrom<&str> for ObisCode {
type Error = IdentifierError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl AsRef<str> for ObisCode {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ObisCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for ObisCode {
type Err = IdentifierError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for ObisCode {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ObisCode {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = ObisCode;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an OBIS code string (e.g. \"1-0:1.8.0*255\")")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<ObisCode, E> {
ObisCode::new(v).map_err(|e| {
crate::identifiers::trace_identifier_deser_error("ObisCode", v, &e);
serde::de::Error::custom(e)
})
}
}
d.deserialize_str(Visitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn c_dot_d_only() {
let c = ObisCode::new("1.8").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(None, None, 1, 8, None, None)
);
}
#[test]
fn c_dot_d_dot_e() {
let c = ObisCode::new("1.8.1").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(None, None, 1, 8, Some(1), None)
);
}
#[test]
fn a_b_colon_c_dot_d_dot_e() {
let c = ObisCode::new("1-0:1.8.1").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(Some(1), Some(0), 1, 8, Some(1), None)
);
}
#[test]
fn with_f_component_star() {
let c = ObisCode::new("1-0:1.8.0*255").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(Some(1), Some(0), 1, 8, Some(0), Some(255))
);
}
#[test]
fn with_f_component_ampersand() {
let c = ObisCode::new("1-0:1.8.0&255").unwrap();
let p = c.components();
assert_eq!(p.f, Some(255));
}
#[test]
fn a_colon_without_b() {
let c = ObisCode::new("1:1.8.1").unwrap();
let p = c.components();
assert_eq!((p.a, p.b, p.c, p.d, p.e), (Some(1), None, 1, 8, Some(1)));
}
#[test]
fn zero_c_component_is_invalid() {
assert!(
matches!(
ObisCode::new("0-0:0.0.0*0").unwrap_err(),
IdentifierError::InvalidFormat { .. }
),
"C=0 must be rejected"
);
let c = ObisCode::new("0-0:1.0.0*0").unwrap();
let p = c.components();
assert_eq!((p.a, p.b, p.c, p.d, p.f), (Some(0), Some(0), 1, 0, Some(0)));
}
#[test]
fn display_preserves_input() {
let input = "1-0:1.8.1";
assert_eq!(ObisCode::new(input).unwrap().to_string(), input);
}
#[test]
fn round_trip() {
let s = "1-0:1.8.0*255";
let c = s.parse::<ObisCode>().unwrap();
assert_eq!(c.to_string(), s);
}
#[test]
fn empty_string_fails() {
assert!(matches!(
ObisCode::new("").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn missing_d_component_fails() {
assert!(matches!(
ObisCode::new("1.").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn missing_c_component_fails() {
assert!(matches!(
ObisCode::new(".8").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn trailing_garbage_fails() {
assert!(matches!(
ObisCode::new("1.8.1.2").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn non_numeric_c_fails() {
assert!(matches!(
ObisCode::new("A.8").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn non_numeric_f_fails() {
assert!(matches!(
ObisCode::new("1.8*abc").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
}