From 7dff97b4749e1b0ff162fb62cc19ea7a5a939b6b Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 29 Jun 2026 22:57:35 +0200 Subject: [PATCH] forward mitemp to home assistant --- Cargo.lock | 8 ++++++ Cargo.toml | 2 ++ README.md | 3 ++ scratch.json | 27 ++++++++++++++++++ src/device/mitemp.rs | 68 +++++++++++++++++++++++++++++++++++++++++++- src/device/mod.rs | 44 ++++++++++++++++++++++++---- src/hass.rs | 34 ++++++++++++++++++++++ src/main.rs | 38 ++++++++++++++++++++----- 8 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 scratch.json create mode 100644 src/hass.rs diff --git a/Cargo.lock b/Cargo.lock index 25a6d37..12abda4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index fa52913..b1e952d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index a2b107d..7d593a6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scratch.json b/scratch.json new file mode 100644 index 0000000..2d43031 --- /dev/null +++ b/scratch.json @@ -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 +} diff --git a/src/device/mitemp.rs b/src/device/mitemp.rs index 6ceb3ef..d5fcbe7 100644 --- a/src/device/mitemp.rs +++ b/src/device/mitemp.rs @@ -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 { diff --git a/src/device/mod.rs b/src/device/mod.rs index a8ac38a..1152e25 100644 --- a/src/device/mod.rs +++ b/src/device/mod.rs @@ -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, @@ -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(&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 { diff --git a/src/hass.rs b/src/hass.rs new file mode 100644 index 0000000..157a6e7 --- /dev/null +++ b/src/hass.rs @@ -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, + 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, +} diff --git a/src/main.rs b/src/main.rs index 326249a..6905a83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>>( ) -> 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>>( } 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) => {