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
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
16
.forgejo/workflows/ci.yaml
Normal file
16
.forgejo/workflows/ci.yaml
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
result
|
||||
config.toml
|
||||
1284
Cargo.lock
generated
Normal file
1284
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# lightflash
|
||||
|
||||
Flash tasmota controlled lights with a tasmota button.
|
||||
107
flake.lock
generated
Normal file
107
flake.lock
generated
Normal 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
38
flake.nix
Normal 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
110
nix/module.nix
Normal 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
3
nix/overlay.nix
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
final: prev: {
|
||||
lightflash = final.callPackage ./package.nix {};
|
||||
}
|
||||
21
nix/package.nix
Normal file
21
nix/package.nix
Normal 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
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