diff --git a/Cargo.toml b/Cargo.toml index c3fa025..9d305ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ clap = { version = "4", features = ["derive"] } futures = "0.3" influxdb2 = { version = "0.5", default-features = false, features = ["rustls"] } rppal = "0.22" +tokio-modbus = { version = "0.17", default-features = false, features = ["rtu"] } +tokio-serial = "5.4" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index eb54d31..88ebfc8 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,25 @@ influx replication list \ --token "$TOWERD_INFLUX_TOKEN" ``` +## Renogy controller + +Need rs232 adapter to talk modbus to renogy controller + +```shell +# list adapters: ls -l /dev/serial/by-id/ +TOWERD_RENOGY_SERIAL=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_Bridge_... +``` + +```shell +# optional +TOWERD_RENOGY_SLAVE=255 +TOWERD_RENOGY_BAUD=9600 +TOWERD_RENOGY_INTERVAL_S=10 +TOWERD_RENOGY_TIMEOUT_MS=1000 +``` + +Thank you to [ESP32ArduinoRenogy](https://github.com/wrybread/ESP32ArduinoRenogy) for doing all the hard work! + ## systemd `towerd` loads `/etc/towerd/env` via `EnvironmentFile`. If using Docker for InfluxDB, ensure Docker starts before towerd: diff --git a/src/config.rs b/src/config.rs index 5c97411..e11cc24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,16 @@ pub struct Config { pub poll_interval_s: f64, pub thermal_alarm_temp_c: f64, pub influx: Option, + pub renogy: RenogyConfig, +} + +#[derive(Debug, Clone)] +pub struct RenogyConfig { + pub serial_path: Option, + pub slave_address: u8, + pub baud_rate: u32, + pub poll_interval_s: f64, + pub timeout_ms: u64, } #[derive(Debug, Clone)] @@ -29,6 +39,31 @@ impl Default for Config { poll_interval_s: 2.0, thermal_alarm_temp_c: 80.0, influx: InfluxConfig::from_env(), + renogy: RenogyConfig::from_env(), + } + } +} + +impl RenogyConfig { + pub fn from_env() -> Self { + Self { + serial_path: std::env::var("TOWERD_RENOGY_SERIAL").ok(), + slave_address: std::env::var("TOWERD_RENOGY_SLAVE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(crate::renogy::registers::SLAVE_ADDRESS_DEFAULT), + baud_rate: std::env::var("TOWERD_RENOGY_BAUD") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(crate::renogy::registers::BAUD_RATE), + poll_interval_s: std::env::var("TOWERD_RENOGY_INTERVAL_S") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(10.0), + timeout_ms: std::env::var("TOWERD_RENOGY_TIMEOUT_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1000), } } } diff --git a/src/daemon.rs b/src/daemon.rs index 3de7740..4dbc66e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -21,6 +21,9 @@ pub async fn run(config: Config) -> anyhow::Result<()> { info!("InfluxDB metrics disabled (set TOWERD_INFLUX_TOKEN, TOWERD_INFLUX_ORG, and TOWERD_INFLUX_BUCKET to enable)"); } + // Setup influx client + let influx = config.influx.clone(); + tokio::select! { _ = tokio::signal::ctrl_c() => { info!("Received shutdown signal"); @@ -32,13 +35,20 @@ pub async fn run(config: Config) -> anyhow::Result<()> { result?; } result = async { - match config.influx { + match influx { Some(influx) => tasks::metrics::run(influx, fan.clone(), alarms.clone()).await, None => std::future::pending().await, } } => { result?; } + result = tasks::renogy::run( + config.renogy.clone(), + config.influx.clone(), + alarms.clone(), + ) => { + result?; + } } fan.lock().await.stop(); diff --git a/src/main.rs b/src/main.rs index eabbc8b..b1f0719 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod alarm; mod config; mod daemon; mod gpio; +mod renogy; mod tasks; mod thermal; diff --git a/src/renogy/mod.rs b/src/renogy/mod.rs new file mode 100644 index 0000000..04ce9e3 --- /dev/null +++ b/src/renogy/mod.rs @@ -0,0 +1,57 @@ +pub mod registers; +mod serial; + +pub use registers::{ControllerData, ControllerInfo}; +pub use serial::resolve; + +use std::time::Duration; + +use anyhow::Context; +use tokio_modbus::prelude::*; +use tokio_serial::{DataBits, FlowControl, Parity, SerialStream, StopBits}; + +use crate::config::RenogyConfig; +use registers::{data, info}; + +pub struct Client { + ctx: tokio_modbus::client::Context, +} + +impl Client { + pub fn open(path: &str, config: &RenogyConfig) -> anyhow::Result { + let builder = tokio_serial::new(path, config.baud_rate) + .data_bits(DataBits::Eight) + .parity(Parity::None) + .stop_bits(StopBits::One) + .flow_control(FlowControl::None) + .timeout(Duration::from_millis(config.timeout_ms)); + + let port = SerialStream::open(&builder) + .with_context(|| format!("failed to open serial port {path}"))?; + let ctx = rtu::attach_slave(port, Slave(config.slave_address)); + + Ok(Self { ctx }) + } + + pub async fn read_data(&mut self) -> anyhow::Result { + let regs = self + .ctx + .read_holding_registers(data::BASE, data::COUNT) + .await? + .map_err(|code| anyhow::anyhow!("modbus exception reading data registers: {code:?}"))?; + ControllerData::parse(®s) + } + + pub async fn read_info(&mut self) -> anyhow::Result { + let regs = self + .ctx + .read_holding_registers(info::BASE, info::COUNT) + .await? + .map_err(|code| anyhow::anyhow!("modbus exception reading info registers: {code:?}"))?; + ControllerInfo::parse(®s) + } + + pub async fn verify(&mut self) -> anyhow::Result<()> { + self.read_data().await.map(|_| ()) + } +} diff --git a/src/renogy/registers.rs b/src/renogy/registers.rs new file mode 100644 index 0000000..ebe5e7b --- /dev/null +++ b/src/renogy/registers.rs @@ -0,0 +1,237 @@ +//! Renogy charge controller Modbus holding register map +//! +//! Based on [ESP32ArduinoRenogy](https://github.com/wrybread/ESP32ArduinoRenogy) + +pub const SLAVE_ADDRESS_DEFAULT: u8 = 255; +pub const BAUD_RATE: u32 = 9600; + +/// Live telemetry block (`readHoldingRegisters(0x0100, 35)`) +pub mod data { + pub const BASE: u16 = 0x0100; + pub const COUNT: u16 = 35; + + pub const BATTERY_SOC: u16 = 0; + pub const BATTERY_VOLTAGE: u16 = 1; + pub const BATTERY_CHARGING_AMPS: u16 = 2; + pub const TEMPERATURES: u16 = 3; + pub const LOAD_VOLTAGE: u16 = 4; + pub const LOAD_AMPS: u16 = 5; + pub const LOAD_WATTS: u16 = 6; + pub const SOLAR_VOLTAGE: u16 = 7; + pub const SOLAR_AMPS: u16 = 8; + pub const SOLAR_WATTS: u16 = 9; + #[allow(dead_code)] // I don't use this + pub const LOAD_SWITCH: u16 = 10; + pub const MIN_BATTERY_VOLTAGE_TODAY: u16 = 11; + pub const MAX_BATTERY_VOLTAGE_TODAY: u16 = 12; + pub const MAX_CHARGING_AMPS_TODAY: u16 = 13; + pub const MAX_DISCHARGING_AMPS_TODAY: u16 = 14; + pub const MAX_CHARGE_WATTS_TODAY: u16 = 15; + pub const MAX_DISCHARGE_WATTS_TODAY: u16 = 16; + pub const CHARGE_AMPHOURS_TODAY: u16 = 17; + pub const DISCHARGE_AMPHOURS_TODAY: u16 = 18; + pub const CHARGE_WATTHOURS_TODAY: u16 = 19; + pub const DISCHARGE_WATTHOURS_TODAY: u16 = 20; + pub const CONTROLLER_UPTIME_DAYS: u16 = 21; + pub const TOTAL_BATTERY_OVERCHARGES: u16 = 22; + pub const TOTAL_BATTERY_FULL_CHARGES: u16 = 23; + pub const TOTAL_CHARGE_AMPHOURS: u16 = 24; + pub const TOTAL_DISCHARGE_AMPHOURS: u16 = 26; + pub const TOTAL_GENERATION_KWH: u16 = 28; + pub const TOTAL_CONSUMPTION_KWH: u16 = 30; + pub const STATUS_WORD: u16 = 32; + pub const FAULT_CODES: u16 = 33; +} + +/// Static controller info (`readHoldingRegisters(0x000A, 17)`) +pub mod info { + pub const BASE: u16 = 0x000A; + pub const COUNT: u16 = 17; + + pub const RATINGS: u16 = 0; + pub const TYPE_AND_DISCHARGE: u16 = 1; + pub const MODEL: u16 = 2; + pub const MODEL_LEN: u16 = 8; + pub const SOFTWARE_VERSION: u16 = 10; + pub const HARDWARE_VERSION: u16 = 12; + pub const SERIAL_NUMBER: u16 = 14; + pub const MODBUS_ADDRESS: u16 = 16; +} + +#[derive(Debug, Clone)] +pub struct ControllerData { + pub battery_soc: u8, + pub battery_voltage: f64, + pub battery_charging_amps: f64, + pub battery_charging_watts: f64, + pub battery_temperature_c: u8, + pub controller_temperature_c: u8, + pub load_voltage: f64, + pub load_amps: f64, + pub load_watts: u8, + pub solar_voltage: f64, + pub solar_amps: f64, + pub solar_watts: u8, + pub min_battery_voltage_today: f64, + pub max_battery_voltage_today: f64, + pub max_charging_amps_today: f64, + pub max_discharging_amps_today: f64, + pub max_charge_watts_today: u8, + pub max_discharge_watts_today: u8, + pub charge_amphours_today: u8, + pub discharge_amphours_today: u8, + pub charge_watthours_today: u8, + pub discharge_watthours_today: u8, + pub controller_uptime_days: u8, + pub total_battery_overcharges: u8, + pub total_battery_full_charges: u8, + pub total_charge_amphours: u32, + pub total_discharge_amphours: u32, + pub total_generation_kwh: u32, + pub total_consumption_kwh: u32, + pub load_status: u8, + pub load_brightness: u8, + pub charging_state: u8, + pub fault_code: u32, +} + +#[derive(Debug, Clone)] +pub struct ControllerInfo { + pub voltage_rating_v: u8, + pub amp_rating_a: u8, + pub wattage_rating_w: u16, + pub discharge_amp_rating_a: u8, + pub controller_type: u8, + pub model: String, + pub software_version: String, + pub hardware_version: String, + pub serial_number: String, + pub modbus_address: u8, +} + +impl ControllerData { + pub fn parse(regs: &[u16]) -> anyhow::Result { + let r = |i: usize| -> anyhow::Result { + regs.get(i) + .copied() + .ok_or_else(|| anyhow::anyhow!("data register {i} missing")) + }; + + let battery_voltage = f64::from(r(data::BATTERY_VOLTAGE as usize)?) * 0.1; + let battery_charging_amps = f64::from(r(data::BATTERY_CHARGING_AMPS as usize)?) * 0.1; + + let temps = r(data::TEMPERATURES as usize)?; + let controller_temperature_c = (temps / 256) as u8; + let battery_temperature_c = (temps % 256) as u8; + + let status_word = r(data::STATUS_WORD as usize)?; + let fault_hi = r(data::FAULT_CODES as usize)?; + let fault_lo = r(data::FAULT_CODES as usize + 1)?; + + Ok(Self { + battery_soc: r(data::BATTERY_SOC as usize)? as u8, + battery_voltage, + battery_charging_amps, + battery_charging_watts: battery_voltage * battery_charging_amps, + battery_temperature_c, + controller_temperature_c, + load_voltage: f64::from(r(data::LOAD_VOLTAGE as usize)?) * 0.1, + load_amps: f64::from(r(data::LOAD_AMPS as usize)?) * 0.01, + load_watts: r(data::LOAD_WATTS as usize)? as u8, + solar_voltage: f64::from(r(data::SOLAR_VOLTAGE as usize)?) * 0.1, + solar_amps: f64::from(r(data::SOLAR_AMPS as usize)?) * 0.01, + solar_watts: r(data::SOLAR_WATTS as usize)? as u8, + min_battery_voltage_today: f64::from(r(data::MIN_BATTERY_VOLTAGE_TODAY as usize)?) + * 0.1, + max_battery_voltage_today: f64::from(r(data::MAX_BATTERY_VOLTAGE_TODAY as usize)?) + * 0.1, + max_charging_amps_today: f64::from(r(data::MAX_CHARGING_AMPS_TODAY as usize)?) * 0.01, + max_discharging_amps_today: f64::from(r(data::MAX_DISCHARGING_AMPS_TODAY as usize)?) + * 0.1, + max_charge_watts_today: r(data::MAX_CHARGE_WATTS_TODAY as usize)? as u8, + max_discharge_watts_today: r(data::MAX_DISCHARGE_WATTS_TODAY as usize)? as u8, + charge_amphours_today: r(data::CHARGE_AMPHOURS_TODAY as usize)? as u8, + discharge_amphours_today: r(data::DISCHARGE_AMPHOURS_TODAY as usize)? as u8, + charge_watthours_today: r(data::CHARGE_WATTHOURS_TODAY as usize)? as u8, + discharge_watthours_today: r(data::DISCHARGE_WATTHOURS_TODAY as usize)? as u8, + controller_uptime_days: r(data::CONTROLLER_UPTIME_DAYS as usize)? as u8, + total_battery_overcharges: r(data::TOTAL_BATTERY_OVERCHARGES as usize)? as u8, + total_battery_full_charges: r(data::TOTAL_BATTERY_FULL_CHARGES as usize)? as u8, + total_charge_amphours: word_pair( + r(data::TOTAL_CHARGE_AMPHOURS as usize)?, + r(data::TOTAL_CHARGE_AMPHOURS as usize + 1)?, + ), + total_discharge_amphours: word_pair( + r(data::TOTAL_DISCHARGE_AMPHOURS as usize)?, + r(data::TOTAL_DISCHARGE_AMPHOURS as usize + 1)?, + ), + total_generation_kwh: word_pair( + r(data::TOTAL_GENERATION_KWH as usize)?, + r(data::TOTAL_GENERATION_KWH as usize + 1)?, + ), + total_consumption_kwh: word_pair( + r(data::TOTAL_CONSUMPTION_KWH as usize)?, + r(data::TOTAL_CONSUMPTION_KWH as usize + 1)?, + ), + load_status: (status_word >> 8) as u8, + load_brightness: ((status_word >> 4) & 0x0F) as u8, + charging_state: (status_word & 0x0F) as u8, + fault_code: word_pair(fault_hi, fault_lo), + }) + } +} + +impl ControllerInfo { + pub fn parse(regs: &[u16]) -> anyhow::Result { + let r = |i: usize| -> anyhow::Result { + regs.get(i) + .copied() + .ok_or_else(|| anyhow::anyhow!("info register {i} missing")) + }; + + let ratings = r(info::RATINGS as usize)?; + let voltage_rating_v = (ratings / 256) as u8; + let amp_rating_a = (ratings % 256) as u8; + + let type_discharge = r(info::TYPE_AND_DISCHARGE as usize)?; + let model_start = info::MODEL as usize; + let model_end = model_start + info::MODEL_LEN as usize; + let model_regs: Vec = (model_start..model_end).map(r).collect::>()?; + + Ok(Self { + voltage_rating_v, + amp_rating_a, + wattage_rating_w: u16::from(voltage_rating_v) * u16::from(amp_rating_a), + discharge_amp_rating_a: (type_discharge / 256) as u8, + controller_type: (type_discharge % 256) as u8, + model: decode_text(&model_regs), + software_version: decode_version(r(info::SOFTWARE_VERSION as usize)?, r(info::SOFTWARE_VERSION as usize + 1)?), + hardware_version: decode_version(r(info::HARDWARE_VERSION as usize)?, r(info::HARDWARE_VERSION as usize + 1)?), + serial_number: decode_version(r(info::SERIAL_NUMBER as usize)?, r(info::SERIAL_NUMBER as usize + 1)?), + modbus_address: r(info::MODBUS_ADDRESS as usize)? as u8, + }) + } +} + +fn word_pair(hi: u16, lo: u16) -> u32 { + (u32::from(hi) << 16) | u32::from(lo) +} + +fn decode_text(regs: &[u16]) -> String { + let mut out = String::new(); + for ® in regs { + let hi = (reg >> 8) as u8; + let lo = (reg & 0xFF) as u8; + if hi.is_ascii_graphic() || hi == b' ' { + out.push(hi as char); + } + if lo.is_ascii_graphic() || lo == b' ' { + out.push(lo as char); + } + } + out.trim().to_string() +} + +fn decode_version(a: u16, b: u16) -> String { + format!("{a}.{b}") +} diff --git a/src/renogy/serial.rs b/src/renogy/serial.rs new file mode 100644 index 0000000..16d84c3 --- /dev/null +++ b/src/renogy/serial.rs @@ -0,0 +1,16 @@ +use std::path::Path; + +use tracing::{debug, warn}; + +/// Returns the configured serial path if the device node exists. +/// +/// Expects a path like `/dev/serial/by-id/usb-...`. +pub fn resolve(path: &str) -> Option { + if Path::new(path).exists() { + debug!(path, "using Renogy serial port"); + Some(path.to_string()) + } else { + warn!(path, "configured Renogy serial port not found"); + None + } +} diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index d76f779..5c2c59f 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -1,3 +1,4 @@ pub mod metrics; +pub mod renogy; pub mod status; pub mod thermal; diff --git a/src/tasks/renogy.rs b/src/tasks/renogy.rs new file mode 100644 index 0000000..52c5165 --- /dev/null +++ b/src/tasks/renogy.rs @@ -0,0 +1,197 @@ +use std::time::Duration; + +use futures::stream; +use influxdb2::models::DataPoint; +use influxdb2::Client as InfluxClient; +use tokio::time::MissedTickBehavior; +use tracing::{debug, info, warn}; + +use crate::alarm::SharedAlarms; +use crate::config::{InfluxConfig, RenogyConfig}; +use crate::renogy::{self, Client as RenogyClient, ControllerData, ControllerInfo}; + +const RECONNECT_INTERVAL_S: f64 = 15.0; + +pub async fn run( + config: RenogyConfig, + influx: Option, + alarms: SharedAlarms, +) -> anyhow::Result<()> { + let Some(serial_path) = config.serial_path.clone() else { + info!("Renogy disabled (set TOWERD_RENOGY_SERIAL to a /dev/serial/by-id path to enable)"); + std::future::pending::<()>().await; + unreachable!() + }; + + let mut reconnect = + tokio::time::interval(Duration::from_secs_f64(RECONNECT_INTERVAL_S)); + reconnect.set_missed_tick_behavior(MissedTickBehavior::Skip); + + info!( + path = %serial_path, + slave = config.slave_address, + baud = config.baud_rate, + interval_s = config.poll_interval_s, + "Renogy task started" + ); + + // Reconnect loop + loop { + let Some(port) = renogy::resolve(&serial_path) else { + alarms.set_fault("renogy", true).await; + reconnect.tick().await; + continue; + }; + + match connect_and_poll(&port, &config, influx.as_ref(), &alarms).await { + Ok(()) => return Ok(()), + Err(e) => { + alarms.set_fault("renogy", true).await; + warn!(error = %e, port = %port, "Renogy session ended, retrying"); + reconnect.tick().await; + } + } + } +} + +async fn connect_and_poll( + port: &str, + config: &RenogyConfig, + influx: Option<&InfluxConfig>, + alarms: &SharedAlarms, +) -> anyhow::Result<()> { + let port_owned = port.to_string(); + let open_config = config.clone(); + let poll_interval_s = config.poll_interval_s; + let mut client = + tokio::task::spawn_blocking(move || RenogyClient::open(&port_owned, &open_config)).await??; + + // Verify connection + client.verify().await?; + let info = client.read_info().await.ok(); + if let Some(ref info) = info { + info!( + port, + model = %info.model, + software = %info.software_version, + hardware = %info.hardware_version, + amps = info.amp_rating_a, + "Renogy controller connected" + ); + } else { + info!(port, "Renogy controller connected (info registers unavailable?)"); + } + + // Setup influx client + let influx_client = influx.map(|cfg| InfluxClient::new(&cfg.url, &cfg.org, &cfg.token)); + + // Setup poll interval + let mut interval = + tokio::time::interval(Duration::from_secs_f64(poll_interval_s)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Poll loop + loop { + // Wait for poll interval + interval.tick().await; + + // Read data + let data = client + .read_data() + .await + .map_err(|e| e.context("failed to read Renogy data registers"))?; + + // Clear fault + alarms.set_fault("renogy", false).await; + + // Debug log + debug!( + battery_v = data.battery_voltage, + battery_soc = data.battery_soc, + solar_w = data.solar_watts, + "Renogy poll ok" + ); + + // Publish data + if let (Some(client), Some(influx)) = (&influx_client, influx) { + if let Err(e) = publish(client, influx, &data, info.as_ref()).await { + warn!(error = %e, "Failed to publish Renogy metrics"); + alarms.set_fault("renogy_influx", true).await; + } else { + alarms.set_fault("renogy_influx", false).await; + } + } + } +} + +// Publish data to InfluxDB +async fn publish( + client: &InfluxClient, + influx: &InfluxConfig, + data: &ControllerData, + info: Option<&ControllerInfo>, +) -> anyhow::Result<()> { + let mut point = DataPoint::builder("renogy").tag("host", &influx.host_tag); + if let Some(info) = info { + if !info.model.is_empty() { + point = point.tag("model", &info.model); + } + } + + point = point + .field("battery_soc", i64::from(data.battery_soc)) + .field("battery_voltage", data.battery_voltage) + .field("battery_charging_amps", data.battery_charging_amps) + .field("battery_charging_watts", data.battery_charging_watts) + .field("battery_temperature_c", i64::from(data.battery_temperature_c)) + .field("controller_temperature_c", i64::from(data.controller_temperature_c)) + .field("load_voltage", data.load_voltage) + .field("load_amps", data.load_amps) + .field("load_watts", i64::from(data.load_watts)) + .field("solar_voltage", data.solar_voltage) + .field("solar_amps", data.solar_amps) + .field("solar_watts", i64::from(data.solar_watts)) + .field("min_battery_voltage_today", data.min_battery_voltage_today) + .field("max_battery_voltage_today", data.max_battery_voltage_today) + .field("max_charging_amps_today", data.max_charging_amps_today) + .field("max_discharging_amps_today", data.max_discharging_amps_today) + .field("max_charge_watts_today", i64::from(data.max_charge_watts_today)) + .field("max_discharge_watts_today", i64::from(data.max_discharge_watts_today)) + .field("charge_amphours_today", i64::from(data.charge_amphours_today)) + .field("discharge_amphours_today", i64::from(data.discharge_amphours_today)) + .field("charge_watthours_today", i64::from(data.charge_watthours_today)) + .field("discharge_watthours_today", i64::from(data.discharge_watthours_today)) + .field("controller_uptime_days", i64::from(data.controller_uptime_days)) + .field("total_battery_overcharges", i64::from(data.total_battery_overcharges)) + .field("total_battery_full_charges", i64::from(data.total_battery_full_charges)) + .field("total_charge_amphours", i64::from(data.total_charge_amphours)) + .field("total_discharge_amphours", i64::from(data.total_discharge_amphours)) + .field("total_generation_kwh", i64::from(data.total_generation_kwh)) + .field("total_consumption_kwh", i64::from(data.total_consumption_kwh)) + .field("load_status", i64::from(data.load_status)) + .field("load_brightness", i64::from(data.load_brightness)) + .field("charging_state", i64::from(data.charging_state)) + .field("fault_code", i64::from(data.fault_code)); + + if let Some(info) = info { + point = point + .field("voltage_rating_v", i64::from(info.voltage_rating_v)) + .field("amp_rating_a", i64::from(info.amp_rating_a)) + .field("wattage_rating_w", i64::from(info.wattage_rating_w)) + .field("discharge_amp_rating_a", i64::from(info.discharge_amp_rating_a)) + .field("controller_type", i64::from(info.controller_type)) + .field("modbus_address", i64::from(info.modbus_address)) + .field("software_version", info.software_version.clone()) + .field("hardware_version", info.hardware_version.clone()) + .field("serial_number", info.serial_number.clone()); + } + + let point = point.build()?; + + client + .write(&influx.bucket, stream::iter(vec![point])) + .await + .map_err(|e| anyhow::anyhow!("influxdb write failed: {e}"))?; + + Ok(()) +}