initial version

This commit is contained in:
Robin Appelman 2025-10-21 23:20:19 +02:00
commit 4254707d82
13 changed files with 1829 additions and 0 deletions

91
src/config.rs Normal file
View file

@ -0,0 +1,91 @@
use secretfile::{load, SecretError};
use serde::Deserialize;
use std::convert::TryFrom;
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Deserialize)]
pub struct LightFlashConfig {
pub mqtt: MqttConfig,
pub switch: Vec<SwitchConfig>,
}
impl LightFlashConfig {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let path = path.as_ref();
let raw = read_to_string(path).map_err(|error| ConfigError::Read {
path: path.into(),
error,
})?;
toml::from_str(&raw).map_err(|error| ConfigError::Parse {
path: path.into(),
error,
})
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SwitchConfig {
pub name: String,
#[serde(default)]
pub lights: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "RawMqttConfig")]
pub struct MqttConfig {
pub hostname: String,
pub port: Option<u16>,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RawMqttConfig {
pub hostname: String,
pub port: Option<u16>,
pub username: Option<String>,
#[serde(flatten)]
pub password: Option<MqttPassword>,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum MqttPassword {
Raw { password: String },
File { password_file: String },
}
impl TryFrom<RawMqttConfig> for MqttConfig {
type Error = SecretError;
fn try_from(value: RawMqttConfig) -> Result<Self, Self::Error> {
let password = match value.password {
Some(MqttPassword::Raw { password }) => Some(password),
Some(MqttPassword::File { password_file }) => Some(load(&password_file)?),
None => None,
};
Ok(MqttConfig {
hostname: value.hostname,
port: value.port,
username: value.username,
password,
})
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {}: {error:#}", path.display())]
Read {
path: PathBuf,
error: std::io::Error,
},
#[error("Failed to parse config file {}: {error:#}", path.display())]
Parse {
path: PathBuf,
error: toml::de::Error,
},
}

134
src/main.rs Normal file
View file

@ -0,0 +1,134 @@
use crate::config::{LightFlashConfig, SwitchConfig};
use clap::Parser;
use clap::builder::Styles;
use clap::builder::styling::{AnsiColor, Effects};
use main_error::MainResult;
use rumqttc::{AsyncClient, ClientError, Event, MqttOptions, Packet, QoS};
use serde::Deserialize;
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::spawn;
use tokio::time::sleep;
use tracing::{debug, info};
mod config;
fn styles() -> Styles {
Styles::styled()
.header(AnsiColor::Yellow.on_default() | Effects::BOLD)
.usage(AnsiColor::Yellow.on_default() | Effects::BOLD)
.literal(AnsiColor::Blue.on_default() | Effects::BOLD)
.placeholder(AnsiColor::Green.on_default())
}
#[derive(Debug, Parser)]
#[command(styles = styles())]
struct Args {
/// Config file
#[arg(long)]
config: PathBuf,
}
#[tokio::main]
async fn main() -> MainResult {
let args: Args = Args::parse();
tracing_subscriber::fmt::init();
let config = LightFlashConfig::load(&args.config)?;
let hostname = hostname::get()
.map(|os_str| os_str.to_string_lossy().to_string())
.unwrap_or_default();
let mut mqtt_options = MqttOptions::new(
format!("lightflash-{}", hostname),
config.mqtt.hostname.as_str(),
config.mqtt.port.unwrap_or(1883),
);
if let (Some(username), Some(password)) = (&config.mqtt.username, &config.mqtt.password) {
mqtt_options.set_credentials(username, password);
}
let (mqtt_client, mut event_loop) = AsyncClient::new(mqtt_options, 10);
let switches: Vec<_> = config
.switch
.into_iter()
.map(Switch::from)
.map(Arc::new)
.collect();
for switch in &switches {
mqtt_client
.subscribe(format!("tele/{}/SENSOR", switch.name), QoS::AtMostOnce)
.await?;
}
let mqtt_client = Arc::new(mqtt_client);
loop {
let notification = event_loop.poll().await?;
if let Event::Incoming(Packet::Publish(packet)) = notification {
if let Some(triggered) = packet
.topic
.as_str()
.strip_prefix("tele/")
.and_then(|topic| topic.strip_suffix("/SENSOR"))
{
for switch in &switches {
if switch.name == triggered {
let payload: SwitchPayload = serde_json::from_slice(&packet.payload)?;
if payload.doorbell.starts_with("ON") {
spawn(flash(mqtt_client.clone(), switch.clone()));
} else if payload.doorbell.starts_with("OFF") {
switch.active.store(false, Ordering::SeqCst);
}
}
}
}
}
}
}
struct Switch {
name: String,
lights: Vec<String>,
active: AtomicBool,
}
impl From<SwitchConfig> for Switch {
fn from(value: SwitchConfig) -> Self {
Switch {
name: value.name,
lights: value.lights,
active: AtomicBool::new(false),
}
}
}
#[derive(Deserialize, Debug)]
struct SwitchPayload<'a> {
doorbell: Cow<'a, str>,
}
async fn flash(mqtt_client: Arc<AsyncClient>, switch: Arc<Switch>) {
switch.active.store(true, Ordering::SeqCst);
info!(switch = switch.name, "flashing lights");
for _ in 0..10 {
for light in &switch.lights {
toggle_light(&mqtt_client, light).await.unwrap();
}
sleep(Duration::from_secs(1)).await;
for light in &switch.lights {
toggle_light(&mqtt_client, light).await.unwrap();
}
sleep(Duration::from_secs(1)).await;
}
}
async fn toggle_light(mqtt_client: &AsyncClient, light: &str) -> Result<(), ClientError> {
debug!(light, "toggling light");
mqtt_client.publish(format!("cmnd/{light}/POWER"), QoS::ExactlyOnce, false, "toggle").await
}