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

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -0,0 +1,16 @@
name: "CI"
on:
pull_request:
push:
jobs:
checks:
runs-on: nix
steps:
- uses: actions/checkout@v4
- uses: https://codeberg.org/icewind/attic-action@v1
with:
name: link
instance: https://cache.icewind.link
authToken: "${{ secrets.ATTIC_TOKEN }}"
- run: nix flake check --keep-going

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
result
config.toml

1284
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "lightflash"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.17"
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros"] }
secretfile = "0.1.1"
toml = "0.9.8"
clap = { version = "4.5.50", features = ["derive"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
main_error = "0.1.2"
rumqttc = "0.25.0"
hostname = "0.4.1"

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# lightflash
Flash tasmota controlled lights with a tasmota button.

107
flake.lock generated Normal file
View file

@ -0,0 +1,107 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1748970125,
"narHash": "sha256-UDyigbDGv8fvs9aS95yzFfOKkEjx1LO3PL3DsKopohA=",
"owner": "ipetkov",
"repo": "crane",
"rev": "323b5746d89e04b22554b061522dfce9e4c49b18",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flakelight": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1750683087,
"narHash": "sha256-CkmobghX9K9soC4DoorFo/vlWXxpf8xuZyoCsOnWzqM=",
"owner": "nix-community",
"repo": "flakelight",
"rev": "aa93e0e1ecdccc70c7a185f60c562effbfe3c8d9",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "flakelight",
"type": "github"
}
},
"mill-scale": {
"inputs": {
"crane": "crane",
"flakelight": [
"flakelight"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1750254239,
"narHash": "sha256-RRhboiUiw4o4hPhxTTnUcd2+8yOLdt6hOTDraMPE/rg=",
"ref": "refs/heads/main",
"rev": "b7201d4a2220de413e6426c405b69839f6130d1c",
"revCount": 53,
"type": "git",
"url": "https://codeberg.org/icewind/mill-scale"
},
"original": {
"type": "git",
"url": "https://codeberg.org/icewind/mill-scale"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1750622754,
"narHash": "sha256-kMhs+YzV4vPGfuTpD3mwzibWUE6jotw5Al2wczI0Pv8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c7ab75210cb8cb16ddd8f290755d9558edde7ee1",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-25.05",
"type": "indirect"
}
},
"root": {
"inputs": {
"flakelight": "flakelight",
"mill-scale": "mill-scale",
"nixpkgs": "nixpkgs"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"mill-scale",
"flakelight",
"nixpkgs"
]
},
"locked": {
"lastModified": 1750214276,
"narHash": "sha256-1kniuhH70q4TAC/xIvjFYH46aHiLrbIlcr6fdrRwO1A=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f9b2b2b1327ff6beab4662b8ea41689e0a57b8d4",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

38
flake.nix Normal file
View file

@ -0,0 +1,38 @@
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-25.05";
flakelight = {
url = "github:nix-community/flakelight";
inputs.nixpkgs.follows = "nixpkgs";
};
mill-scale = {
url = "git+https://codeberg.org/icewind/mill-scale";
inputs.flakelight.follows = "flakelight";
};
};
outputs = {mill-scale, ...}:
mill-scale ./. {
withOverlays = [
(import ./nix/overlay.nix)
];
packages = {
lightflash = pkgs: pkgs.lightflash;
};
nixosModules = {outputs, ...}: {
default = {
pkgs,
config,
lib,
...
}: {
imports = [./nix/module.nix];
config = lib.mkIf config.services.lightflash.enable {
nixpkgs.overlays = [outputs.overlays.default];
services.lightflash.package = lib.mkDefault pkgs.lightflash;
};
};
};
};
}

110
nix/module.nix Normal file
View file

@ -0,0 +1,110 @@
{
config,
lib,
pkgs,
...
}:
with lib; let
format = pkgs.formats.toml {};
cfg = config.services.lightflash;
configFile = format.generate "lightflash.toml" {
mqtt = {
inherit (cfg.mqtt) hostname username;
password_file = "$CREDENTIALS_DIRECTORY/mqtt_password";
};
switch = cfg.switches;
};
in {
options.services.lightflash = {
enable = mkEnableOption "Enables the lightflash service";
mqtt = mkOption {
type = types.submodule {
options = {
hostname = mkOption {
type = types.str;
description = "mqtt server hostname";
};
username = mkOption {
type = types.str;
description = "mqtt username";
};
passwordFile = mkOption {
type = types.str;
description = "path containing the mqtt password";
};
};
};
};
switches = mkOption {
description = "configured triggers";
type = types.listOf (types.submodule {
options = {
name = mkOption {
type = types.str;
description = "switch to listen on";
};
lights = mkOption {
type = types.listOf types.str;
description = "lights to flash";
};
};
});
};
package = mkOption {
type = types.package;
description = "package to use";
};
logLevel = mkOption {
type = types.str;
default = "INFO";
description = "log level";
};
};
config = mkIf cfg.enable {
systemd.services.lightflash = {
wantedBy = ["multi-user.target"];
environment = {
RUST_LOG = cfg.logLevel;
};
serviceConfig = {
LoadCredential = [
"mqtt_password:${cfg.mqtt.passwordFile}"
];
Restart = "on-failure";
ExecStart = "${getExe cfg.package} --config ${configFile}";
DynamicUser = true;
PrivateUsers = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
ProtectClock = true;
CapabilityBoundingSet = true;
ProtectControlGroups = true;
SystemCallArchitectures = "native";
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectKernelTunables = true;
ProtectHostname = true;
LockPersonality = true;
ProtectProc = "invisible";
PrivateDevices = true;
RestrictAddressFamilies = ["AF_LOCAL" "AF_INET"];
RestrictRealtime = true;
SystemCallFilter = ["~@reboot" "~@cpu-emulation" "~@obsolete" "~@debug" "~@swap" "~@clock" "~@module"];
IPAddressDeny = ["any"];
IPAddressAllow = ["localhost"];
UMask = "0007";
RuntimeDirectory = "lightflash";
};
};
};
}

3
nix/overlay.nix Normal file
View file

@ -0,0 +1,3 @@
final: prev: {
lightflash = final.callPackage ./package.nix {};
}

21
nix/package.nix Normal file
View file

@ -0,0 +1,21 @@
{
rustPlatform,
pkg-config,
lib,
}: let
inherit (lib.sources) sourceByRegex;
inherit (builtins) fromTOML readFile;
src = sourceByRegex ../. ["Cargo.*" "(src)(/.*)?"];
version = (fromTOML (readFile ../Cargo.toml)).package.version;
in
rustPlatform.buildRustPackage rec {
pname = "lightflash";
inherit src version;
cargoLock = {
lockFile = ../Cargo.lock;
};
meta.mainProgram = "lightflash";
}

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
}