Compare commits

..

4 Commits

Author SHA1 Message Date
KenwoodFox
bbe5fd5452 Add enhanced fan logic 2026-06-18 14:09:14 -04:00
KenwoodFox
49618f0c0a Add renogy bp 2026-06-15 15:06:21 -04:00
KenwoodFox
ee02bf99cf Influxdb docker replication 2026-06-15 12:13:24 -04:00
KenwoodFox
f86a448fe1 include these bits 2026-06-15 11:29:17 -04:00
13 changed files with 752 additions and 41 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target/
Cargo.lock
.env

View File

@@ -10,6 +10,18 @@ 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"] }
# Had to pin stack for rpi
idna_adapter = "=1.2.1"
icu_collections = "=2.1.1"
icu_locale_core = "=2.1.1"
icu_normalizer = "=2.1.1"
icu_normalizer_data = "=2.1.1"
icu_properties = "=2.1.2"
icu_properties_data = "=2.1.2"
icu_provider = "=2.1.1"

125
README.md
View File

@@ -24,3 +24,128 @@ sudo systemctl enable --now towerd
sudo systemctl status towerd
sudo journalctl -u towerd -f
```
## Metrics
Originally I was going to write to my influxdb server over the network but I learned that influxdb can be configured
to save locally and drain when network is up, which would be pretty awesome for edge computing like the tower. So,
new architecture is to run locally and configure it to replicate.
### Local Config
Create `/opt/towerd/.env`
```shell
INFLUXDB_INIT_PASSWORD=change-me
INFLUXDB_INIT_ADMIN_TOKEN=change-me-to-a-long-random-token
INFLUXDB_INIT_ORG=tower
INFLUXDB_INIT_BUCKET=tower
```
then
```shell
cd /opt/towerd
docker compose up -d
```
Init vars only matter once
InfluxDB listens on `127.0.0.1:8086` only.
### Configure towerd
Create `/etc/towerd/env`:
```shell
TOWERD_INFLUX_URL=http://127.0.0.1:8086
TOWERD_INFLUX_ORG=tower
TOWERD_INFLUX_BUCKET=tower
TOWERD_INFLUX_TOKEN=<same as INFLUXDB_INIT_ADMIN_TOKEN>
TOWERD_INFLUX_HOST=kw1fox-1
# optional
# TOWERD_INFLUX_INTERVAL_S=30
```
Restart towerd after editing:
```shell
sudo systemctl restart towerd
```
### Replicate to remote InfluxDB
Need influx cli
```shell
# register the remote server
influx remote create \
--host http://127.0.0.1:8086 \
--token "$TOWERD_INFLUX_TOKEN" \
--name kitsune-remote \
--remote-url http://influx.kitsunehosting.net:8086 \
--remote-api-token <remote-write-token> \
--remote-org-id <remote-org-id>
# list remotes to get the ID from the previous step
influx remote list \
--host http://127.0.0.1:8086 \
--token "$TOWERD_INFLUX_TOKEN"
# replicate the local bucket to the remote bucket
influx replication create \
--host http://127.0.0.1:8086 \
--token "$TOWERD_INFLUX_TOKEN" \
--name tower-to-kitsune \
--remote-id <remote-id> \
--local-bucket-id <local-bucket-id> \
--remote-bucket <remote-bucket-name>
```
Find the local bucket ID:
```shell
influx bucket list \
--host http://127.0.0.1:8086 \
--token "$TOWERD_INFLUX_TOKEN"
```
Check replication queue health:
```shell
influx replication list \
--host http://127.0.0.1:8086 \
--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:
```ini
[Unit]
After=docker.service
Wants=docker.service
```
Add those lines to `/etc/systemd/system/towerd.service` under `[Unit]`.

View File

@@ -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)]
@@ -24,11 +34,36 @@ impl Default for Config {
Self {
status_pin: 4,
fan_pin: 17,
fan_on_temp_c: 40.0,
fan_off_temp_c: 35.0,
fan_on_temp_c: 35.0,
fan_off_temp_c: 30.0,
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),
}
}
}
@@ -41,7 +76,7 @@ impl InfluxConfig {
Some(Self {
url: std::env::var("TOWERD_INFLUX_URL")
.unwrap_or_else(|_| "http://influx.kitsunehosting.net:8086".into()),
.unwrap_or_else(|_| "http://127.0.0.1:8086".into()),
org,
bucket,
token,

View File

@@ -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");
@@ -28,17 +31,27 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
result = tasks::status::run(config.status_pin, status_rx) => {
result?;
}
result = tasks::thermal::run(config.clone(), fan.clone(), alarms.clone()) => {
result = tasks::thermal::run(config.clone(), alarms.clone()) => {
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.fan_on_temp_c,
config.fan_off_temp_c,
fan.clone(),
config.influx.clone(),
alarms.clone(),
) => {
result?;
}
}
fan.lock().await.stop();

View File

@@ -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
View 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(&regs)
}
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(&regs)
}
pub async fn verify(&mut self) -> anyhow::Result<()> {
self.read_data().await.map(|_| ())
}
}

237
src/renogy/registers.rs Normal file
View 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 &reg 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
View 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
}
}

View File

@@ -1,3 +1,4 @@
pub mod metrics;
pub mod renogy;
pub mod status;
pub mod thermal;

242
src/tasks/renogy.rs Normal file
View File

@@ -0,0 +1,242 @@
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::gpio::{Fan, SharedFan};
use crate::renogy::{self, Client as RenogyClient, ControllerData, ControllerInfo};
const RECONNECT_INTERVAL_S: f64 = 15.0;
pub async fn run(
config: RenogyConfig,
fan_on_temp_c: f64,
fan_off_temp_c: f64,
fan: SharedFan,
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,
fan_on_temp_c,
fan_off_temp_c,
&fan,
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,
fan_on_temp_c: f64,
fan_off_temp_c: f64,
fan: &SharedFan,
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;
{
let mut fan = fan.lock().await;
update_fan(
&mut fan,
fan_on_temp_c,
fan_off_temp_c,
data.controller_temperature_c,
);
debug!(
battery_v = data.battery_voltage,
battery_soc = data.battery_soc,
controller_temp_c = data.controller_temperature_c,
solar_w = data.solar_watts,
fan = if fan.on() { "on" } else { "off" },
"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;
}
}
}
}
fn update_fan(fan: &mut Fan, on_temp_c: f64, off_temp_c: f64, controller_temp_c: u8) {
let temp_c = f64::from(controller_temp_c);
if !fan.on() && temp_c > on_temp_c {
fan.set_on(true);
info!(
controller_temp_c = temp_c,
threshold = on_temp_c,
"Fan on"
);
} else if fan.on() && temp_c <= off_temp_c {
fan.set_on(false);
info!(
controller_temp_c = temp_c,
threshold = off_temp_c,
"Fan off"
);
}
}
// 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(())
}

View File

@@ -1,57 +1,26 @@
use std::time::Duration;
use tokio::time::MissedTickBehavior;
use tracing::{debug, info};
use tracing::debug;
use crate::alarm::SharedAlarms;
use crate::config::Config;
use crate::gpio::{Fan, SharedFan};
use crate::thermal;
pub async fn run(config: Config, fan: SharedFan, alarms: SharedAlarms) -> anyhow::Result<()> {
// Setup the interval for the thermal task
pub async fn run(config: Config, alarms: SharedAlarms) -> anyhow::Result<()> {
let mut interval =
tokio::time::interval(Duration::from_secs_f64(config.poll_interval_s));
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop { // Main loop
loop {
interval.tick().await;
// Read the temperature
let temp_c = tokio::task::spawn_blocking(thermal::read_cpu_temp_c)
.await??;
// Check if the temperature is over the alarm threshold
let over_temp = temp_c >= config.thermal_alarm_temp_c;
alarms.set_fault("thermal", over_temp).await;
// Update the fan
let mut fan = fan.lock().await;
update_fan(&mut fan, &config, temp_c);
// Debug logging
debug!(
temp_c,
fan = if fan.on() { "on" } else { "off" },
alarm = ?alarms.status(),
);
}
}
fn update_fan(fan: &mut Fan, config: &Config, temp_c: f64) {
if !fan.on() && temp_c >= config.fan_on_temp_c {
fan.set_on(true);
info!(
temp_c,
threshold = config.fan_on_temp_c,
"Fan on"
);
} else if fan.on() && temp_c <= config.fan_off_temp_c {
fan.set_on(false);
info!(
temp_c,
threshold = config.fan_off_temp_c,
"Fan off"
);
debug!(temp_c, alarm = ?alarms.status());
}
}

View File

@@ -1,10 +1,12 @@
[Unit]
Description=Tower Daemon
After=multi-user.target
After=multi-user.target influxdb.service
Wants=influxdb.service
[Service]
Type=simple
User=root
EnvironmentFile=-/etc/towerd/env
WorkingDirectory=/opt/towerd
ExecStart=/opt/towerd/target/release/towerd
Restart=on-failure