cleanup rf formatting

This commit is contained in:
Robin Appelman 2026-06-27 00:13:40 +02:00
commit 16993948cf
4 changed files with 166 additions and 153 deletions

View file

@ -50,7 +50,7 @@ impl MiTempDevice {
} }
pub fn format_prometheus(&self) -> impl Display + '_ { pub fn format_prometheus(&self) -> impl Display + '_ {
MitempFormatter { device: &self } MitempFormatter { device: self }
} }
} }

View file

@ -1,8 +1,10 @@
mod mitemp; mod mitemp;
mod rf;
pub use crate::device::mitemp::MiTempDevice;
use color_eyre::{Report, Result, eyre::WrapErr}; use color_eyre::{Report, Result, eyre::WrapErr};
use jzon::JsonValue; use jzon::JsonValue;
pub use mitemp::MiTempDevice;
pub use rf::{RfDeviceId, TempState};
use rumqttc::{AsyncClient, QoS}; use rumqttc::{AsyncClient, QoS};
use serde::de::Error; use serde::de::Error;
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
@ -10,11 +12,11 @@ use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::convert::TryFrom; use std::convert::TryFrom;
use std::fmt::{self, Debug, Display, Formatter, Write}; use std::fmt::{self, Debug, Display, Formatter, Write};
use std::num::ParseIntError;
use std::str::FromStr;
use std::time::Instant; use std::time::Instant;
use tokio::task::spawn; use tokio::task::spawn;
use crate::device::rf::parse_rf_payload;
#[derive(Default)] #[derive(Default)]
pub struct DeviceStates { pub struct DeviceStates {
pub mi_temp_names: BTreeMap<BDAddr, String>, pub mi_temp_names: BTreeMap<BDAddr, String>,
@ -92,7 +94,7 @@ impl DeviceStates {
let state = self let state = self
.rf_temp_devices .rf_temp_devices
.entry(data.device_id()) .entry(data.device_id())
.or_insert_with(|| TempState::new(name.clone())); .or_insert_with(|| TempState::new(id.clone(), name.clone()));
state.humidity = data.humidity; state.humidity = data.humidity;
state.temperature = data.temperature; state.temperature = data.temperature;
} }
@ -121,7 +123,7 @@ impl DeviceStates {
let state = self let state = self
.rf_temp_devices .rf_temp_devices
.entry(self.active_rf_temp_id.clone()) .entry(self.active_rf_temp_id.clone())
.or_insert_with(|| TempState::new(name.clone())); .or_insert_with(|| TempState::new(self.active_rf_temp_id.clone(), name.clone()));
match field { match field {
"temperature_C" => state.temperature = payload.parse().unwrap_or_default(), "temperature_C" => state.temperature = payload.parse().unwrap_or_default(),
"temperature_F" => { "temperature_F" => {
@ -420,53 +422,6 @@ pub fn format_device_state<W: Write>(
Ok(()) Ok(())
} }
#[derive(Debug)]
pub struct TempState {
name: String,
temperature: f32,
humidity: u8,
}
impl TempState {
pub fn new(name: String) -> Self {
TempState {
name,
temperature: 0.0,
humidity: 0,
}
}
}
pub fn format_rf_temp_state<W: Write>(
mut writer: W,
channel: &RfDeviceId,
names: &HashMap<RfDeviceId, String>,
state: &TempState,
) -> fmt::Result {
let name = if let Some(name) = names.get(channel) {
name
} else {
return Ok(());
};
if state.temperature > 0.0 {
writeln!(
writer,
"sensor_temperature{{model=\"{}\", id=\"{}\", channel=\"{}\", name=\"{}\"}} {}",
channel.name, channel.id, channel.channel, name, state.temperature
)?;
}
if state.humidity > 0 {
writeln!(
writer,
"sensor_humidity{{model=\"{}\", id=\"{}\", channel=\"{}\", name=\"{}\"}} {}",
channel.name, channel.id, channel.channel, name, state.humidity
)?;
}
Ok(())
}
pub fn format_dsmr_state<W: Write>(mut writer: W, device: &str, state: &DsmrState) -> fmt::Result { pub fn format_dsmr_state<W: Write>(mut writer: W, device: &str, state: &DsmrState) -> fmt::Result {
let power_total = state.power_total_tariff_1.unwrap_or_default() let power_total = state.power_total_tariff_1.unwrap_or_default()
+ state.power_total_tariff_2.unwrap_or_default(); + state.power_total_tariff_2.unwrap_or_default();
@ -733,91 +688,3 @@ pub fn format_pms_state<W: Write>(
)?; )?;
Ok(()) Ok(())
} }
#[derive(Debug, PartialEq)]
struct RfPayload<'a> {
name: &'a str,
id: u16,
channel: u8,
battery: bool,
temperature: f32,
humidity: u8,
}
impl<'a> RfPayload<'a> {
pub fn device_id(&self) -> RfDeviceId {
RfDeviceId {
name: self.name.into(),
id: self.id,
channel: self.channel,
}
}
}
#[derive(Hash, PartialEq, Eq, Debug, Default, Clone)]
pub struct RfDeviceId {
name: String,
id: u16,
channel: u8,
}
impl<'de> Deserialize<'de> for RfDeviceId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let str = <Cow<'de, str>>::deserialize(deserializer)?;
Self::from_str(&str).map_err(D::Error::custom)
}
}
impl FromStr for RfDeviceId {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.splitn(3, ':');
let name = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default().parse()?;
let channel = parts.next().unwrap_or_default().parse()?;
Ok(RfDeviceId {
name: name.into(),
id,
channel,
})
}
}
fn parse_rf_payload<'a>(payload: &'a str) -> Option<RfPayload<'a>> {
let mut parts = payload.split(";").skip(2);
let name = parts.next()?;
let id = parts.next()?.strip_prefix("ID=")?.parse().ok()?;
let channel = parts.next()?.strip_prefix("CHN=")?.parse().ok()?;
let battery = parts.next()?.strip_prefix("BAT=")? == "OK";
let temperature = parts.next()?.strip_prefix("TEMP=")?;
let temperature = u32::from_str_radix(temperature, 16).ok()?;
let humidity = parts.next()?.strip_prefix("HUM=")?.parse().ok()?;
Some(RfPayload {
name,
id,
channel,
battery,
temperature: temperature as f32 / 10.0,
humidity,
})
}
#[test]
fn test_rf_payload() {
assert_eq!(
RfPayload {
name: "Bresser-3CH",
id: 49,
channel: 1,
battery: true,
temperature: 16.1,
humidity: 58
},
parse_rf_payload("20;1E;Bresser-3CH;ID=49;CHN=0001;BAT=OK;TEMP=00a1;HUM=58;").unwrap()
)
}

151
src/device/rf.rs Normal file
View file

@ -0,0 +1,151 @@
use std::borrow::Cow;
use std::fmt::{self, Display};
use std::num::ParseIntError;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, de::Error};
#[derive(Debug)]
pub struct TempState {
pub name: String,
pub id: RfDeviceId,
pub temperature: f32,
pub humidity: u8,
}
impl TempState {
pub fn new(id: RfDeviceId, name: String) -> Self {
TempState {
name,
id,
temperature: 0.0,
humidity: 0,
}
}
pub fn format_prometheus(&self) -> impl Display + '_ {
TempFormatter { device: self }
}
}
struct TempFormatter<'a> {
device: &'a TempState,
}
impl Display for TempFormatter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.device.temperature > 0.0 {
writeln!(
f,
"sensor_temperature{{model=\"{}\", id=\"{}\", channel=\"{}\", name=\"{}\"}} {}",
self.device.id.name,
self.device.id.id,
self.device.id.channel,
self.device.name,
self.device.temperature
)?;
}
if self.device.humidity > 0 {
writeln!(
f,
"sensor_humidity{{model=\"{}\", id=\"{}\", channel=\"{}\", name=\"{}\"}} {}",
self.device.id.name,
self.device.id.id,
self.device.id.channel,
self.device.name,
self.device.humidity
)?;
}
Ok(())
}
}
#[derive(Debug, PartialEq)]
pub struct RfPayload<'a> {
pub name: &'a str,
pub id: u16,
pub channel: u8,
pub battery: bool,
pub temperature: f32,
pub humidity: u8,
}
impl<'a> RfPayload<'a> {
pub fn device_id(&self) -> RfDeviceId {
RfDeviceId {
name: self.name.into(),
id: self.id,
channel: self.channel,
}
}
}
#[derive(Hash, PartialEq, Eq, Debug, Default, Clone)]
pub struct RfDeviceId {
pub name: String,
pub id: u16,
pub channel: u8,
}
impl<'de> Deserialize<'de> for RfDeviceId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let str = <Cow<'de, str>>::deserialize(deserializer)?;
Self::from_str(&str).map_err(D::Error::custom)
}
}
impl FromStr for RfDeviceId {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.splitn(3, ':');
let name = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default().parse()?;
let channel = parts.next().unwrap_or_default().parse()?;
Ok(RfDeviceId {
name: name.into(),
id,
channel,
})
}
}
pub fn parse_rf_payload<'a>(payload: &'a str) -> Option<RfPayload<'a>> {
let mut parts = payload.split(";").skip(2);
let name = parts.next()?;
let id = parts.next()?.strip_prefix("ID=")?.parse().ok()?;
let channel = parts.next()?.strip_prefix("CHN=")?.parse().ok()?;
let battery = parts.next()?.strip_prefix("BAT=")? == "OK";
let temperature = parts.next()?.strip_prefix("TEMP=")?;
let temperature = u32::from_str_radix(temperature, 16).ok()?;
let humidity = parts.next()?.strip_prefix("HUM=")?.parse().ok()?;
Some(RfPayload {
name,
id,
channel,
battery,
temperature: temperature as f32 / 10.0,
humidity,
})
}
#[test]
fn test_rf_payload() {
assert_eq!(
RfPayload {
name: "Bresser-3CH",
id: 49,
channel: 1,
battery: true,
temperature: 16.1,
humidity: 58
},
parse_rf_payload("20;1E;Bresser-3CH;ID=49;CHN=0001;BAT=OK;TEMP=00a1;HUM=58;").unwrap()
)
}

View file

@ -4,9 +4,7 @@ mod mqtt;
mod topic; mod topic;
use crate::config::{Config, ListenConfig}; use crate::config::{Config, ListenConfig};
use crate::device::{ use crate::device::{Device, DeviceStates, format_device_state, format_dsmr_state};
Device, DeviceStates, format_device_state, format_dsmr_state, format_rf_temp_state,
};
use crate::mqtt::mqtt_stream; use crate::mqtt::mqtt_stream;
use crate::topic::Topic; use crate::topic::Topic;
use clap::Parser; use clap::Parser;
@ -43,8 +41,7 @@ async fn main() -> Result<()> {
}; };
let mqtt_options = config.mqtt()?; let mqtt_options = config.mqtt()?;
let device_states = let device_states = DeviceStates::new(config.names.mi_temp, config.names.rf_temp);
DeviceStates::new(config.names.mi_temp.clone(), config.names.rf_temp.clone());
let device_states = Arc::new(Mutex::new(device_states)); let device_states = Arc::new(Mutex::new(device_states));
ctrlc::set_handler(move || { ctrlc::set_handler(move || {
@ -52,7 +49,7 @@ async fn main() -> Result<()> {
}) })
.expect("Error setting Ctrl-C handler"); .expect("Error setting Ctrl-C handler");
spawn(serve(device_states.clone(), config)); spawn(serve(device_states.clone(), config.listen));
loop { loop {
let (client, stream) = mqtt_stream(mqtt_options.clone()) let (client, stream) = mqtt_stream(mqtt_options.clone())
@ -73,9 +70,7 @@ async fn main() -> Result<()> {
} }
} }
async fn serve(device_states: Arc<Mutex<DeviceStates>>, config: Config) { async fn serve(device_states: Arc<Mutex<DeviceStates>>, listen: ListenConfig) {
let rf_temp_names = config.names.rf_temp;
let state = warp::any().map(move || device_states.clone()); let state = warp::any().map(move || device_states.clone());
let metrics = warp::path!("metrics") let metrics = warp::path!("metrics")
@ -92,14 +87,14 @@ async fn serve(device_states: Arc<Mutex<DeviceStates>>, config: Config) {
for (_, state) in state.mi_temp() { for (_, state) in state.mi_temp() {
write!(&mut response, "{}", state.format_prometheus()).expect("formatting failed"); write!(&mut response, "{}", state.format_prometheus()).expect("formatting failed");
} }
for (channel, state) in state.rf_temp() { for (_, state) in state.rf_temp() {
format_rf_temp_state(&mut response, channel, &rf_temp_names, state).unwrap() write!(&mut response, "{}", state.format_prometheus()).expect("formatting failed");
} }
response response
}); });
let cancel = async { ctrl_c().await.unwrap() }; let cancel = async { ctrl_c().await.unwrap() };
match config.listen { match listen {
ListenConfig::Ip { address, port } => { ListenConfig::Ip { address, port } => {
warp::serve(metrics) warp::serve(metrics)
.bind((address, port)) .bind((address, port))