This commit is contained in:
KenwoodFox
2026-06-15 10:32:37 -04:00
parent c7accf6ec6
commit f49ff99b6b
15 changed files with 353 additions and 191 deletions

11
.gitignore vendored
View File

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

13
Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "towerd"
version = "0.1.0"
edition = "2021"
description = "Tower daemon for KW1FOX-1"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
ctrlc = "3"
rppal = "0.22"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -3,16 +3,24 @@
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
```

View File

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

22
src/config.rs Normal file
View File

@@ -0,0 +1,22 @@
#[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 error_temp_c: f64,
}
impl Default for Config {
fn default() -> Self {
Self {
status_pin: 4,
fan_pin: 17,
fan_on_temp_c: 40.0,
fan_off_temp_c: 35.0,
poll_interval_s: 2.0,
error_temp_c: 80.0,
}
}
}

99
src/daemon.rs Normal file
View File

@@ -0,0 +1,99 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tracing::{debug, info};
use crate::config::Config;
use crate::gpio::{Status, TowerGpio};
use crate::thermal;
pub struct TowerDaemon {
config: Config,
gpio: TowerGpio,
running: Arc<AtomicBool>,
}
impl TowerDaemon {
/// Creates a new TowerDaemon instance
// fn new is like the constructor and builds this object
pub fn new(config: Config) -> anyhow::Result<Self> {
let gpio = TowerGpio::new(config.status_pin, config.fan_pin)?;
let running = Arc::new(AtomicBool::new(true));
Ok(Self {
config,
gpio,
running,
})
}
// Runs the daemon
pub fn run(mut self) -> anyhow::Result<()> {
let running = Arc::clone(&self.running);
ctrlc::set_handler(move || {
info!("Received shutdown signal");
running.store(false, Ordering::Relaxed);
})?;
info!(
status_pin = self.config.status_pin,
fan_pin = self.config.fan_pin,
"Tower daemon started"
);
// Do tick (main loop)
while self.running.load(Ordering::Relaxed) {
self.tick()?;
thread::sleep(Duration::from_secs_f64(self.config.poll_interval_s));
}
self.gpio.stop();
info!("Tower daemon stopped");
Ok(())
}
// Single daemon tick
fn tick(&mut self) -> anyhow::Result<()> {
let temp_c = thermal::read_cpu_temp_c()?;
self.update_fan(temp_c);
self.update_status(temp_c);
debug!(
temp_c,
fan = if self.gpio.fan_on() { "on" } else { "off" },
status = ?self.gpio.status(),
);
Ok(())
}
// Update the fan based on temperature
fn update_fan(&mut self, temp_c: f64) {
if !self.gpio.fan_on() && temp_c >= self.config.fan_on_temp_c {
self.gpio.set_fan(true);
info!(
temp_c,
threshold = self.config.fan_on_temp_c,
"Fan on"
);
} else if self.gpio.fan_on() && temp_c <= self.config.fan_off_temp_c {
self.gpio.set_fan(false);
info!(
temp_c,
threshold = self.config.fan_off_temp_c,
"Fan off"
);
}
}
// Update the status
fn update_status(&self, temp_c: f64) {
if temp_c >= self.config.error_temp_c {
self.gpio.set_status(Status::Error);
} else {
self.gpio.set_status(Status::Ok);
}
}
}

144
src/gpio.rs Normal file
View File

@@ -0,0 +1,144 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use anyhow::Context;
use rppal::gpio::{Gpio, OutputPin};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Ok,
Error,
}
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);
pub struct TowerGpio {
_gpio: Gpio,
fan: OutputPin,
status: Arc<Mutex<Status>>,
blink_shutdown: Arc<AtomicBool>,
blink_handle: Option<JoinHandle<()>>,
}
impl TowerGpio {
/// Sets up the GPIO pins and spawns the blink thread
pub fn new(status_pin: u8, fan_pin: u8) -> anyhow::Result<Self> {
let gpio = Gpio::new().context("failed to initialize GPIO")?;
// Setup the fan
let mut fan = gpio
.get(fan_pin)
.context("failed to open fan pin")?
.into_output();
fan.set_low();
// Setup the status LED
let status_led = gpio
.get(status_pin)
.context("failed to open status pin")?
.into_output();
let status = Arc::new(Mutex::new(Status::Ok));
let blink_shutdown = Arc::new(AtomicBool::new(false)); // A signal to use to stop the blink thread
let blink_status = Arc::clone(&status); // Shared reference for the current status
let blink_stop = Arc::clone(&blink_shutdown); // Shared reference to the shutdown signal
let blink_handle = thread::spawn(move || {
blink_loop(status_led, blink_status, blink_stop); // Spawn the blink thread
});
Ok(Self { // Return the struct with the GPIO pins and the blink thread handle
_gpio: gpio,
fan,
status,
blink_shutdown,
blink_handle: Some(blink_handle),
})
}
// Get the current status
pub fn status(&self) -> Status {
*self.status.lock().expect("status lock poisoned")
}
// Check if the fan is on
pub fn fan_on(&self) -> bool {
self.fan.is_set_high()
}
// Set the current status
pub fn set_status(&self, status: Status) {
let mut current = self.status.lock().expect("status lock poisoned");
if *current == status {
return;
}
*current = status;
}
// Set the fan on or off
pub fn set_fan(&mut self, on: bool) {
if on {
self.fan.set_high();
} else {
self.fan.set_low();
}
}
// Stop the GPIO pins and join the blink thread
pub fn stop(&mut self) {
self.blink_shutdown.store(true, Ordering::Relaxed);
if let Some(handle) = self.blink_handle.take() {
let _ = handle.join();
}
self.fan.set_low();
}
}
// For when the tower GPIO is dropped
impl Drop for TowerGpio {
fn drop(&mut self) {
self.stop();
}
}
// The blink loop for the status LED
fn blink_loop(mut led: OutputPin, status: Arc<Mutex<Status>>, shutdown: Arc<AtomicBool>) {
while !shutdown.load(Ordering::Relaxed) {
let current = *status.lock().expect("status lock poisoned");
let (on_time, off_time) = match current {
Status::Ok => (SLOW_ON, SLOW_OFF),
Status::Error => (FAST_ON, FAST_OFF),
};
led.set_high();
if sleep_or_shutdown(on_time, &shutdown) {
break;
}
led.set_low();
if sleep_or_shutdown(off_time, &shutdown) {
break;
}
}
led.set_low();
}
fn sleep_or_shutdown(duration: Duration, shutdown: &AtomicBool) -> bool {
let step = Duration::from_millis(50);
let mut remaining = duration;
while remaining > Duration::ZERO {
if shutdown.load(Ordering::Relaxed) {
return true;
}
let slice = remaining.min(step);
thread::sleep(slice);
remaining = remaining.saturating_sub(slice);
}
shutdown.load(Ordering::Relaxed)
}

38
src/main.rs Normal file
View File

@@ -0,0 +1,38 @@
mod config;
mod daemon;
mod gpio;
mod thermal;
use clap::Parser;
use tracing::Level;
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::daemon::TowerDaemon;
#[derive(Parser)]
#[command(name = "towerd", about = "Tower Daemon")]
struct Args {
#[arg(short, long, help = "Enable debug logging")]
verbose: bool,
}
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();
TowerDaemon::new(Config::default())?.run()
}

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

@@ -6,7 +6,7 @@ After=multi-user.target
Type=simple Type=simple
User=root User=root
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

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