diff --git a/.gitignore b/.gitignore index ed3bd95e..3cfc39b0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ Cargo.lock *.pdb # Ignore VS Code user settings -.vscode/ \ No newline at end of file +.vscode/ diff --git a/Cargo.toml b/Cargo.toml index 81a8d4fb..c3c19344 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ env_logger = "0.10.1" log = "0.4.20" protobuf = "3.3.0" rumqttc = "0.23.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +hostname = "0.3.1" [build-dependencies] protobuf-codegen = "3.3.0" diff --git a/ha-hoymiles-wifi-addon/config.yaml b/ha-hoymiles-wifi-addon/config.yaml index 9a488946..e8791b02 100644 --- a/ha-hoymiles-wifi-addon/config.yaml +++ b/ha-hoymiles-wifi-addon/config.yaml @@ -11,6 +11,7 @@ arch: # List of supported architectures. - amd64 startup: application # When the add-on should start. boot: auto # Boot options, auto or manual. +services: ['mqtt:want'] options: # Default options value for the add-on. inverter_host: '' mqtt_broker_host: '' @@ -19,7 +20,7 @@ options: # Default options value for the add-on. mqtt_port: 1883 schema: # Schema validation for the options. inverter_host: str - mqtt_broker_host: str - mqtt_username: str - mqtt_password: str + mqtt_broker_host: str? + mqtt_username: str? + mqtt_password: str? mqtt_port: int \ No newline at end of file diff --git a/ha-hoymiles-wifi-addon/run.sh b/ha-hoymiles-wifi-addon/run.sh index 49ef8c6b..96b144e3 100644 --- a/ha-hoymiles-wifi-addon/run.sh +++ b/ha-hoymiles-wifi-addon/run.sh @@ -2,14 +2,23 @@ # Enable strict mode for bash (exit on error, error on undefined variable, error if any pipeline element fails) set -euo pipefail - # Fetch values from the add-on configuration by extracting it from /data/options.json + +HA_MQTT_BROKER_HOST=$(bashio::services mqtt "host") +HA_MQTT_USERNAME=$(bashio::services mqtt "username") +HA_MQTT_PASSWORD=$(bashio::services mqtt "password") + INVERTER_HOST=$(bashio::config 'inverter_host') MQTT_BROKER_HOST=$(bashio::config 'mqtt_broker_host') MQTT_USERNAME=$(bashio::config 'mqtt_username') MQTT_PASSWORD=$(bashio::config 'mqtt_password') MQTT_PORT=$(bashio::config 'mqtt_port') +# Use bashio::config values if they are defined, otherwise fall back to bashio::services values +MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-$HA_MQTT_BROKER_HOST} +MQTT_USERNAME=${MQTT_USERNAME:-$HA_MQTT_USERNAME} +MQTT_PASSWORD=${MQTT_PASSWORD:-$HA_MQTT_PASSWORD} + # Check if the required configs are provided if [[ -z "$INVERTER_HOST" ]]; then diff --git a/src/inverter.rs b/src/inverter.rs index d7ecc286..fe3cb055 100644 --- a/src/inverter.rs +++ b/src/inverter.rs @@ -38,7 +38,7 @@ impl<'a> Inverter<'a> { info!("Inverter is {new_state:?}"); } } - + pub fn update_state(&mut self) -> Option { self.sequence = self.sequence.wrapping_add(1); diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..017509bb --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,21 @@ +use std::io::Write; + +use chrono::Local; +use env_logger::Builder; +use log::LevelFilter; + + +pub(crate) fn init_logger() { + Builder::new() + .format(|buf, record| { + writeln!( + buf, + "{} [{}] - {}", + Local::now().format("%Y-%m-%dT%H:%M:%S"), + record.level(), + record.args() + ) + }) + .filter(None, LevelFilter::Info) + .init(); +} diff --git a/src/main.rs b/src/main.rs index a2a470da..65a3050a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,18 +4,18 @@ mod inverter; mod mqtt; mod protos; +mod logging; +mod mqtt_schemas; use crate::inverter::Inverter; use crate::mqtt::{MetricCollector, Mqtt}; +use crate::logging::init_logger; -use std::io::Write; use std::thread; use std::time::Duration; -use chrono::Local; use clap::Parser; -use env_logger::Builder; -use log::{info, LevelFilter}; +use log::info; use protos::hoymiles::RealData; #[derive(Parser)] @@ -31,19 +31,7 @@ struct Cli { static REQUEST_DELAY: u64 = 30_500; fn main() { - Builder::new() - .format(|buf, record| { - writeln!( - buf, - "{} [{}] - {}", - Local::now().format("%Y-%m-%dT%H:%M:%S"), - record.level(), - record.args() - ) - }) - .filter(None, LevelFilter::Info) - .init(); - + init_logger(); let cli = Cli::parse(); // set up mqtt connection diff --git a/src/mqtt.rs b/src/mqtt.rs index c10bf20d..683721b2 100644 --- a/src/mqtt.rs +++ b/src/mqtt.rs @@ -1,9 +1,12 @@ use std::{thread, time::Duration}; use crate::protos::hoymiles::RealData::HMSStateResponse; +use crate::mqtt_schemas::DeviceConfig; -use log::{debug, warn}; use rumqttc::{Client, MqttOptions, QoS}; +use serde_json::json; +use crate::mqtt_schemas::SensorConfig; +use log::{error, debug}; pub trait MetricCollector { fn publish(&mut self, hms_state: &HMSStateResponse); @@ -13,6 +16,7 @@ pub struct Mqtt { client: Client, } + impl Mqtt { pub fn new( host: &str, @@ -20,7 +24,8 @@ impl Mqtt { password: &Option, port: u16, ) -> Self { - let mut mqttoptions = MqttOptions::new("hms800wt2-mqtt-publisher", host, port); + let this_host = hostname::get().unwrap().into_string().unwrap(); + let mut mqttoptions = MqttOptions::new(format!("hms-mqtt-publisher_{}", this_host), host, port); mqttoptions.set_keep_alive(Duration::from_secs(5)); //parse the mqtt authentication options @@ -42,35 +47,140 @@ impl Mqtt { Self { client } } + + fn publish_json(&mut self, topic: &str, payload: serde_json::Value) { + debug!("Publishing to {topic} with payload {payload}"); + + let payload = serde_json::to_string(&payload).unwrap(); + if let Err(e) = self.client.publish(topic, QoS::AtMostOnce, true, payload) { + error!("Failed to publish message: {e}"); + } + } + + fn publish_configs(&mut self, config_topic: &str, sensor_configs: &Vec) { + // configs let home assistant know what sensors are available and where to find them + for sensor_config in sensor_configs { + let config_topic = format!("{}/{}/config", config_topic, sensor_config.unique_id); + let config_payload = serde_json::to_value(&sensor_config).unwrap(); + self.publish_json(&config_topic, config_payload); + } + } + + fn publish_states(&mut self, hms_state: &HMSStateResponse, state_topic: &str) { + // states contain the actual data + let json_payload = hms_state.to_json_payload(); + self.publish_json(state_topic, json_payload); + } } + impl MetricCollector for Mqtt { fn publish(&mut self, hms_state: &HMSStateResponse) { - debug!("{hms_state}"); - - let pv_current_power = hms_state.pv_current_power as f32 / 10.; - let pv_daily_yield = hms_state.pv_daily_yield; - - self.client - .subscribe("hms800wt2/pv_current_power", QoS::AtMostOnce) - .unwrap(); - match self.client.publish( - "hms800wt2/pv_current_power", - QoS::AtMostOnce, - true, - pv_current_power.to_string(), - ) { - Ok(_) => {} - Err(e) => warn!("mqtt error: {e}"), + let config_topic = format!("homeassistant/sensor/hms_{}", hms_state.short_dtu_sn()); + let state_topic = format!("solar/hms_{}/state", hms_state.short_dtu_sn()); + + let device_config = hms_state.create_sensor_configs(&state_topic); + + self.publish_configs(&config_topic, &device_config); + self.publish_states(hms_state, &state_topic); + } +} + + +/// `HMSStateResponse` is a struct that contains the data from the inverter. +/// +/// Provide utility functions to extract data from the struct. +impl HMSStateResponse { + fn get_model(&self) -> String { + // TODO: figure out a way to properly identify the model + format!("HMS-WiFi") + } + + fn get_name(&self) -> String { + format!("Hoymiles {} {}", self.get_model(), self.short_dtu_sn()) + } + + fn short_dtu_sn(&self) -> String { + self.dtu_sn[..8].to_string() + } + + fn get_total_efficiency(&self) -> f32{ + let total_module_power: f32 = self.port_state.iter().map( + |port| port.pv_power as f32 + ).sum(); + if total_module_power > 0.0 { + self.pv_current_power as f32 / total_module_power * 100.0 + } else { + 0.0 + } + } + + fn to_json_payload(&self) -> serde_json::Value { + // when modifying this function, modify the sensor config in create_device_config accordingly + let mut json = json!({ + "dtu_sn": self.dtu_sn, + "pv_current_power": format!("{:.2}", self.pv_current_power as f32 * 0.1), + "pv_daily_yield": self.pv_daily_yield, + "efficiency": format!("{:.2}", self.get_total_efficiency()) + }); + + // Convert each PortState to json + for port in self.port_state.iter() { + json[format!("pv_{}_vol", port.pv_port)] = format!("{:.2}", port.pv_vol as f32 * 0.1).into(); + json[format!("pv_{}_cur", port.pv_port)] = format!("{:.2}", port.pv_cur as f32 * 0.1).into(); + json[format!("pv_{}_power", port.pv_port)] = format!("{:.2}", port.pv_power as f32 * 0.1).into(); + json[format!("pv_{}_energy_total", port.pv_port)] = port.pv_energy_total.into(); + json[format!("pv_{}_daily_yield", port.pv_port)] = port.pv_daily_yield.into(); } - match self.client.publish( - "hms800wt2/pv_daily_yield", - QoS::AtMostOnce, - true, - pv_daily_yield.to_string(), - ) { - Ok(_) => {} - Err(e) => warn!("mqtt error: {e}"), + // Convert each InverterState to json (for a HMS-XXXW-2T, there is only one inverter) + for inverter in self.inverter_state.iter() { + json[format!("inv_{}_grid_voltage", inverter.port_id)] = format!("{:.2}", inverter.grid_voltage as f32 * 0.1).into(); + json[format!("inv_{}_grid_freq", inverter.port_id)] = format!("{:.2}", inverter.grid_freq as f32 * 0.01).into(); + json[format!("inv_{}_pv_current_power", inverter.port_id)] = format!("{:.2}", inverter.pv_current_power as f32 * 0.1).into(); + json[format!("inv_{}_temperature", inverter.port_id)] = format!("{:.2}", inverter.temperature as f32 * 0.1).into(); } + + json } + + fn create_sensor_configs(&self, state_topic: &str) -> Vec { + let mut sensors = Vec::new(); + + let device_config = DeviceConfig::new( + self.get_name(), + self.get_model(), + Vec::from([format!("hms_{}", self.short_dtu_sn())]), + ); + + // Sensors for the whole inverter + sensors.extend([ + SensorConfig::string(state_topic, &device_config, "DTU Serial Number", "dtu_sn"), + SensorConfig::power(state_topic, &device_config, "Total Power", "pv_current_power"), + SensorConfig::energy(state_topic, &device_config, "Total Daily Yield", "pv_daily_yield"), + SensorConfig::efficiency(state_topic, &device_config, "Efficiency", "efficiency") + ]); + + // Sensors for each pv string + for port in &self.port_state { + let idx = port.pv_port; + sensors.extend([ + SensorConfig::power(state_topic, &device_config, &format!("PV {} Power", idx), &format!("pv_{}_power", idx)), + SensorConfig::voltage(state_topic, &device_config, &format!("PV {} Voltage", idx), &format!("pv_{}_vol", idx)), + SensorConfig::current(state_topic, &device_config, &format!("PV {} Current", idx), &format!("pv_{}_cur", idx)), + SensorConfig::energy(state_topic, &device_config, &format!("PV {} Daily Yield", idx), &format!("pv_{}_daily_yield", idx)), + SensorConfig::energy(state_topic, &device_config, &format!("PV {} Energy Total", idx), &format!("pv_{}_energy_total", idx)), + ]); + } + for inverter in &self.inverter_state { + let idx = inverter.port_id; + sensors.extend([ + SensorConfig::power(state_topic, &device_config, &format!("Inverter {} Power", idx), &format!("inv_{}_pv_current_power", idx)), + SensorConfig::temperature(state_topic, &device_config, &format!("Inverter {} Temperature", idx), &format!("inv_{}_temperature", idx)), + SensorConfig::voltage(state_topic, &device_config, &format!("Inverter {} Grid Voltage", idx), &format!("inv_{}_grid_voltage", idx)), + SensorConfig::frequency(state_topic, &device_config, &format!("Inverter {} Grid Frequency", idx), &format!("inv_{}_grid_freq", idx)), + ]); + } + sensors + } + } diff --git a/src/mqtt_schemas.rs b/src/mqtt_schemas.rs new file mode 100644 index 00000000..de4fc6e9 --- /dev/null +++ b/src/mqtt_schemas.rs @@ -0,0 +1,114 @@ + +use serde::Serialize; + + +/// `DeviceConfig` is used to define the configuration for a Home Assistant device +/// in the MQTT discovery protocol and is used to group entities together. +/// +#[derive(Serialize, Clone)] +pub struct DeviceConfig { + name: String, + model: String, + identifiers: Vec, + manufacturer: String, + sw_version: String, // Software version of the application that supplies the discovered MQTT item. +} + + +impl DeviceConfig { + pub fn new(name: String, model: String, identifiers: Vec) -> Self { + Self { + name, + model, + identifiers, + manufacturer: "Hoymiles".to_string(), + // Rust compiler sets the CARGO_PKG_VERSION environment from the Cargo.toml . + sw_version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} + + +/// `SensorConfig` is used to define the configuration for a Home Assistant sensor entity +/// in the MQTT discovery protocol. +/// +/// More information about the MQTT discovery protocol can be found here: +/// https://www.home-assistant.io/docs/mqtt/discovery/ +/// +/// More information about the Home assistant sensor entities can be found here: +/// https://developers.home-assistant.io/docs/core/entity/sensor/ +/// +#[derive(Serialize)] +pub struct SensorConfig { + pub unique_id: String, // A globally unique identifier for the sensor. + name: String, // The name of the sensor. + state_topic: String, // The MQTT topic where sensor readings will be published. + value_template: String, // A template to extract a value from the mqtt message. + device: DeviceConfig, // The device that the sensor belongs to, used to group entities together. + // exclude optional if they are not provided + #[serde(skip_serializing_if = "Option::is_none")] + unit_of_measurement: Option, // The unit of measurement of the sensor. + #[serde(skip_serializing_if = "Option::is_none")] + device_class: Option, // The type/class of the sensor, e.g. energy, power, temperature, etc. +} + + +impl SensorConfig { + pub fn new_sensor( + state_topic: &str, device_config: &DeviceConfig, unique_id: &str, name: &str, + device_class: Option, unit_of_measurement: Option + ) -> Self { + let value_template = format!("{{{{ value_json.{} }}}}", unique_id); + let unique_id = format!("{}_{}", device_config.identifiers[0], unique_id); + SensorConfig { + unique_id, + name: name.to_string(), + state_topic: state_topic.to_string(), + unit_of_measurement, + device_class, + value_template, + device: device_config.clone() + } + } + + pub fn string(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + None, None) + } + + pub fn power(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + Some("power".to_string()), Some("W".to_string())) + } + + pub fn energy(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + Some("energy".to_string()), Some("Wh".to_string())) + } + + pub fn voltage(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + Some("voltage".to_string()), Some("V".to_string())) + } + + pub fn current(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + Some("current".to_string()), Some("A".to_string())) + } + + pub fn temperature(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + Some("temperature".to_string()), Some("°C".to_string())) + } + + pub fn efficiency(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + None, Some("%".to_string())) + } + + pub fn frequency(state_topic: &str, device_config: &DeviceConfig, name: &str, key: &str) -> Self { + Self::new_sensor(state_topic, &device_config, key, name, + Some("frequency".to_string()), Some("Hz".to_string())) + } + +} diff --git a/src/protos/RealData.proto b/src/protos/RealData.proto index 7f0448e4..23b6ccdc 100644 --- a/src/protos/RealData.proto +++ b/src/protos/RealData.proto @@ -12,7 +12,7 @@ message InverterState { int64 inv_id = 1; int32 port_id = 2; int32 grid_voltage = 3; // [V], factor 0.1 - int32 grid_freq = 4; // [Hz], factor 0.1 + int32 grid_freq = 4; // [Hz], factor 0.01 int32 pv_current_power = 5; // [W], factor 0.1 int32 unknown1 = 7; int32 unknown2 = 8; // power limit? [%], factor 0.1 @@ -34,7 +34,7 @@ message PortState { } message HMSStateResponse { - bytes dtu_sn = 1; // serial + string dtu_sn = 1; // serial int32 time = 2; // epoch int32 device_nub = 3; int32 pv_nub = 4; // repeats cp field from request