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

View file

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

View file

@ -44,6 +44,9 @@ and the desired name.
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
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 maplit::hashmap;
use serde::Serialize;
use crate::device::{BDAddr, FormattableDevice};
use crate::{
device::{BDAddr, FormattableDevice},
hass::{DiscoveryComponent, DiscoveryConfig, DiscoveryDevice, DiscoveryOrigin},
};
use std::{
fmt::{self, Debug, Display},
time::Instant,
@ -48,6 +53,67 @@ impl MiTempDevice {
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 {

View file

@ -20,6 +20,8 @@ use std::time::Instant;
pub use tasmota::DeviceState;
use tokio::task::spawn;
use crate::hass::DiscoveryConfig;
#[derive(Default)]
pub struct DeviceStates {
pub mi_temp_names: BTreeMap<BDAddr, String>,
@ -32,6 +34,11 @@ pub struct DeviceStates {
active_rf_temp_id: RfDeviceId,
}
pub enum HassUpdate {
Discovery(String, DiscoveryConfig),
Update(String, String),
}
pub trait FormattableDevice {
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
.devices
.entry(device)
@ -97,11 +104,16 @@ impl DeviceStates {
match BDAddr::from_mi_temp_mac_part(addr) {
Ok(addr) => {
if let Some(name) = self.mi_temp_names.get(&addr) {
let state = self
.mi_temp_devices
.entry(addr)
.or_insert_with(|| MiTempDevice::new(addr, name.clone()));
let state = self.mi_temp_devices.entry(addr).or_insert_with(|| {
let device = MiTempDevice::new(addr, name.clone());
hass_cb(HassUpdate::Discovery(
device.discovery_topic(),
device.mqtt_device(),
));
device
});
state.update(value);
hass_cb(HassUpdate::Update(state.state_topic(), state.mqtt_state()));
}
}
Err(e) => eprintln!("Failed to parse mitemp mac: {:#}", e),
@ -261,6 +273,28 @@ impl BDAddr {
address.reverse();
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 {

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 device;
mod hass;
mod mqtt;
mod topic;
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::topic::Topic;
use clap::Parser;
@ -125,11 +126,11 @@ async fn mqtt_client<S: Stream<Item = Result<Publish>>>(
) -> Result<()> {
while let Some(message) = stream.next().await {
let message = message?;
println!(
"{} {}",
message.topic,
std::str::from_utf8(message.payload.as_ref()).unwrap_or_default()
);
// println!(
// "{} {}",
// message.topic,
// std::str::from_utf8(message.payload.as_ref()).unwrap_or_default()
// );
let topic = Topic::from(message.topic.as_str());
match topic {
@ -150,10 +151,33 @@ async fn mqtt_client<S: Stream<Item = Result<Publish>>>(
}
Topic::Power(_) => {}
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();
if let Ok(json) = jzon::parse(payload) {
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) => {