diff --git a/.gitignore b/.gitignore index 644aaf7..ca98cd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,2 @@ -venv/ -__pycache__/ -*.py[cod] -*.egg-info/ -dist/ -build/ -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ +/target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4c135fa --- /dev/null +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index cd433bf..cee53ae 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,24 @@ 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 -``` \ No newline at end of file +``` + +## Usage + +```shell +./target/release/towerd --verbose + +# systemd +sudo systemctl status towerd +sudo journalctl -u towerd -f +``` diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 326d9ff..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -gpiozero>=2.0 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7869b53 --- /dev/null +++ b/src/config.rs @@ -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, + } + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..09dca37 --- /dev/null +++ b/src/daemon.rs @@ -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, +} + +impl TowerDaemon { + /// Creates a new TowerDaemon instance + // fn new is like the constructor and builds this object + pub fn new(config: Config) -> anyhow::Result { + 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); + } + } +} diff --git a/src/gpio.rs b/src/gpio.rs new file mode 100644 index 0000000..f46c320 --- /dev/null +++ b/src/gpio.rs @@ -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>, + blink_shutdown: Arc, + blink_handle: Option>, +} + +impl TowerGpio { + /// Sets up the GPIO pins and spawns the blink thread + pub fn new(status_pin: u8, fan_pin: u8) -> anyhow::Result { + 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>, shutdown: Arc) { + 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) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..03fcc40 --- /dev/null +++ b/src/main.rs @@ -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() +} diff --git a/src/thermal.rs b/src/thermal.rs new file mode 100644 index 0000000..efbf957 --- /dev/null +++ b/src/thermal.rs @@ -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 { + 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) +} diff --git a/systemd/towerd.service b/systemd/towerd.service index d08c113..5df7148 100644 --- a/systemd/towerd.service +++ b/systemd/towerd.service @@ -6,7 +6,7 @@ After=multi-user.target Type=simple User=root WorkingDirectory=/opt/towerd -ExecStart=/opt/towerd/venv/bin/python -m towerd +ExecStart=/opt/towerd/target/release/towerd Restart=on-failure RestartSec=5 diff --git a/towerd/__init__.py b/towerd/__init__.py deleted file mode 100644 index aab60e0..0000000 --- a/towerd/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tower Daemon""" - -__version__ = "0.1.0" diff --git a/towerd/__main__.py b/towerd/__main__.py deleted file mode 100644 index 3d2e3c3..0000000 --- a/towerd/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from towerd.main import main - -main() diff --git a/towerd/gpio_ctrl.py b/towerd/gpio_ctrl.py deleted file mode 100644 index 406ffae..0000000 --- a/towerd/gpio_ctrl.py +++ /dev/null @@ -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) diff --git a/towerd/main.py b/towerd/main.py deleted file mode 100644 index 0abc07c..0000000 --- a/towerd/main.py +++ /dev/null @@ -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() diff --git a/towerd/thermal.py b/towerd/thermal.py deleted file mode 100644 index 33b2b7c..0000000 --- a/towerd/thermal.py +++ /dev/null @@ -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