use time::{Date, OffsetDateTime};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum GatewayStatus {
Provisioned,
Commissioned,
Operational,
Revoked,
Replaced,
CommunicationFault,
}
impl GatewayStatus {
#[must_use]
pub fn is_data_delivering(self) -> bool {
matches!(self, Self::Operational)
}
#[must_use]
pub fn requires_substitute_values(self) -> bool {
matches!(
self,
Self::Revoked | Self::Replaced | Self::CommunicationFault
)
}
#[must_use]
pub fn triggers_sonderablesung(self) -> bool {
matches!(self, Self::CommunicationFault | Self::Revoked)
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct GatewayCertificate {
pub serial_number: String,
pub cert_type: CertificateType,
pub subject_cn: String,
pub issuer_cn: String,
pub valid_from: Date,
pub valid_to: Date,
pub is_revoked: bool,
pub revoked_at: Option<OffsetDateTime>,
}
impl GatewayCertificate {
#[must_use]
pub fn is_valid(&self, today: Date) -> bool {
!self.is_revoked && today >= self.valid_from && today <= self.valid_to
}
#[must_use]
pub fn days_to_expiry(&self, today: Date) -> i32 {
(self.valid_to - today).whole_days() as i32
}
#[must_use]
pub fn is_expiring_soon(&self, today: Date, warning_days: i32) -> bool {
!self.is_revoked && self.days_to_expiry(today) <= warning_days
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum CertificateType {
Tls,
Sig,
Enc,
KeyAgreement,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ClsChannel {
pub channel_id: String,
pub malo_id: String,
pub device_type: ClsDeviceType,
pub max_power_kw: rust_decimal::Decimal,
pub channel_status: ClsChannelStatus,
pub produktcode: Option<String>,
pub valid_from: Date,
pub valid_to: Option<Date>,
}
impl ClsChannel {
#[must_use]
pub fn is_active(&self) -> bool {
matches!(self.channel_status, ClsChannelStatus::Active)
}
#[must_use]
pub fn is_section_14a_compliant(&self) -> bool {
self.produktcode.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ClsDeviceType {
HeatPump,
EvCharger,
AirConditioning,
NightStorage,
PvInverter,
BatteryStorage,
GenericLoad,
}
impl ClsDeviceType {
#[must_use]
pub fn is_section_14a_relevant(self) -> bool {
matches!(
self,
Self::HeatPump | Self::EvCharger | Self::AirConditioning | Self::NightStorage
)
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::HeatPump => "Wärmepumpe",
Self::EvCharger => "Wallbox / E-Mobilität",
Self::AirConditioning => "Klimaanlage",
Self::NightStorage => "Nachtspeicherheizung",
Self::PvInverter => "PV-Wechselrichter",
Self::BatteryStorage => "Batteriespeicher",
Self::GenericLoad => "Steuerbare Last",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ClsChannelStatus {
Active,
Suspended,
Pending,
Decommissioned,
Fault,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SmgwSession {
pub device_id: String,
pub firmware_version: String,
pub msb_mp_id: String,
pub malo_id: String,
pub status: GatewayStatus,
pub certificates: Vec<GatewayCertificate>,
pub cls_channels: Vec<ClsChannel>,
pub last_contact_at: Option<OffsetDateTime>,
pub installed_at: Date,
}
impl SmgwSession {
#[must_use]
pub fn has_valid_tls_cert(&self, today: Date) -> bool {
self.certificates
.iter()
.any(|c| matches!(c.cert_type, CertificateType::Tls) && c.is_valid(today))
}
#[must_use]
pub fn expiring_certificates(
&self,
today: Date,
warning_days: i32,
) -> Vec<&GatewayCertificate> {
self.certificates
.iter()
.filter(|c| !c.is_revoked && c.is_expiring_soon(today, warning_days))
.collect()
}
#[must_use]
pub fn active_cls_channels(&self) -> Vec<&ClsChannel> {
self.cls_channels
.iter()
.filter(|ch| ch.is_active())
.collect()
}
#[must_use]
pub fn has_section_14a_cls(&self) -> bool {
self.cls_channels
.iter()
.any(|ch| ch.is_active() && ch.device_type.is_section_14a_relevant())
}
#[must_use]
pub fn hours_since_last_contact(&self) -> Option<i64> {
let last = self.last_contact_at?;
let now = OffsetDateTime::now_utc();
Some((now - last).whole_hours())
}
#[must_use]
pub fn is_communication_fault(&self, threshold_hours: i64) -> bool {
self.hours_since_last_contact()
.is_none_or(|h| h > threshold_hours)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
fn valid_cert(cert_type: CertificateType) -> GatewayCertificate {
GatewayCertificate {
serial_number: "AA:BB:CC".to_owned(),
cert_type,
subject_cn: "SMGW-001".to_owned(),
issuer_cn: "BSI-Smart-Meter-CA".to_owned(),
valid_from: date!(2025 - 01 - 01),
valid_to: date!(2027 - 12 - 31),
is_revoked: false,
revoked_at: None,
}
}
fn basic_gateway() -> SmgwSession {
SmgwSession {
device_id: "SMGW-2026-001".to_owned(),
firmware_version: "2.1.4".to_owned(),
msb_mp_id: "9900357000004".to_owned(),
malo_id: "51238696780".to_owned(),
status: GatewayStatus::Operational,
certificates: vec![valid_cert(CertificateType::Tls)],
cls_channels: Vec::new(),
last_contact_at: Some(OffsetDateTime::now_utc()),
installed_at: date!(2025 - 06 - 01),
}
}
#[test]
fn operational_gateway_delivers_data() {
assert!(GatewayStatus::Operational.is_data_delivering());
assert!(!GatewayStatus::CommunicationFault.is_data_delivering());
}
#[test]
fn revoked_requires_substitute() {
assert!(GatewayStatus::Revoked.requires_substitute_values());
assert!(GatewayStatus::CommunicationFault.requires_substitute_values());
assert!(!GatewayStatus::Operational.requires_substitute_values());
}
#[test]
fn certificate_validity_check() {
let cert = valid_cert(CertificateType::Tls);
let today = date!(2026 - 07 - 15);
assert!(cert.is_valid(today));
assert!(!cert.is_expiring_soon(today, 30)); }
#[test]
fn certificate_expiry_warning() {
let mut cert = valid_cert(CertificateType::Tls);
cert.valid_to = date!(2026 - 07 - 25); let today = date!(2026 - 07 - 15);
assert!(cert.is_expiring_soon(today, 30)); assert_eq!(cert.days_to_expiry(today), 10);
}
#[test]
fn revoked_certificate_not_valid() {
let mut cert = valid_cert(CertificateType::Sig);
cert.is_revoked = true;
assert!(!cert.is_valid(date!(2026 - 07 - 15)));
assert!(!cert.is_expiring_soon(date!(2026 - 07 - 15), 30));
}
#[test]
fn gateway_has_valid_tls() {
let gw = basic_gateway();
assert!(gw.has_valid_tls_cert(date!(2026 - 07 - 15)));
}
#[test]
fn gateway_no_tls_false() {
let mut gw = basic_gateway();
gw.certificates.clear();
assert!(!gw.has_valid_tls_cert(date!(2026 - 07 - 15)));
}
#[test]
fn cls_channel_section_14a() {
let channel = ClsChannel {
channel_id: "CLS-01".to_owned(),
malo_id: "51238696780".to_owned(),
device_type: ClsDeviceType::HeatPump,
max_power_kw: dec!(8.5),
channel_status: ClsChannelStatus::Active,
produktcode: Some("BDEW-14A-HP".to_owned()),
valid_from: date!(2026 - 01 - 01),
valid_to: None,
};
assert!(channel.is_active());
assert!(channel.is_section_14a_compliant());
assert!(channel.device_type.is_section_14a_relevant());
}
#[test]
fn gateway_with_cls_has_14a() {
let mut gw = basic_gateway();
gw.cls_channels.push(ClsChannel {
channel_id: "CLS-01".to_owned(),
malo_id: gw.malo_id.clone(),
device_type: ClsDeviceType::EvCharger,
max_power_kw: dec!(11.0),
channel_status: ClsChannelStatus::Active,
produktcode: Some("BDEW-14A-EV".to_owned()),
valid_from: date!(2026 - 01 - 01),
valid_to: None,
});
assert!(gw.has_section_14a_cls());
}
#[test]
fn expiring_certs_found() {
let mut gw = basic_gateway();
let mut expiring = valid_cert(CertificateType::Sig);
expiring.valid_to = date!(2026 - 07 - 20); gw.certificates.push(expiring);
let today = date!(2026 - 07 - 15);
let expiring = gw.expiring_certificates(today, 30);
assert_eq!(expiring.len(), 1);
assert!(matches!(expiring[0].cert_type, CertificateType::Sig));
}
#[test]
fn device_type_labels_non_empty() {
for dt in [
ClsDeviceType::HeatPump,
ClsDeviceType::EvCharger,
ClsDeviceType::AirConditioning,
ClsDeviceType::NightStorage,
ClsDeviceType::PvInverter,
ClsDeviceType::BatteryStorage,
ClsDeviceType::GenericLoad,
] {
assert!(!dt.label().is_empty());
}
}
}