3 Commits

Author SHA1 Message Date
d839bd6df5 Remove waker 2026-05-15 16:05:52 +02:00
ab28ff5671 Fix dockerfile syntax 2026-05-15 15:56:19 +02:00
ba6ce926fc Purge collectors and info page 2026-05-15 15:46:42 +02:00
21 changed files with 29 additions and 631 deletions

45
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@@ -1304,6 +1304,12 @@ dependencies = [
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.17"
@@ -1835,26 +1841,14 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.87"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.50",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
@@ -1872,9 +1866,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.87"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1882,22 +1876,25 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.87"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.50",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.87"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"

View File

@@ -1,10 +1,10 @@
##################
### BASE STAGE ###
##################
FROM rust:1.76.0 as base
FROM rust:1.95.0 AS base
# Install build dependencies
RUN cargo install --locked trunk@^0.18.8 strip_cargo_version
RUN cargo install --locked trunk@^0.21.14 strip_cargo_version
RUN rustup target add wasm32-unknown-unknown
RUN rustup target add x86_64-unknown-linux-musl

View File

@@ -6,17 +6,6 @@ persistence_dir = "/tmp/"
#username = "user"
#password = "password"
[collectors]
markdown_web_links = [
"https://example.org/lmao.md"
]
#weatherapi_key = ""
#weatherapi_locations = [
# "London",
#]
[[bulbs]]
id = "light/bedroom"

View File

@@ -1,20 +0,0 @@
mod markdown_web;
mod weatherapi;
pub use markdown_web::MarkdownWeb;
pub use weatherapi::WeatherApi;
use serde::Deserialize;
#[async_trait::async_trait]
pub trait Collector {
async fn collect(&mut self) -> anyhow::Result<String>;
}
#[derive(Deserialize)]
pub struct CollectorConfig {
pub markdown_web_links: Vec<String>,
pub weatherapi_key: Option<String>,
pub weatherapi_locations: Vec<String>,
}

View File

@@ -1,16 +0,0 @@
use crate::collector::Collector;
use reqwest::get;
pub struct MarkdownWeb {
pub url: String,
}
#[async_trait::async_trait]
impl Collector for MarkdownWeb {
async fn collect(&mut self) -> anyhow::Result<String> {
let text = get(&self.url).await?.text().await?;
let html = markdown::to_html(&text);
Ok(html)
}
}

View File

@@ -1,151 +0,0 @@
use crate::collector::Collector;
use chrono::{serde::ts_seconds, DateTime, Utc};
use reqwest::get;
use serde::Deserialize;
use std::fmt::{self, Display, Formatter};
#[derive(Deserialize)]
struct Response {
current: CurrentBody,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct Condition {
text: String,
icon: String,
code: i64,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct CurrentBody {
#[serde(with = "ts_seconds")]
last_updated_epoch: DateTime<Utc>,
last_updated: String,
temp_c: f64,
is_day: i8,
condition: Condition,
wind_kph: f64,
wind_degree: f64,
wind_dir: String,
pressure_mb: f64,
precip_mm: f64,
humidity: f64,
cloud: f64,
feelslike_c: f64,
vis_km: f64,
uv: f64,
gust_kph: f64,
}
pub struct WeatherApi {
pub api_key: String,
pub location: String,
}
#[async_trait::async_trait]
impl Collector for WeatherApi {
async fn collect(&mut self) -> anyhow::Result<String> {
let path = "https://api.weatherapi.com/v1/current.json";
let api_key = &self.api_key;
let location = &self.location;
let url = format!("{path}?key={api_key}&q={location}&aqi=no");
let Response {
current:
CurrentBody {
last_updated_epoch,
temp_c,
feelslike_c,
wind_kph,
precip_mm,
humidity,
cloud,
..
},
..
} = get(&url).await?.json().await?;
let wind_mps = (wind_kph / 3.6).round();
let wind = wind_speed_to_beaufort(wind_mps);
let temp = if feelslike_c != temp_c {
format!("**{temp_c}°** (känns som {feelslike_c}°)")
} else {
format!("**{temp_c}°**")
};
let time_of_day = last_updated_epoch.naive_local().format("%H:%M");
let markdown = format!(
r#"
# Väder
## {location} kl {time_of_day}
{temp}. **{wind}** ({wind_mps} m/s).
{cloud}% molntäcke, {precip_mm} mm regn, {humidity}% luftfuktighet.
"#
);
let html = markdown::to_html(markdown.trim());
Ok(html)
}
}
/// Wind speed expressed on the beaufort scale
struct BeaufortScale(u8);
impl Display for BeaufortScale {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let name = match self {
BeaufortScale(0) => "Lugnt",
BeaufortScale(1 | 2) => "Svag vind",
BeaufortScale(3 | 4) => "Måttlig vind",
BeaufortScale(5 | 6) => "Frisk vind",
BeaufortScale(7 | 8) => "Hård vind",
BeaufortScale(9) => "Mycket hård vind",
BeaufortScale(10) => "Storm",
BeaufortScale(11) => "Svår storm",
BeaufortScale(12..) => "Orkan",
};
write!(f, "{name}")
}
}
fn wind_speed_to_beaufort(mps: f64) -> BeaufortScale {
let beaufort_wind_speeds = [
0.0..0.3,
0.3..1.6,
1.6..3.4,
3.4..5.5,
5.5..8.8,
8.0..10.8,
10.8..13.9,
13.9..17.2,
17.2..20.8,
20.8..24.5,
24.5..28.5,
28.5..32.7,
32.7..37.0,
37.0..41.5,
41.5..46.2,
46.2..51.0,
51.0..56.1,
56.1..61.3,
];
let mps = mps.max(0.0);
let index = beaufort_wind_speeds
.iter()
.enumerate()
.find_map(|(i, range)| range.contains(&mps).then_some(i))
.unwrap_or(beaufort_wind_speeds.len());
BeaufortScale(index as u8)
}

View File

@@ -1,10 +1,8 @@
mod collector;
mod persistence;
mod tasks;
mod util;
use clap::Parser;
use collector::CollectorConfig;
use common::{BulbMap, ClientMessage, ServerMessage};
use futures_util::{SinkExt, StreamExt};
use lighter_manager::{manager::BulbsConfig, mqtt_conf::MqttConfig};
@@ -47,8 +45,6 @@ struct Opt {
pub struct Config {
mqtt: MqttConfig,
collectors: CollectorConfig,
persistence_dir: Option<PathBuf>,
#[serde(flatten)]
@@ -104,7 +100,6 @@ async fn main() {
let state = Box::leak(Box::new(state));
task::spawn(tasks::lights_task(state));
task::spawn(tasks::info_task(state));
let ws = warp::path("ws")
// The `ws()` filter will prepare the Websocket handshake.

View File

@@ -1,73 +0,0 @@
use std::time::Duration;
use common::ServerMessage;
use tokio::time::sleep;
use crate::{
collector::{Collector, MarkdownWeb, WeatherApi},
State,
};
pub async fn info_task(state: &State) {
let mut collectors: Vec<Box<dyn Collector + Send>> = vec![];
for url in &state.config.collectors.markdown_web_links {
collectors.push(Box::new(MarkdownWeb {
url: url.to_string(),
}));
}
if !state.config.collectors.weatherapi_locations.is_empty() {
let api_key = state
.config
.collectors
.weatherapi_key
.as_deref()
.expect("Missing weatherapi_key");
for location in state.config.collectors.weatherapi_locations.iter().cloned() {
collectors.push(Box::new(WeatherApi {
api_key: api_key.to_string(),
location,
}));
}
}
let mut collectors = collectors.into_boxed_slice();
if collectors.is_empty() {
return;
}
let server_message = &state.server_message;
let collectors_len = collectors.len();
let next = move |i: usize| (i + 1) % collectors_len;
let mut i = 0;
loop {
sleep(Duration::from_secs(30)).await;
// don't bother collecting if no clients are connected
// there is always 1 receiver held by main process
if server_message.receiver_count() <= 1 {
continue;
}
i = next(i);
let collector = &mut collectors[i];
let msg = match collector.collect().await {
Ok(html) => ServerMessage::InfoPage { html },
Err(e) => {
warn!("collector error: {e}");
continue;
}
};
if let Err(e) = server_message.send(msg) {
error!("broadcast channel error: {e}");
return;
}
}
}

View File

@@ -10,7 +10,7 @@ use tokio::select;
use crate::persistence::PersistenceFile;
use crate::State;
use self::scripts::{LightScript, Party, Waker};
use self::scripts::{LightScript, Party};
pub mod scripts;
@@ -35,10 +35,6 @@ pub async fn lights_task(state: &State) {
.expect("Failed to launch bulb manager");
let mut scripts: HashMap<ScriptId, Box<dyn LightScript + Send>> = Default::default();
scripts.insert(
"waker".to_string(),
Box::new(Waker::create(manager.clone())),
);
scripts.insert(
"party".to_string(),
Box::new(Party::create(manager.clone())),

View File

@@ -0,0 +1 @@

View File

@@ -1,60 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use common::{BulbPrefs, Param};
use lighter_lib::BulbId;
use lighter_manager::manager::BulbManager;
use std::sync::Mutex;
use crate::util::DeadMansHandle;
use super::LightScript;
pub struct Auto {
state: Arc<Mutex<AutoState>>,
_task_handle: DeadMansHandle,
}
struct AutoState {
manager: BulbManager,
enabled_bulbs: HashMap<BulbId, AutoBulbState>,
}
#[derive(Default)]
struct AutoBulbState {}
impl LightScript for Auto {
fn get_params(&mut self, bulb: &BulbId) -> BulbPrefs {
let state = self.state.lock().unwrap();
let enabled = state.enabled_bulbs.contains_key(bulb);
BulbPrefs {
kvs: [("Auto".to_string(), Param::Toggle(enabled))]
.into_iter()
.collect(),
}
}
fn set_param(&mut self, bulb: &BulbId, name: &str, param: Param) {
if name != "Auto" {
error!("invalid param name");
return;
}
let Param::Toggle(enabled) = param else {
error!("invalid param kind");
return;
};
let mut state = self.state.lock().unwrap();
if !enabled {
state.enabled_bulbs.remove(bulb);
} else {
let bulb_state = AutoBulbState::default();
state.enabled_bulbs.insert(bulb.clone(), bulb_state);
}
}
}
async fn auto_task(state: Arc<Mutex<AutoState>>) {
loop {}
}

View File

@@ -1,12 +1,8 @@
use common::{BulbPrefs, Param};
use lighter_lib::BulbId;
mod daylight;
mod party;
mod waker;
pub use daylight::Daylight;
pub use party::Party;
pub use waker::Waker;
pub trait LightScript {
fn get_params(&mut self, bulb: &BulbId) -> BulbPrefs;

View File

@@ -1,204 +0,0 @@
use std::{collections::HashMap, time::Duration};
use chrono::{DateTime, Datelike, Local, NaiveTime, Weekday};
use common::{BulbPrefs, Param};
use lighter_lib::{BulbColor, BulbId};
use lighter_manager::manager::{BulbCommand, BulbManager, BulbSelector};
use tokio::{spawn, time::sleep};
use crate::util::DeadMansHandle;
use super::LightScript;
pub struct Waker {
manager: BulbManager,
wake_times: HashMap<BulbId, HashMap<Weekday, NaiveTime>>,
wake_tasks: HashMap<(BulbId, Weekday), DeadMansHandle>,
}
impl Waker {
pub fn create(manager: BulbManager) -> Self {
Waker {
manager,
wake_times: Default::default(),
wake_tasks: Default::default(),
}
}
}
const TIME_FMT: &str = "%H:%M";
const WAKE_TARGET_BRIGHTNESS: u8 = 75;
const WAKE_TARGET_TEMPERATURE: u8 = 60;
impl LightScript for Waker {
fn get_params(&mut self, bulb: &BulbId) -> BulbPrefs {
let settings = self.wake_times.entry(bulb.clone()).or_default();
let kvs = DAYS_OF_WEEK
.iter()
.map(|day| {
let time = match settings.get(day) {
Some(time) => time.format(TIME_FMT).to_string(),
None => String::new(),
};
(format!("{day:?}"), Param::String(time))
})
.collect();
BulbPrefs { kvs }
}
fn set_param(&mut self, bulb: &BulbId, name: &str, time: super::Param) {
let settings = self.wake_times.entry(bulb.clone()).or_default();
let Param::String(time) = time else {
error!("invalit param kind");
return;
};
let time = NaiveTime::parse_from_str(&time, TIME_FMT)
.map(Some)
.unwrap_or(None);
let weekday = match name {
"Mon" => Weekday::Mon,
"Tue" => Weekday::Tue,
"Wed" => Weekday::Wed,
"Thu" => Weekday::Thu,
"Fri" => Weekday::Fri,
"Sat" => Weekday::Sat,
"Sun" => Weekday::Sun,
_ => {
error!("invalit param name");
return;
}
};
let Some(time) = time else {
settings.remove(&weekday);
self.wake_tasks.remove(&(bulb.clone(), weekday));
return;
};
settings.insert(weekday, time);
let task = spawn(wake_task(self.manager.clone(), bulb.clone(), weekday, time));
self.wake_tasks
.insert((bulb.clone(), weekday), DeadMansHandle(task.abort_handle()));
}
}
const DAYS_OF_WEEK: &[Weekday] = &[
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
Weekday::Sat,
Weekday::Sun,
];
async fn wake_task(manager: BulbManager, id: BulbId, day: Weekday, time: NaiveTime) {
let mut alarm = next_alarm(Local::now(), day, time);
loop {
info!("waking lamp {id:?} at {alarm}");
sleep((alarm - Local::now()).to_std().unwrap()).await;
if let Some(bulb) = manager.bulbs().await.get(&id) {
// don't wake the bulb if it's already turned on
if bulb.power {
continue;
}
} else {
warn!("bulb {id:?} does not exist");
return;
};
info!("waking lamp {id:?}");
let r = manager
.until_interrupted(id.clone(), async {
// slowly turn up brightness of bulb
for brightness in (1..=WAKE_TARGET_BRIGHTNESS).map(|i| (i as f32) * 0.01) {
sleep(Duration::from_secs(12)).await;
manager
.send_command(BulbCommand::SetColor(
BulbSelector::Id(id.clone()),
BulbColor::Kelvin {
t: 0.0,
b: brightness,
},
))
.await
}
// slowly turn up temperature of bulb
for temperature in (1..=WAKE_TARGET_TEMPERATURE).map(|i| (i as f32) * 0.01) {
sleep(Duration::from_secs(12)).await;
manager
.send_command(BulbCommand::SetColor(
BulbSelector::Id(id.clone()),
BulbColor::Kelvin {
t: temperature,
b: WAKE_TARGET_BRIGHTNESS as f32 * 0.01,
},
))
.await
}
})
.await;
if r.is_none() {
info!("interrupted waking lamp {id:?}");
}
alarm = next_alarm(Local::now(), day, time);
}
}
/// Get the next alarm, from a weekday+time schedule.
fn next_alarm(now: DateTime<Local>, day: Weekday, time: NaiveTime) -> DateTime<Local> {
let day_of_alarm = day.num_days_from_monday() as i64;
let day_now = now.weekday().num_days_from_monday() as i64;
let alarm = now + chrono::Duration::days(day_of_alarm - day_now);
let mut alarm = alarm
.date_naive()
.and_time(time)
.and_local_timezone(Local)
.unwrap();
if alarm <= now {
alarm += chrono::Duration::weeks(1);
}
alarm
}
#[cfg(test)]
mod test {
use super::next_alarm;
use chrono::{offset::TimeZone, Local, NaiveTime, Weekday};
#[test]
fn test_alarm_date() {
const FMT: &str = "%Y-%m-%d %H:%M";
let now = Local.datetime_from_str("2022-10-18 15:30", FMT).unwrap();
let test_values = [
(Weekday::Tue, (16, 30), "2022-10-18 16:30"),
(Weekday::Tue, (14, 30), "2022-10-25 14:30"),
(Weekday::Wed, (15, 30), "2022-10-19 15:30"),
(Weekday::Mon, (15, 30), "2022-10-24 15:30"),
];
for (day, (hour, min), expected) in test_values {
let expected = Local.datetime_from_str(expected, FMT).unwrap();
assert_eq!(
next_alarm(now, day, NaiveTime::from_hms(hour, min, 0)),
expected
);
}
}
}

View File

@@ -1,5 +1,3 @@
pub mod info;
pub mod lights;
pub use info::info_task;
pub use lights::lights_task;

View File

@@ -6,10 +6,6 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub enum ServerMessage {
InfoPage {
html: String,
},
/// Update the state of a bulb
BulbState {
id: BulbId,
@@ -23,7 +19,6 @@ pub enum ServerMessage {
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub enum ClientMessage {
//SubscribeToInfo,
//SubscribeToBulbs,
GetBulbs,
SetBulbColor {

View File

@@ -6,7 +6,7 @@ edition = "2021"
[dependencies]
seed = "0.10.0"
wasm-bindgen = "=0.2.87" # must match Trunk.toml
wasm-bindgen = "=0.2.121" # must match Trunk.toml
serde = { version = "1.0.0", features = ['derive'] }
ron = "0.7.1"
chrono = { version = "0.4.20", features = ["serde"] }

View File

@@ -1,8 +1,8 @@
[serve]
address = "0.0.0.0"
addresses = ["0.0.0.0"]
[tools]
wasm_bindgen = "0.2.87"
wasm_bindgen = "0.2.121"
[[proxy]]
# This WebSocket proxy example has a backend and ws field. This example will listen for

View File

@@ -42,9 +42,6 @@ pub enum Pages {
#[page("404", NotFound)]
NotFound(page::not_found::Model),
#[page("info", Info)]
Info(page::info::Model),
#[page("lights", Lights)]
Lights(page::lights::Model),
}
@@ -52,7 +49,6 @@ pub enum Pages {
#[derive(Debug)]
pub enum PageMsg {
NotFound(page::not_found::Msg),
Info(page::info::Msg),
Lights(page::lights::Msg),
}

View File

@@ -1,39 +0,0 @@
use common::ServerMessage;
use seed::prelude::*;
use seed::{div, raw};
use seed_router::Page;
#[derive(Default)]
pub struct Model {
content: String,
}
#[derive(Debug)]
pub enum Msg {
ServerMessage(ServerMessage),
}
impl Page for Model {
type Msg = Msg;
fn new(orders: &mut impl Orders<Self::Msg>) -> Self {
orders.subscribe(Msg::ServerMessage);
Model {
content: r#"<div class="penguin"></div>"#.into(),
}
}
fn update(&mut self, msg: Self::Msg, _orders: &mut impl Orders<Self::Msg>) {
match msg {
Msg::ServerMessage(ServerMessage::InfoPage { html }) => {
self.content = html;
}
Msg::ServerMessage(_) => {}
}
}
fn view(&self) -> Node<Self::Msg> {
div![raw![&self.content]]
}
}

View File

@@ -2,7 +2,7 @@ use crate::components::color_picker::{ColorPicker, ColorPickerMsg};
use crate::css::C;
use common::{BulbGroup, BulbGroupShape, BulbMap, BulbPrefs, ClientMessage, Param, ServerMessage};
use lighter_lib::{BulbId, BulbMode};
use seed::{attrs, button, div, empty, h1, h2, input, label, span, table, td, tr, C};
use seed::{attrs, button, div, empty, h1, input, label, span, table, td, tr, C};
use seed::{prelude::*, IF};
use seed_router::Page;
use std::collections::{BTreeMap, HashSet};
@@ -366,7 +366,6 @@ impl Page for Model {
div![
C![C.prefs_box],
IF!(selected_bulb.is_none() => C![C.cross_out]),
h2!["Settings"],
if let Some(selected_bulb) = selected_bulb {
table![selected_bulb
.prefs

View File

@@ -1,3 +1,2 @@
pub mod info;
pub mod lights;
pub mod not_found;