mirror of
https://codeberg.org/icewind/lightflash.git
synced 2026-06-03 18:14:06 +02:00
initial version
This commit is contained in:
commit
4254707d82
13 changed files with 1829 additions and 0 deletions
91
src/config.rs
Normal file
91
src/config.rs
Normal 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
134
src/main.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue