forward mitemp to home assistant

This commit is contained in:
Robin Appelman 2026-06-29 22:57:35 +02:00
commit 7dff97b474
8 changed files with 211 additions and 13 deletions

8
Cargo.lock generated
View file

@ -747,6 +747,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.2" version = "2.8.2"
@ -1264,10 +1270,12 @@ dependencies = [
"dotenvy", "dotenvy",
"hostname", "hostname",
"jzon", "jzon",
"maplit",
"pin-utils", "pin-utils",
"rumqttc", "rumqttc",
"secretfile", "secretfile",
"serde", "serde",
"serde_json",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"toml", "toml",

View file

@ -19,9 +19,11 @@ pin-utils = "0.1.0"
hostname = "0.4.2" hostname = "0.4.2"
tokio-stream = { version = "0.1.18", features = ["net"] } tokio-stream = { version = "0.1.18", features = ["net"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
secretfile = "0.1.1" secretfile = "0.1.1"
toml = "1.1.2" toml = "1.1.2"
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
maplit = "1.0.2"
[profile.release] [profile.release]
lto = true lto = true

View file

@ -44,6 +44,9 @@ and the desired name.
MITEMP_NAMES="351234=Bedroom,352468=Living Room" MITEMP_NAMES="351234=Bedroom,352468=Living Room"
``` ```
In addition to exposing the data to prometheus, taspromto will also publish this
data over the same MQTT connection in a home-assistant compatible format.
## 433Mhz temperature sensors ## 433Mhz temperature sensors
Taspromto can parse data 433Mhz temperature sensors send to MQTT by Taspromto can parse data 433Mhz temperature sensors send to MQTT by

27
scratch.json Normal file
View file

@ -0,0 +1,27 @@
{
"device": {
"identifiers": ["mitemp-58:2D:34:35:F3:D4"],
"name": "Bedroom",
"manufacturer": "Icewind",
"model": "Taspromto - Mitemp"
},
"origin": { "name": "taspromto" },
"components": {
"mitemp_582D3435F3D4_humidity": {
"platform": "sensor",
"device_class": "humidity",
"unit_of_measurement": "%",
"value_template": "{{ value_json.humidity}}",
"unique_id": "mitemp_582D3435F3D4_temperature"
},
"mitemp_582D3435F3D4_temperature": {
"platform": "sensor",
"device_class": "temperature",
"unit_of_measurement": "°C",
"value_template": "{{ value_json.temperature}}",
"unique_id": "mitemp_582D3435F3D4_temperature"
}
},
"state_topic": "taspromto/sensor/mitemp_582D3435F3D4/state",
"qos": 2
}

View file

@ -1,6 +1,11 @@
use jzon::JsonValue; use jzon::JsonValue;
use maplit::hashmap;
use serde::Serialize;
use crate::device::{BDAddr, FormattableDevice}; use crate::{
device::{BDAddr, FormattableDevice},
hass::{DiscoveryComponent, DiscoveryConfig, DiscoveryDevice, DiscoveryOrigin},
};
use std::{ use std::{
fmt::{self, Debug, Display}, fmt::{self, Debug, Display},
time::Instant, time::Instant,
@ -48,6 +53,67 @@ impl MiTempDevice {
self.dew_point = dew_point; self.dew_point = dew_point;
} }
} }
pub fn discovery_topic(&self) -> String {
format!(
"homeassistant/device/mitemp_{}/config",
self.addr.format_digits()
)
}
pub fn mqtt_device(&self) -> DiscoveryConfig {
let component_temp = DiscoveryComponent {
platform: "sensor",
device_class: "temperature",
unit_of_measurement: "°C",
value_template: "{{ value_json.temperature}}",
unique_id: format!("mitemp_{}_temperature", self.addr.format_digits()),
};
let component_humidity = DiscoveryComponent {
platform: "sensor",
device_class: "humidity",
unit_of_measurement: "%",
value_template: "{{ value_json.humidity}}",
unique_id: format!("mitemp_{}_humidity", self.addr.format_digits()),
};
DiscoveryConfig {
device: DiscoveryDevice {
identifiers: vec![format!("mitemp-{}", self.addr)],
name: self.name.clone(),
manufacturer: "Xiaomi",
model: "Mitemp",
},
origin: DiscoveryOrigin { name: "taspromto" },
components: hashmap! {
"temperature" => component_temp,
"humidity" => component_humidity,
},
state_topic: self.state_topic(),
qos: 2,
}
}
pub fn state_topic(&self) -> String {
format!(
"taspromto/sensor/mitemp_{}/state",
self.addr.format_digits()
)
}
pub fn mqtt_state(&self) -> String {
#[derive(Serialize)]
struct State {
temperature: f32,
humidity: f32,
}
serde_json::to_string(&State {
temperature: self.temperature,
humidity: self.humidity,
})
.unwrap()
}
} }
impl FormattableDevice for MiTempDevice { impl FormattableDevice for MiTempDevice {

View file

@ -20,6 +20,8 @@ use std::time::Instant;
pub use tasmota::DeviceState; pub use tasmota::DeviceState;
use tokio::task::spawn; use tokio::task::spawn;
use crate::hass::DiscoveryConfig;
#[derive(Default)] #[derive(Default)]
pub struct DeviceStates { pub struct DeviceStates {
pub mi_temp_names: BTreeMap<BDAddr, String>, pub mi_temp_names: BTreeMap<BDAddr, String>,
@ -32,6 +34,11 @@ pub struct DeviceStates {
active_rf_temp_id: RfDeviceId, active_rf_temp_id: RfDeviceId,
} }
pub enum HassUpdate {
Discovery(String, DiscoveryConfig),
Update(String, String),
}
pub trait FormattableDevice { pub trait FormattableDevice {
fn format_open_metrics(&self) -> impl Display + '_; fn format_open_metrics(&self) -> impl Display + '_;
@ -85,7 +92,7 @@ impl DeviceStates {
) )
} }
pub fn update(&mut self, device: Device, json: JsonValue) { pub fn update<Cb: Fn(HassUpdate)>(&mut self, device: Device, json: JsonValue, hass_cb: Cb) {
let device = self let device = self
.devices .devices
.entry(device) .entry(device)
@ -97,11 +104,16 @@ impl DeviceStates {
match BDAddr::from_mi_temp_mac_part(addr) { match BDAddr::from_mi_temp_mac_part(addr) {
Ok(addr) => { Ok(addr) => {
if let Some(name) = self.mi_temp_names.get(&addr) { if let Some(name) = self.mi_temp_names.get(&addr) {
let state = self let state = self.mi_temp_devices.entry(addr).or_insert_with(|| {
.mi_temp_devices let device = MiTempDevice::new(addr, name.clone());
.entry(addr) hass_cb(HassUpdate::Discovery(
.or_insert_with(|| MiTempDevice::new(addr, name.clone())); device.discovery_topic(),
device.mqtt_device(),
));
device
});
state.update(value); state.update(value);
hass_cb(HassUpdate::Update(state.state_topic(), state.mqtt_state()));
} }
} }
Err(e) => eprintln!("Failed to parse mitemp mac: {:#}", e), Err(e) => eprintln!("Failed to parse mitemp mac: {:#}", e),
@ -261,6 +273,28 @@ impl BDAddr {
address.reverse(); address.reverse();
Ok(BDAddr { address }) Ok(BDAddr { address })
} }
/// Format the address as only the digits, leaving out the semicolons
pub fn format_digits(&self) -> impl Display + use<'_> {
struct DigitFormatter<'a> {
address: &'a [u8; 6usize],
}
impl Display for DigitFormatter<'_> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let a = self.address;
write!(
f,
"{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
a[5], a[4], a[3], a[2], a[1], a[0]
)
}
}
DigitFormatter {
address: &self.address,
}
}
} }
impl Display for BDAddr { impl Display for BDAddr {

34
src/hass.rs Normal file
View file

@ -0,0 +1,34 @@
use std::collections::HashMap;
use serde::Serialize;
#[derive(Serialize)]
pub struct DiscoveryConfig {
pub device: DiscoveryDevice,
pub origin: DiscoveryOrigin,
pub components: HashMap<&'static str, DiscoveryComponent>,
pub state_topic: String,
pub qos: u8,
}
#[derive(Serialize)]
pub struct DiscoveryDevice {
pub identifiers: Vec<String>,
pub name: String,
pub manufacturer: &'static str,
pub model: &'static str,
}
#[derive(Serialize)]
pub struct DiscoveryOrigin {
pub name: &'static str,
}
#[derive(Serialize)]
pub struct DiscoveryComponent {
pub platform: &'static str,
pub device_class: &'static str,
pub unit_of_measurement: &'static str,
pub value_template: &'static str,
pub unique_id: String,
}

View file

@ -1,10 +1,11 @@
mod config; mod config;
mod device; mod device;
mod hass;
mod mqtt; mod mqtt;
mod topic; mod topic;
use crate::config::{Config, ListenConfig}; use crate::config::{Config, ListenConfig};
use crate::device::{Device, DeviceStates, FormattableDevice}; use crate::device::{Device, DeviceStates, FormattableDevice, HassUpdate};
use crate::mqtt::mqtt_stream; use crate::mqtt::mqtt_stream;
use crate::topic::Topic; use crate::topic::Topic;
use clap::Parser; use clap::Parser;
@ -125,11 +126,11 @@ async fn mqtt_client<S: Stream<Item = Result<Publish>>>(
) -> Result<()> { ) -> Result<()> {
while let Some(message) = stream.next().await { while let Some(message) = stream.next().await {
let message = message?; let message = message?;
println!( // println!(
"{} {}", // "{} {}",
message.topic, // message.topic,
std::str::from_utf8(message.payload.as_ref()).unwrap_or_default() // std::str::from_utf8(message.payload.as_ref()).unwrap_or_default()
); // );
let topic = Topic::from(message.topic.as_str()); let topic = Topic::from(message.topic.as_str());
match topic { match topic {
@ -150,10 +151,33 @@ async fn mqtt_client<S: Stream<Item = Result<Publish>>>(
} }
Topic::Power(_) => {} Topic::Power(_) => {}
Topic::Result(device) | Topic::Sensor(device) | Topic::Status(device) => { Topic::Result(device) | Topic::Sensor(device) | Topic::Status(device) => {
let send_client = client.clone();
let payload = std::str::from_utf8(message.payload.as_ref()).unwrap_or_default(); let payload = std::str::from_utf8(message.payload.as_ref()).unwrap_or_default();
if let Ok(json) = jzon::parse(payload) { if let Ok(json) = jzon::parse(payload) {
let mut device_states = device_states.lock().unwrap(); let mut device_states = device_states.lock().unwrap();
device_states.update(device, json); device_states.update(device, json, |update| {
let send_client = send_client.clone();
spawn(async move {
match update {
HassUpdate::Discovery(topic, payload) => {
send_client
.publish(
topic,
QoS::AtLeastOnce,
true,
serde_json::to_vec(&payload).unwrap(),
)
.await
}
HassUpdate::Update(topic, payload) => {
send_client
.publish(topic, QoS::AtLeastOnce, true, payload)
.await
}
}
});
});
} }
} }
Topic::Msg(_device) => { Topic::Msg(_device) => {