Add renogy bp
This commit is contained in:
@@ -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"] }
|
||||
|
||||
19
README.md
19
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:
|
||||
|
||||
@@ -7,6 +7,16 @@ pub struct Config {
|
||||
pub poll_interval_s: f64,
|
||||
pub thermal_alarm_temp_c: f64,
|
||||
pub influx: Option<InfluxConfig>,
|
||||
pub renogy: RenogyConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenogyConfig {
|
||||
pub serial_path: Option<String>,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,6 +2,7 @@ mod alarm;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod gpio;
|
||||
mod renogy;
|
||||
mod tasks;
|
||||
mod thermal;
|
||||
|
||||
|
||||
57
src/renogy/mod.rs
Normal file
57
src/renogy/mod.rs
Normal file
@@ -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<Self> {
|
||||
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<ControllerData> {
|
||||
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<ControllerInfo> {
|
||||
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(|_| ())
|
||||
}
|
||||
}
|
||||
237
src/renogy/registers.rs
Normal file
237
src/renogy/registers.rs
Normal file
@@ -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<Self> {
|
||||
let r = |i: usize| -> anyhow::Result<u16> {
|
||||
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<Self> {
|
||||
let r = |i: usize| -> anyhow::Result<u16> {
|
||||
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<u16> = (model_start..model_end).map(r).collect::<Result<_, _>>()?;
|
||||
|
||||
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}")
|
||||
}
|
||||
16
src/renogy/serial.rs
Normal file
16
src/renogy/serial.rs
Normal file
@@ -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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod metrics;
|
||||
pub mod renogy;
|
||||
pub mod status;
|
||||
pub mod thermal;
|
||||
|
||||
197
src/tasks/renogy.rs
Normal file
197
src/tasks/renogy.rs
Normal file
@@ -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<InfluxConfig>,
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user