Compare commits

..

6 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
KenwoodFox
35e56d25e7 Add influxdb 2026-06-15 10:51:54 -04:00
KenwoodFox
f49ff99b6b Rust Ver 2026-06-15 10:32:37 -04:00
24 changed files with 1187 additions and 192 deletions

12
.gitignore vendored
View File

@@ -1,9 +1,3 @@
venv/
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.pytest_cache/
.mypy_cache/
.ruff_cache/
/target/
Cargo.lock
.env

27
Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "towerd"
version = "0.1.0"
edition = "2021"
description = "Tower daemon for KW1FOX-1"
[dependencies]
anyhow = "1"
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"

141
README.md
View File

@@ -3,16 +3,149 @@
I wrote this to control the "hardware" on KW1FOX-1 tower.
its.. not really intended for use anywhere else but, if anything inspires you go ahead and grab it!
Just some simple python.
# Install
## Install
```shell
sudo git clone https://git.kitsunehosting.net/Kenwood/towerd.git /opt/towerd
cd /opt/towerd
sudo python3 -m venv venv
sudo venv/bin/pip install -r requirements.txt
cargo build --release
sudo cp systemd/towerd.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now towerd
```
## Usage
```shell
./target/release/towerd --verbose
# systemd
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

@@ -1 +0,0 @@
gpiozero>=2.0

59
src/alarm.rs Normal file
View File

@@ -0,0 +1,59 @@
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::{watch, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Ok,
Alarm,
}
// Shared reference to alarm board
pub type SharedAlarms = Arc<AlarmBoard>;
pub struct AlarmBoard {
faults: Mutex<HashSet<&'static str>>,
status_tx: watch::Sender<Status>,
}
impl AlarmBoard {
// Create a new alarm board
pub fn new() -> (SharedAlarms, watch::Receiver<Status>) {
let (status_tx, status_rx) = watch::channel(Status::Ok);
let board = Arc::new(Self {
faults: Mutex::new(HashSet::new()),
status_tx,
});
(board, status_rx)
}
// Set a fault (source, string, active)
pub async fn set_fault(&self, source: &'static str, active: bool) {
let mut faults = self.faults.lock().await;
if active {
faults.insert(source);
} else {
faults.remove(source);
}
let status = if faults.is_empty() {
Status::Ok
} else {
Status::Alarm
};
let _ = self.status_tx.send_if_modified(|current| {
if *current != status {
*current = status;
true
} else {
false
}
});
}
pub fn status(&self) -> Status {
*self.status_tx.borrow()
}
}

91
src/config.rs Normal file
View File

@@ -0,0 +1,91 @@
#[derive(Debug, Clone)]
pub struct Config {
pub status_pin: u8,
pub fan_pin: u8,
pub fan_on_temp_c: f64,
pub fan_off_temp_c: f64,
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)]
pub struct InfluxConfig {
pub url: String,
pub org: String,
pub bucket: String,
pub token: String,
pub host_tag: String,
pub metrics_interval_s: f64,
}
impl Default for Config {
fn default() -> Self {
Self {
status_pin: 4,
fan_pin: 17,
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),
}
}
}
impl InfluxConfig {
pub fn from_env() -> Option<Self> {
let token = std::env::var("TOWERD_INFLUX_TOKEN").ok()?;
let org = std::env::var("TOWERD_INFLUX_ORG").ok()?;
let bucket = std::env::var("TOWERD_INFLUX_BUCKET").ok()?;
Some(Self {
url: std::env::var("TOWERD_INFLUX_URL")
.unwrap_or_else(|_| "http://127.0.0.1:8086".into()),
org,
bucket,
token,
host_tag: std::env::var("TOWERD_INFLUX_HOST")
.unwrap_or_else(|_| "kw1fox-1".into()),
metrics_interval_s: std::env::var("TOWERD_INFLUX_INTERVAL_S")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30.0),
})
}
}

60
src/daemon.rs Normal file
View File

@@ -0,0 +1,60 @@
use std::sync::Arc;
use tracing::info;
use crate::alarm::AlarmBoard;
use crate::config::Config;
use crate::gpio::Fan;
use crate::tasks;
pub async fn run(config: Config) -> anyhow::Result<()> {
let (alarms, status_rx) = AlarmBoard::new();
let fan = Arc::new(tokio::sync::Mutex::new(Fan::new(config.fan_pin)?));
info!(
status_pin = config.status_pin,
fan_pin = config.fan_pin,
"Tower daemon started"
);
if config.influx.is_none() {
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");
}
result = tasks::status::run(config.status_pin, status_rx) => {
result?;
}
result = tasks::thermal::run(config.clone(), alarms.clone()) => {
result?;
}
result = async {
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();
info!("Tower daemon stopped");
Ok(())
}

42
src/gpio.rs Normal file
View File

@@ -0,0 +1,42 @@
use std::sync::Arc;
use anyhow::Context;
use rppal::gpio::{Gpio, OutputPin};
use tokio::sync::Mutex;
pub type SharedFan = Arc<Mutex<Fan>>;
pub struct Fan {
_gpio: Gpio,
pin: OutputPin,
}
impl Fan {
pub fn new(fan_pin: u8) -> anyhow::Result<Self> {
let gpio = Gpio::new().context("failed to initialize GPIO")?;
let mut pin = gpio
.get(fan_pin)
.context("failed to open fan pin")?
.into_output();
pin.set_low();
Ok(Self { _gpio: gpio, pin })
}
pub fn on(&self) -> bool {
self.pin.is_set_high()
}
pub fn set_on(&mut self, on: bool) {
if on {
self.pin.set_high();
} else {
self.pin.set_low();
}
}
pub fn stop(&mut self) {
self.pin.set_low();
}
}

41
src/main.rs Normal file
View File

@@ -0,0 +1,41 @@
mod alarm;
mod config;
mod daemon;
mod gpio;
mod renogy;
mod tasks;
mod thermal;
use clap::Parser;
use tracing::Level;
use tracing_subscriber::EnvFilter;
use crate::config::Config;
#[derive(Parser)]
#[command(name = "towerd", about = "Tower Daemon")]
struct Args {
#[arg(short, long, help = "Enable debug logging")]
verbose: bool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let default_level = if args.verbose {
Level::DEBUG
} else {
Level::INFO
};
let filter = EnvFilter::builder()
.with_default_directive(default_level.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
daemon::run(Config::default()).await
}

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
}
}

74
src/tasks/metrics.rs Normal file
View File

@@ -0,0 +1,74 @@
use std::time::Duration;
use futures::stream;
use influxdb2::models::DataPoint;
use influxdb2::Client;
use tokio::time::MissedTickBehavior;
use tracing::{info, warn};
use crate::alarm::{SharedAlarms, Status};
use crate::config::InfluxConfig;
use crate::gpio::SharedFan;
use crate::thermal;
pub async fn run(config: InfluxConfig, fan: SharedFan, alarms: SharedAlarms) -> anyhow::Result<()> {
// Get client
let client = Client::new(&config.url, &config.org, &config.token);
// Get interval
let mut interval =
tokio::time::interval(Duration::from_secs_f64(config.metrics_interval_s));
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
// Debug logging
info!(
url = %config.url,
org = %config.org,
bucket = %config.bucket,
host = %config.host_tag,
interval_s = config.metrics_interval_s,
"Metrics task started"
);
loop { // Main loop
interval.tick().await;
// Publish the metrics
match publish(&client, &config, &fan, &alarms).await {
Ok(()) => alarms.set_fault("metrics", false).await,
Err(e) => {
// Set the fault if the publish fails
warn!(error = %e, "Failed to publish metrics");
alarms.set_fault("metrics", true).await;
}
}
}
}
// Publish the metrics
async fn publish(
client: &Client,
config: &InfluxConfig,
fan: &SharedFan,
alarms: &SharedAlarms,
) -> anyhow::Result<()> {
let temp_c = tokio::task::spawn_blocking(thermal::read_cpu_temp_c)
.await??;
let fan_on = fan.lock().await.on();
let alarm = matches!(alarms.status(), Status::Alarm);
let point = DataPoint::builder("tower")
.tag("host", &config.host_tag)
.field("cpu_temp_c", temp_c)
.field("fan_on", fan_on)
.field("alarm", alarm)
.build()?;
client
.write(&config.bucket, stream::iter(vec![point]))
.await
.map_err(|e| anyhow::anyhow!("influxdb write failed: {e}"))?;
Ok(())
}

4
src/tasks/mod.rs Normal file
View File

@@ -0,0 +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(())
}

45
src/tasks/status.rs Normal file
View File

@@ -0,0 +1,45 @@
use std::time::Duration;
use anyhow::Context;
use rppal::gpio::{Gpio, OutputPin};
use tokio::sync::watch;
use crate::alarm::Status;
const SLOW_ON: Duration = Duration::from_secs(1);
const SLOW_OFF: Duration = Duration::from_secs(1);
const FAST_ON: Duration = Duration::from_millis(150);
const FAST_OFF: Duration = Duration::from_millis(150);
struct StatusLed {
pin: OutputPin,
}
impl Drop for StatusLed {
fn drop(&mut self) {
self.pin.set_low();
}
}
pub async fn run(status_pin: u8, status_rx: watch::Receiver<Status>) -> anyhow::Result<()> {
let gpio = Gpio::new().context("failed to initialize GPIO")?;
let led = gpio
.get(status_pin)
.context("failed to open status pin")?
.into_output();
let mut led = StatusLed { pin: led };
loop {
// Get the on and off times based on the status
let (on_time, off_time) = match *status_rx.borrow() {
Status::Ok => (SLOW_ON, SLOW_OFF),
Status::Alarm => (FAST_ON, FAST_OFF),
};
led.pin.set_high();
tokio::time::sleep(on_time).await;
led.pin.set_low();
tokio::time::sleep(off_time).await;
}
}

26
src/tasks/thermal.rs Normal file
View File

@@ -0,0 +1,26 @@
use std::time::Duration;
use tokio::time::MissedTickBehavior;
use tracing::debug;
use crate::alarm::SharedAlarms;
use crate::config::Config;
use crate::thermal;
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 {
interval.tick().await;
let temp_c = tokio::task::spawn_blocking(thermal::read_cpu_temp_c)
.await??;
let over_temp = temp_c >= config.thermal_alarm_temp_c;
alarms.set_fault("thermal", over_temp).await;
debug!(temp_c, alarm = ?alarms.status());
}
}

21
src/thermal.rs Normal file
View File

@@ -0,0 +1,21 @@
use std::fs;
use std::path::Path;
const THERMAL_ZONE: &str = "/sys/class/thermal/thermal_zone0/temp";
pub fn read_cpu_temp_c() -> anyhow::Result<f64> {
let path = Path::new(THERMAL_ZONE);
if !path.exists() {
// Bail throws an error and exits for us
anyhow::bail!(
"Thermal sensor not found at {THERMAL_ZONE}. Maybe running on bad hardware?"
);
}
let millidegrees: i32 = fs::read_to_string(path)?
.trim()
.parse()
.map_err(|e| anyhow::anyhow!("failed to parse thermal reading: {e}"))?;
Ok(millidegrees as f64 / 1000.0)
}

View File

@@ -1,12 +1,14 @@
[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/venv/bin/python -m towerd
ExecStart=/opt/towerd/target/release/towerd
Restart=on-failure
RestartSec=5

View File

@@ -1,3 +0,0 @@
"""Tower Daemon"""
__version__ = "0.1.0"

View File

@@ -1,3 +0,0 @@
from towerd.main import main
main()

View File

@@ -1,59 +0,0 @@
from enum import Enum
from gpiozero import LED, OutputDevice
class Status(Enum):
OK = "ok"
ERROR = "error"
# Blink timings (seconds)
SLOW_ON = 1.0
SLOW_OFF = 1.0
FAST_ON = 0.15
FAST_OFF = 0.15
class TowerGPIO:
"""GPIO controls"""
def __init__(self, status_pin: int = 4, fan_pin: int = 17) -> None:
self._status_led = LED(status_pin)
self._fan = OutputDevice(fan_pin, active_high=True, initial_value=False)
self._status = Status.OK
@property
def status(self) -> Status:
return self._status
@property
def fan_on(self) -> bool:
return self._fan.value
def set_status(self, status: Status) -> None:
if status == self._status:
return
self._status = status
self._apply_blink()
def set_fan(self, on: bool) -> None:
if on:
self._fan.on()
else:
self._fan.off()
def start(self) -> None:
self._apply_blink()
def stop(self) -> None:
self._status_led.off()
self._fan.off()
self._status_led.close()
self._fan.close()
def _apply_blink(self) -> None:
if self._status is Status.OK:
self._status_led.blink(on_time=SLOW_ON, off_time=SLOW_OFF, background=True)
else:
self._status_led.blink(on_time=FAST_ON, off_time=FAST_OFF, background=True)

View File

@@ -1,96 +0,0 @@
import argparse
import logging
import signal
import time
from dataclasses import dataclass
from towerd.gpio_ctrl import Status, TowerGPIO
from towerd.thermal import read_cpu_temp_c
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class Config:
status_pin: int = 4
fan_pin: int = 17
fan_on_temp_c: float = 50.0
fan_off_temp_c: float = 45.0
poll_interval_s: float = 2.0
error_temp_c: float = 80.0
class TowerDaemon:
def __init__(self, config: Config) -> None:
self.config = config
self._running = True
self._gpio = TowerGPIO(
status_pin=config.status_pin,
fan_pin=config.fan_pin,
)
def run(self) -> None:
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
self._gpio.start()
log.info(
"Tower daemon started (status GPIO %d, fan GPIO %d)",
self.config.status_pin,
self.config.fan_pin,
)
try:
while self._running:
self._tick()
time.sleep(self.config.poll_interval_s)
finally:
self._gpio.stop()
log.info("Tower daemon stopped")
def _tick(self) -> None:
temp_c = read_cpu_temp_c()
self._update_fan(temp_c)
self._update_status(temp_c)
log.debug(
"temp=%.1fC fan=%s status=%s",
temp_c,
"on" if self._gpio.fan_on else "off",
self._gpio.status.value,
)
def _update_fan(self, temp_c: float) -> None:
if not self._gpio.fan_on and temp_c >= self.config.fan_on_temp_c:
self._gpio.set_fan(True)
log.info("Fan on (%.1fC >= %.1fC)", temp_c, self.config.fan_on_temp_c)
elif self._gpio.fan_on and temp_c <= self.config.fan_off_temp_c:
self._gpio.set_fan(False)
log.info("Fan off (%.1fC <= %.1fC)", temp_c, self.config.fan_off_temp_c)
def _update_status(self, temp_c: float) -> None:
if temp_c >= self.config.error_temp_c:
self._gpio.set_status(Status.ERROR)
else:
self._gpio.set_status(Status.OK)
def _handle_signal(self, signum: int, _frame) -> None:
log.info("Received signal %s, shutting down", signum)
self._running = False
def main() -> None:
parser = argparse.ArgumentParser(description="Tower Daemon")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
TowerDaemon(Config()).run()
if __name__ == "__main__":
main()

View File

@@ -1,14 +0,0 @@
from pathlib import Path
THERMAL_ZONE = Path("/sys/class/thermal/thermal_zone0/temp")
def read_cpu_temp_c() -> float:
"""Get the CPU temp"""
if not THERMAL_ZONE.exists():
raise FileNotFoundError(
f"Thermal sensor not found at {THERMAL_ZONE}. "
"Maybe running on bad hardware?"
)
millidegrees = int(THERMAL_ZONE.read_text().strip())
return millidegrees / 1000.0