Skip to content

Commit

Permalink
Feature/ha discovery (#27)
Browse files Browse the repository at this point in the history
Co-authored-by: Dominik Andreas <admin@localhost>
Co-authored-by: Dennis Luxen <ich@dennisluxen.de>
  • Loading branch information
3 people authored Nov 24, 2023
1 parent b402a0a commit 0685bac
Show file tree
Hide file tree
Showing 10 changed files with 297 additions and 51 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ Cargo.lock
*.pdb

# Ignore VS Code user settings
.vscode/
.vscode/
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 4 additions & 3 deletions ha-hoymiles-wifi-addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand All @@ -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
11 changes: 10 additions & 1 deletion ha-hoymiles-wifi-addon/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/inverter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ impl<'a> Inverter<'a> {
info!("Inverter is {new_state:?}");
}
}

pub fn update_state(&mut self) -> Option<HMSStateResponse> {
self.sequence = self.sequence.wrapping_add(1);

Expand Down
21 changes: 21 additions & 0 deletions src/logging.rs
Original file line number Diff line number Diff line change
@@ -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();
}
22 changes: 5 additions & 17 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
Expand Down
162 changes: 136 additions & 26 deletions src/mqtt.rs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -13,14 +16,16 @@ pub struct Mqtt {
client: Client,
}


impl Mqtt {
pub fn new(
host: &str,
username: &Option<String>,
password: &Option<String>,
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
Expand All @@ -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<SensorConfig>) {
// 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<SensorConfig> {
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
}

}
Loading

0 comments on commit 0685bac

Please sign in to comment.