Compare commits
6 Commits
c7accf6ec6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe5fd5452 | ||
|
|
49618f0c0a | ||
|
|
ee02bf99cf | ||
|
|
f86a448fe1 | ||
|
|
35e56d25e7 | ||
|
|
f49ff99b6b |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,9 +1,3 @@
|
|||||||
venv/
|
/target/
|
||||||
__pycache__/
|
Cargo.lock
|
||||||
*.py[cod]
|
.env
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
|
|||||||
27
Cargo.toml
Normal file
27
Cargo.toml
Normal 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"
|
||||||
143
README.md
143
README.md
@@ -3,16 +3,149 @@
|
|||||||
I wrote this to control the "hardware" on KW1FOX-1 tower.
|
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!
|
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
|
```shell
|
||||||
sudo git clone https://git.kitsunehosting.net/Kenwood/towerd.git /opt/towerd
|
sudo git clone https://git.kitsunehosting.net/Kenwood/towerd.git /opt/towerd
|
||||||
cd /opt/towerd
|
cd /opt/towerd
|
||||||
sudo python3 -m venv venv
|
cargo build --release
|
||||||
sudo venv/bin/pip install -r requirements.txt
|
|
||||||
sudo cp systemd/towerd.service /etc/systemd/system/
|
sudo cp systemd/towerd.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now towerd
|
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]`.
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
gpiozero>=2.0
|
|
||||||
59
src/alarm.rs
Normal file
59
src/alarm.rs
Normal 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
91
src/config.rs
Normal 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
60
src/daemon.rs
Normal 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
42
src/gpio.rs
Normal 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
41
src/main.rs
Normal 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
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
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/tasks/metrics.rs
Normal file
74
src/tasks/metrics.rs
Normal 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
4
src/tasks/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod metrics;
|
||||||
|
pub mod renogy;
|
||||||
|
pub mod status;
|
||||||
|
pub mod thermal;
|
||||||
242
src/tasks/renogy.rs
Normal file
242
src/tasks/renogy.rs
Normal 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
45
src/tasks/status.rs
Normal 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
26
src/tasks/thermal.rs
Normal 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
21
src/thermal.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Tower Daemon
|
Description=Tower Daemon
|
||||||
After=multi-user.target
|
After=multi-user.target influxdb.service
|
||||||
|
Wants=influxdb.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
|
EnvironmentFile=-/etc/towerd/env
|
||||||
WorkingDirectory=/opt/towerd
|
WorkingDirectory=/opt/towerd
|
||||||
ExecStart=/opt/towerd/venv/bin/python -m towerd
|
ExecStart=/opt/towerd/target/release/towerd
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
"""Tower Daemon"""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from towerd.main import main
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user