cleanup tasmota formatting

This commit is contained in:
Robin Appelman 2026-06-27 00:13:40 +02:00
commit 7955380859
3 changed files with 398 additions and 373 deletions

View file

@ -1,11 +1,13 @@
mod dsmr;
mod mitemp;
mod rf;
mod tasmota;
use color_eyre::{Report, Result, eyre::WrapErr};
pub use dsmr::{DsmrMessageType, DsmrState};
use jzon::JsonValue;
pub use mitemp::MiTempDevice;
use rf::parse_rf_payload;
pub use rf::{RfDeviceId, TempState};
use rumqttc::{AsyncClient, QoS};
use serde::de::Error;
@ -13,12 +15,11 @@ use serde::{Deserialize, Deserializer};
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::convert::TryFrom;
use std::fmt::{self, Debug, Display, Formatter, Write};
use std::fmt::{self, Debug, Display, Formatter};
use std::time::Instant;
pub use tasmota::DeviceState;
use tokio::task::spawn;
use crate::device::rf::parse_rf_payload;
#[derive(Default)]
pub struct DeviceStates {
pub mi_temp_names: BTreeMap<BDAddr, String>,
@ -52,7 +53,10 @@ impl DeviceStates {
}
pub fn update(&mut self, device: Device, json: JsonValue) {
let device = self.devices.entry(device).or_default();
let device = self
.devices
.entry(device)
.or_insert_with_key(|device| DeviceState::new(device.hostname.clone()));
for (key, value) in json.entries() {
if let Some(addr) = key.strip_prefix("MJ_HT_V1") {
@ -196,206 +200,6 @@ impl Device {
}
}
#[derive(Debug)]
pub struct DeviceState {
pub state: Option<bool>,
pub name: String,
pub power_watts: Option<f32>,
pub power_yesterday: Option<f32>,
pub power_today: Option<f32>,
pub power_total: Option<f32>,
pub power_total_low: Option<f32>,
pub power_total_high: Option<f32>,
pub gas_total: Option<f32>,
pub co2: Option<f32>,
pub pms_state: Option<PMSState>,
pub last_seen: Instant,
pub firmware: String,
pub version: f32,
}
impl Default for DeviceState {
fn default() -> Self {
DeviceState {
state: Default::default(),
name: Default::default(),
power_watts: Default::default(),
power_yesterday: Default::default(),
power_today: Default::default(),
power_total: Default::default(),
power_total_low: Default::default(),
power_total_high: Default::default(),
gas_total: Default::default(),
co2: Default::default(),
pms_state: Default::default(),
last_seen: Instant::now(),
firmware: Default::default(),
version: 0.0,
}
}
}
impl DeviceState {
pub fn update(&mut self, json: JsonValue) {
self.last_seen = Instant::now();
if json["DeviceName"].is_string() && !json["DeviceName"].is_empty() {
self.name = json["DeviceName"].to_string();
}
if json["POWER"].is_string() && !json["POWER"].is_empty() {
self.state = Some(json["POWER"] == "ON");
}
if let Some(power) = json["ENERGY"]["Power"].as_number().map(f32::from) {
self.power_watts = Some(power);
}
if let Some(yesterday) = json["ENERGY"]["Yesterday"].as_number().map(f32::from) {
self.power_yesterday = Some(yesterday);
}
if let Some(today) = json["ENERGY"]["Today"].as_number().map(f32::from) {
self.power_today = Some(today);
}
if let Some(co2) = json["MHZ19B"]["CarbonDioxide"].as_number().map(f32::from) {
if co2 > 1.0 {
self.co2 = Some(co2);
}
}
if let Some(power) = json["OBIS"]["Power"].as_number().map(f32::from) {
self.power_watts = Some(power);
}
if let Some(total) = json["OBIS"]["Total"].as_number().map(f32::from) {
self.power_total = Some(total);
}
if let Some(total) = json["OBIS"]["Total_high"].as_number().map(f32::from) {
self.power_total_high = Some(total);
}
if let Some(total) = json["OBIS"]["Total_low"].as_number().map(f32::from) {
self.power_total_low = Some(total);
}
if let Some(gas) = json["OBIS"]["Gas_total"].as_number().map(f32::from) {
self.gas_total = Some(gas);
}
if let Some(version) = json["StatusFWR"]["Version"].as_str() {
self.firmware = version.into();
if let Some(version) = version
.rfind('.')
.map(|index| &version[0..index])
.and_then(|s| s.parse().ok())
{
self.version = version
}
}
if json["PMS5003"].is_object() {
let pms = self.pms_state.get_or_insert(PMSState::default());
pms.update(&json["PMS5003"]);
}
}
}
pub fn format_device_state<W: Write>(
mut writer: W,
device: &Device,
state: &DeviceState,
) -> fmt::Result {
if state.name.is_empty() {
println!("{} has no name set, skipping", device.hostname);
return Ok(());
}
writeln!(
writer,
"tasmota_online{{tasmota_id=\"{}\", name=\"{}\"}} 1",
device.hostname, state.name
)?;
if let Some(switch_state) = state.state {
writeln!(
writer,
"switch_state{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname,
state.name,
if switch_state { 1 } else { 0 }
)?;
}
if let Some(power_watts) = state.power_watts {
writeln!(
writer,
"power_watts{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, power_watts
)?;
}
if let Some(power_yesterday) = state.power_yesterday {
writeln!(
writer,
"power_yesterday_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, power_yesterday
)?;
}
if let Some(power_today) = state.power_today {
writeln!(
writer,
"power_today_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, power_today
)?;
}
if let Some(power_total) = state.power_total {
writeln!(
writer,
"power_total_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, power_total
)?;
}
if let Some(power_total) = state.power_total_high {
writeln!(
writer,
"power_total_high_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, power_total
)?;
}
if let Some(power_total) = state.power_total_low {
writeln!(
writer,
"power_total_low_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, power_total
)?;
}
if let Some(gas_total) = state.gas_total {
writeln!(
writer,
"gas_total_m3{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, gas_total
)?;
}
if let Some(co2) = state.co2 {
writeln!(
writer,
"sensor_co2{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, state.name, co2
)?;
}
if let Some(pms) = state.pms_state.as_ref() {
format_pms_state(&mut writer, device, state, pms)?;
}
if !state.firmware.is_empty() {
writeln!(
writer,
r#"tasmota_version{{tasmota_id="{}", name="{}", firmware="{}", version="{}"}} 1"#,
device.hostname, state.name, state.firmware, state.version
)?;
}
Ok(())
}
/// Stores the 6 byte address used to identify Bluetooth devices.
#[derive(Copy, Clone, Hash, Eq, PartialEq, Default, Ord, PartialOrd)]
#[repr(C)]
@ -450,169 +254,3 @@ impl Debug for BDAddr {
(self as &dyn Display).fmt(f)
}
}
//"PMS5003":{"CF1":6,"CF2.5":8,"CF10":8,"PM1":6,"PM2.5":8,"PM10":8,"PB0.3":0,"PB0.5":0,"PB1":0,"PB2.5":0,"PB5":0,"PB10":0}
#[derive(Debug, Clone, Default)]
pub struct PMSState {
cf1: u16,
cf2_5: u16,
cf10: u16,
pm1: u16,
pm2_5: u16,
pm10: u16,
pb0_3: u16,
pb0_5: u16,
pb1: u16,
pb2_5: u16,
pb5: u16,
pb10: u16,
}
impl PMSState {
pub fn update(&mut self, json: &JsonValue) {
if let Some(val) = json["CF1"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.cf1 = val;
}
if let Some(val) = json["CF2.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.cf2_5 = val;
}
if let Some(val) = json["CF10"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.cf10 = val;
}
if let Some(val) = json["PM1"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pm1 = val;
}
if let Some(val) = json["PM2.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pm2_5 = val;
}
if let Some(val) = json["PM10"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pm10 = val;
}
if let Some(val) = json["PB0.3"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb0_3 = val;
}
if let Some(val) = json["PB0.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb0_5 = val;
}
if let Some(val) = json["PB1"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb1 = val;
}
if let Some(val) = json["PB2.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb2_5 = val;
}
if let Some(val) = json["PB5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb5 = val;
}
if let Some(val) = json["PB10"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb10 = val;
}
}
}
pub fn format_pms_state<W: Write>(
mut writer: W,
device: &Device,
device_state: &DeviceState,
state: &PMSState,
) -> fmt::Result {
let name = &device_state.name;
writeln!(
writer,
"cf1{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.cf1
)?;
writeln!(
writer,
"cf2_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.cf2_5
)?;
writeln!(
writer,
"cf10{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.cf10
)?;
writeln!(
writer,
"pm1{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pm1
)?;
writeln!(
writer,
"pm2_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pm2_5
)?;
writeln!(
writer,
"pm10{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pm10
)?;
writeln!(
writer,
"pb0_3{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pb0_3
)?;
writeln!(
writer,
"pb0_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pb0_5
)?;
writeln!(
writer,
"pb1{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pb1
)?;
writeln!(
writer,
"pb2_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pb2_5
)?;
writeln!(
writer,
"pb5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pb5
)?;
writeln!(
writer,
"pb10{{tasmota_id=\"{}\", name=\"{}\"}} {}",
device.hostname, name, state.pb10
)?;
Ok(())
}

386
src/device/tasmota.rs Normal file
View file

@ -0,0 +1,386 @@
use std::{
fmt::{self, Display},
time::Instant,
};
use jzon::JsonValue;
#[derive(Debug)]
pub struct DeviceState {
pub state: Option<bool>,
pub name: String,
pub hostname: String,
pub power_watts: Option<f32>,
pub power_yesterday: Option<f32>,
pub power_today: Option<f32>,
pub power_total: Option<f32>,
pub power_total_low: Option<f32>,
pub power_total_high: Option<f32>,
pub gas_total: Option<f32>,
pub co2: Option<f32>,
pub pms_state: Option<PMSState>,
pub last_seen: Instant,
pub firmware: String,
pub version: f32,
}
impl DeviceState {
pub fn new(hostname: String) -> Self {
DeviceState {
hostname,
state: Default::default(),
name: Default::default(),
power_watts: Default::default(),
power_yesterday: Default::default(),
power_today: Default::default(),
power_total: Default::default(),
power_total_low: Default::default(),
power_total_high: Default::default(),
gas_total: Default::default(),
co2: Default::default(),
pms_state: Default::default(),
last_seen: Instant::now(),
firmware: Default::default(),
version: 0.0,
}
}
pub fn update(&mut self, json: JsonValue) {
self.last_seen = Instant::now();
if json["DeviceName"].is_string() && !json["DeviceName"].is_empty() {
self.name = json["DeviceName"].to_string();
}
if json["POWER"].is_string() && !json["POWER"].is_empty() {
self.state = Some(json["POWER"] == "ON");
}
if let Some(power) = json["ENERGY"]["Power"].as_number().map(f32::from) {
self.power_watts = Some(power);
}
if let Some(yesterday) = json["ENERGY"]["Yesterday"].as_number().map(f32::from) {
self.power_yesterday = Some(yesterday);
}
if let Some(today) = json["ENERGY"]["Today"].as_number().map(f32::from) {
self.power_today = Some(today);
}
if let Some(co2) = json["MHZ19B"]["CarbonDioxide"].as_number().map(f32::from) {
if co2 > 1.0 {
self.co2 = Some(co2);
}
}
if let Some(power) = json["OBIS"]["Power"].as_number().map(f32::from) {
self.power_watts = Some(power);
}
if let Some(total) = json["OBIS"]["Total"].as_number().map(f32::from) {
self.power_total = Some(total);
}
if let Some(total) = json["OBIS"]["Total_high"].as_number().map(f32::from) {
self.power_total_high = Some(total);
}
if let Some(total) = json["OBIS"]["Total_low"].as_number().map(f32::from) {
self.power_total_low = Some(total);
}
if let Some(gas) = json["OBIS"]["Gas_total"].as_number().map(f32::from) {
self.gas_total = Some(gas);
}
if let Some(version) = json["StatusFWR"]["Version"].as_str() {
self.firmware = version.into();
if let Some(version) = version
.rfind('.')
.map(|index| &version[0..index])
.and_then(|s| s.parse().ok())
{
self.version = version
}
}
if json["PMS5003"].is_object() {
let pms = self.pms_state.get_or_insert(PMSState::default());
pms.update(&json["PMS5003"]);
}
}
pub fn format_open_metrics(&self) -> impl Display + '_ {
TasmotaFormatter { device: self }
}
}
struct TasmotaFormatter<'a> {
device: &'a DeviceState,
}
impl Display for TasmotaFormatter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.device.name.is_empty() {
return Ok(());
}
writeln!(
f,
"tasmota_online{{tasmota_id=\"{}\", name=\"{}\"}} 1",
self.device.hostname, self.device.name
)?;
if let Some(switch_state) = self.device.state {
writeln!(
f,
"switch_state{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname,
self.device.name,
if switch_state { 1 } else { 0 }
)?;
}
if let Some(power_watts) = self.device.power_watts {
writeln!(
f,
"power_watts{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, power_watts
)?;
}
if let Some(power_yesterday) = self.device.power_yesterday {
writeln!(
f,
"power_yesterday_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, power_yesterday
)?;
}
if let Some(power_today) = self.device.power_today {
writeln!(
f,
"power_today_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, power_today
)?;
}
if let Some(power_total) = self.device.power_total {
writeln!(
f,
"power_total_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, power_total
)?;
}
if let Some(power_total) = self.device.power_total_high {
writeln!(
f,
"power_total_high_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, power_total
)?;
}
if let Some(power_total) = self.device.power_total_low {
writeln!(
f,
"power_total_low_kwh{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, power_total
)?;
}
if let Some(gas_total) = self.device.gas_total {
writeln!(
f,
"gas_total_m3{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, gas_total
)?;
}
if let Some(co2) = self.device.co2 {
writeln!(
f,
"sensor_co2{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, self.device.name, co2
)?;
}
if let Some(pms) = self.device.pms_state.as_ref() {
write!(
f,
"{}",
PmsFormatter {
device: self.device,
pms
}
)?;
}
if !self.device.firmware.is_empty() {
writeln!(
f,
r#"tasmota_version{{tasmota_id="{}", name="{}", firmware="{}", version="{}"}} 1"#,
self.device.hostname, self.device.name, self.device.firmware, self.device.version
)?;
}
Ok(())
}
}
//"PMS5003":{"CF1":6,"CF2.5":8,"CF10":8,"PM1":6,"PM2.5":8,"PM10":8,"PB0.3":0,"PB0.5":0,"PB1":0,"PB2.5":0,"PB5":0,"PB10":0}
#[derive(Debug, Clone, Default)]
pub struct PMSState {
cf1: u16,
cf2_5: u16,
cf10: u16,
pm1: u16,
pm2_5: u16,
pm10: u16,
pb0_3: u16,
pb0_5: u16,
pb1: u16,
pb2_5: u16,
pb5: u16,
pb10: u16,
}
impl PMSState {
pub fn update(&mut self, json: &JsonValue) {
if let Some(val) = json["CF1"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.cf1 = val;
}
if let Some(val) = json["CF2.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.cf2_5 = val;
}
if let Some(val) = json["CF10"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.cf10 = val;
}
if let Some(val) = json["PM1"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pm1 = val;
}
if let Some(val) = json["PM2.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pm2_5 = val;
}
if let Some(val) = json["PM10"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pm10 = val;
}
if let Some(val) = json["PB0.3"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb0_3 = val;
}
if let Some(val) = json["PB0.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb0_5 = val;
}
if let Some(val) = json["PB1"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb1 = val;
}
if let Some(val) = json["PB2.5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb2_5 = val;
}
if let Some(val) = json["PB5"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb5 = val;
}
if let Some(val) = json["PB10"]
.as_number()
.and_then(|num| u16::try_from(num).ok())
{
self.pb10 = val;
}
}
}
struct PmsFormatter<'a> {
pms: &'a PMSState,
device: &'a DeviceState,
}
impl Display for PmsFormatter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = &self.device.name;
writeln!(
f,
"cf1{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.cf1
)?;
writeln!(
f,
"cf2_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.cf2_5
)?;
writeln!(
f,
"cf10{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.cf10
)?;
writeln!(
f,
"pm1{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pm1
)?;
writeln!(
f,
"pm2_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pm2_5
)?;
writeln!(
f,
"pm10{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pm10
)?;
writeln!(
f,
"pb0_3{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pb0_3
)?;
writeln!(
f,
"pb0_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pb0_5
)?;
writeln!(
f,
"pb1{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pb1
)?;
writeln!(
f,
"pb2_5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pb2_5
)?;
writeln!(
f,
"pb5{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pb5
)?;
writeln!(
f,
"pb10{{tasmota_id=\"{}\", name=\"{}\"}} {}",
self.device.hostname, name, self.pms.pb10
)?;
Ok(())
}
}

View file

@ -4,7 +4,7 @@ mod mqtt;
mod topic;
use crate::config::{Config, ListenConfig};
use crate::device::{Device, DeviceStates, format_device_state};
use crate::device::{Device, DeviceStates};
use crate::mqtt::mqtt_stream;
use crate::topic::Topic;
use clap::Parser;
@ -78,8 +78,9 @@ async fn serve(device_states: Arc<Mutex<DeviceStates>>, listen: ListenConfig) {
.map(move |state: Arc<Mutex<DeviceStates>>| {
let state = state.lock().unwrap();
let mut response = String::new();
for (device, state) in state.devices() {
format_device_state(&mut response, device, state).unwrap();
for (_, state) in state.devices() {
write!(&mut response, "{}", state.format_open_metrics())
.expect("formatting failed");
}
for (_, state) in state.dsmr_devices() {
write!(&mut response, "{}", state.format_open_metrics())