7 Commits
Author SHA1 Message Date
hulthe ff612220ab 1.0.1 2026-10-22 21:16:40 +02:00
hulthe 8974463e82 Update dependencies 2026-10-22 21:15:41 +02:00
hulthe 3948efd5db More filthy scale hacks 2026-05-16 00:52:28 +02:00
hulthe 5a449927e3 1.0.0 2026-05-15 16:24:55 +02:00
hulthe d839bd6df5 Remove waker 2026-05-15 16:05:52 +02:00
hulthe ab28ff5671 Fix dockerfile syntax 2026-05-15 15:56:19 +02:00
hulthe ba6ce926fc Purge collectors and info page 2026-05-15 15:46:42 +02:00
24 changed files with 781 additions and 1006 deletions

No files matched your search

Generated
+752 -444
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,10 +1,10 @@
##################
### BASE STAGE ###
##################
FROM rust:1.76.0 as base
FROM rust:1.99.1 AS base
# Install build dependencies
RUN cargo install --locked trunk@^0.18.8 strip_cargo_version
RUN cargo install --locked trunk@^0.22.0 strip_cargo_version
RUN rustup target add wasm32-unknown-unknown
RUN rustup target add x86_64-unknown-linux-musl
+3 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "hemma"
version = "0.3.3"
edition = "2021"
version = "1.0.1"
edition = "2024"
[dependencies]
futures-util = "0.3.21"
@@ -10,7 +10,7 @@ pretty_env_logger = "0.4.0"
tokio = { version = "1.19.2", features = ["full"] }
warp = "0.3.2"
ron = "0.7.1"
reqwest = { version = "0.11.11", default_features = false, features = ["rustls-tls", "json"] }
reqwest = { version = "0.11.11", default-features = false, features = ["rustls-tls", "json"] }
async-trait = "0.1.56"
anyhow = "1.0.58"
markdown = "0.3.0"
-11
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"
-20
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>,
}
-16
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)
}
}
-151
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)
}
-5
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.
-73
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;
}
}
}
+1 -5
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())),
+1
View File
@@ -0,0 +1 @@
-2
View File
@@ -2,9 +2,7 @@ use common::{BulbPrefs, Param};
use lighter_lib::BulbId;
mod party;
mod waker;
pub use party::Party;
pub use waker::Waker;
pub trait LightScript {
fn get_params(&mut self, bulb: &BulbId) -> BulbPrefs;
-204
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
);
}
}
}
-2
View File
@@ -1,5 +1,3 @@
pub mod info;
pub mod lights;
pub use info::info_task;
pub use lights::lights_task;
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "common"
version = "0.3.3"
edition = "2021"
version = "1.0.1"
edition = "2024"
[dependencies]
serde = { version = "1.0.137", features = ["derive"] }
-5
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 {
+4 -4
View File
@@ -1,17 +1,17 @@
[package]
name = "hemma_web"
version = "0.3.3"
authors = ["Joakim Hulthe <joakim@hulthe.net"]
edition = "2021"
version = "1.0.1"
edition = "2024"
[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"] }
gloo-net = { version = "0.4.0", default-features = false, features = ["websocket"] }
gloo-console = "0.3.0"
uuid = { version = "1.28.0", features = ["js"] }
[dependencies.css_typegen]
git = "https://github.com/hulthe/css_typegen.git"
+2 -2
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
+9 -9
View File
@@ -5,26 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- stylesheets -->
<link data-trunk data-inline rel="scss" href="/static/styles/common.scss">
<link data-trunk data-inline rel="scss" href="/static/styles/penguin.scss">
<link data-trunk data-inline rel="scss" href="static/styles/common.scss">
<link data-trunk data-inline rel="scss" href="static/styles/penguin.scss">
<!-- fonts -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
<!-- pwa manifest -->
<link data-trunk rel="copy-file" href="/static/manifest.json">
<link rel="manifest" href="/manifest.json">
<link data-trunk rel="copy-file" href="static/manifest.json">
<link rel="manifest" href="manifest.json">
<!-- copy image directory -->
<link data-trunk rel="copy-dir" href="/static/images">
<link data-trunk rel="copy-dir" href="static/images">
<!-- image preloading -->
<link rel="preload" href="/images/penguin1.svg" as="image">
<link rel="preload" href="/images/penguin2.svg" as="image">
<link rel="preload" href="/images/penguin3.svg" as="image">
<link rel="preload" href="images/penguin1.svg" as="image">
<link rel="preload" href="images/penguin2.svg" as="image">
<link rel="preload" href="images/penguin3.svg" as="image">
<!-- icon -->
<link rel="icon" type="image/png" href="/images/penguin2.svg">
<link rel="icon" type="image/png" href="images/penguin2.svg">
<title>hemma</title>
<meta name="description" content="Home automation and information">
-4
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),
}
-39
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]]
}
}
+1 -2
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
-1
View File
@@ -1,3 +1,2 @@
pub mod info;
pub mod lights;
pub mod not_found;
+4
View File
@@ -28,6 +28,10 @@ body {
}
}
@media (width <= 390px) { body { scale: 0.85; } }
@media (width <= 360px) { body { scale: 0.80; } }
@media (width <= 340px) { body { scale: 0.75; } }
@media (width <= 320px) { body { scale: 0.70; } }
@media (width <= 300px) { body { scale: 0.65; } }
.info_box {
margin: auto;