12 Commits
pwa ... cleanup

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
428e75488d Typos and todos 2024-08-09 10:15:18 +02:00
86918e69c3 0.3.3 2024-03-27 08:59:18 +01:00
2af82804c0 Tweak light waker behaviour 2024-03-27 08:58:25 +01:00
35f3aa76f6 0.3.2 2024-02-25 14:05:55 +01:00
8bd16b6755 Add alternate bulb list view 2024-02-25 14:04:59 +01:00
558bf4782b Update dockerfile rust version 2024-02-24 09:58:27 +01:00
c278e0f830 0.3.1 2024-02-23 23:45:53 +01:00
38f5dd1b71 Update deps 2024-02-23 23:44:28 +01:00
714b31ea31 0.3.0 2023-12-05 16:14:40 +01:00
27 changed files with 563 additions and 797 deletions

500
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,3 @@ lto = true
opt-level = 's' opt-level = 's'
# Issue with const-generics # Issue with const-generics
incremental = false incremental = false
[patch."https://git.nubo.sh/hulthe/lighter.git"]
lighter_manager = { path = "../lighter/manager" }
lighter_lib = { path = "../lighter/lib" }

View File

@@ -1,10 +1,10 @@
################## ##################
### BASE STAGE ### ### BASE STAGE ###
################## ##################
FROM rust:1.72.1 as base FROM rust:1.95.0 AS base
# Install build dependencies # Install build dependencies
RUN cargo install --locked cargo-make trunk 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 wasm32-unknown-unknown
RUN rustup target add x86_64-unknown-linux-musl RUN rustup target add x86_64-unknown-linux-musl

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "hemma" name = "hemma"
version = "0.2.3" version = "0.3.3"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -6,17 +6,6 @@ persistence_dir = "/tmp/"
#username = "user" #username = "user"
#password = "password" #password = "password"
[collectors]
markdown_web_links = [
"https://example.org/lmao.md"
]
#weatherapi_key = ""
#weatherapi_locations = [
# "London",
#]
[[bulbs]] [[bulbs]]
id = "light/bedroom" 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 persistence;
mod tasks; mod tasks;
mod util; mod util;
use clap::Parser; use clap::Parser;
use collector::CollectorConfig;
use common::{BulbMap, ClientMessage, ServerMessage}; use common::{BulbMap, ClientMessage, ServerMessage};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use lighter_manager::{manager::BulbsConfig, mqtt_conf::MqttConfig}; use lighter_manager::{manager::BulbsConfig, mqtt_conf::MqttConfig};
@@ -47,8 +45,6 @@ struct Opt {
pub struct Config { pub struct Config {
mqtt: MqttConfig, mqtt: MqttConfig,
collectors: CollectorConfig,
persistence_dir: Option<PathBuf>, persistence_dir: Option<PathBuf>,
#[serde(flatten)] #[serde(flatten)]
@@ -104,7 +100,6 @@ async fn main() {
let state = Box::leak(Box::new(state)); let state = Box::leak(Box::new(state));
task::spawn(tasks::lights_task(state)); task::spawn(tasks::lights_task(state));
task::spawn(tasks::info_task(state));
let ws = warp::path("ws") let ws = warp::path("ws")
// The `ws()` filter will prepare the Websocket handshake. // 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::persistence::PersistenceFile;
use crate::State; use crate::State;
use self::scripts::{LightScript, Party, Waker}; use self::scripts::{LightScript, Party};
pub mod scripts; pub mod scripts;
@@ -35,10 +35,6 @@ pub async fn lights_task(state: &State) {
.expect("Failed to launch bulb manager"); .expect("Failed to launch bulb manager");
let mut scripts: HashMap<ScriptId, Box<dyn LightScript + Send>> = Default::default(); let mut scripts: HashMap<ScriptId, Box<dyn LightScript + Send>> = Default::default();
scripts.insert(
"waker".to_string(),
Box::new(Waker::create(manager.clone())),
);
scripts.insert( scripts.insert(
"party".to_string(), "party".to_string(),
Box::new(Party::create(manager.clone())), Box::new(Party::create(manager.clone())),

View File

@@ -0,0 +1 @@

View File

@@ -2,9 +2,7 @@ use common::{BulbPrefs, Param};
use lighter_lib::BulbId; use lighter_lib::BulbId;
mod party; mod party;
mod waker;
pub use party::Party; pub use party::Party;
pub use waker::Waker;
pub trait LightScript { pub trait LightScript {
fn get_params(&mut self, bulb: &BulbId) -> BulbPrefs; fn get_params(&mut self, bulb: &BulbId) -> BulbPrefs;

View File

@@ -37,13 +37,12 @@ impl LightScript for Party {
fn set_param(&mut self, bulb: &BulbId, name: &str, param: Param) { fn set_param(&mut self, bulb: &BulbId, name: &str, param: Param) {
if name != "Party" { if name != "Party" {
error!("invalit param name"); error!("invalid param name");
return; return;
} }
// TODO: should be toggle
let Param::Toggle(enabled) = param else { let Param::Toggle(enabled) = param else {
error!("invalit param kind"); error!("invalid param kind");
return; return;
}; };

View File

@@ -1,188 +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";
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..=75).map(|i| (i as f32) * 0.01) {
//sleep(Duration::from_secs(12)).await;
sleep(Duration::from_millis(500)).await;
manager
.send_command(BulbCommand::SetColor(
BulbSelector::Id(id.clone()),
BulbColor::Kelvin {
t: 0.0,
b: brightness,
},
))
.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 mod lights;
pub use info::info_task;
pub use lights::lights_task; pub use lights::lights_task;

View File

@@ -1,5 +1,6 @@
use tokio::task::AbortHandle; use tokio::task::AbortHandle;
/// A tokio task handle that will abort the task when dropped.
pub struct DeadMansHandle(pub AbortHandle); pub struct DeadMansHandle(pub AbortHandle);
impl From<AbortHandle> for DeadMansHandle { impl From<AbortHandle> for DeadMansHandle {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "common" name = "common"
version = "0.2.3" version = "0.3.3"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

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

View File

@@ -1,12 +1,12 @@
[package] [package]
name = "hemma_web" name = "hemma_web"
version = "0.2.3" version = "0.3.3"
authors = ["Joakim Hulthe <joakim@hulthe.net"] authors = ["Joakim Hulthe <joakim@hulthe.net"]
edition = "2021" edition = "2021"
[dependencies] [dependencies]
seed = "0.10.0" 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'] } serde = { version = "1.0.0", features = ['derive'] }
ron = "0.7.1" ron = "0.7.1"
chrono = { version = "0.4.20", features = ["serde"] } chrono = { version = "0.4.20", features = ["serde"] }

View File

@@ -1,8 +1,8 @@
[serve] [serve]
address = "0.0.0.0" addresses = ["0.0.0.0"]
[tools] [tools]
wasm_bindgen = "0.2.87" wasm_bindgen = "0.2.121"
[[proxy]] [[proxy]]
# This WebSocket proxy example has a backend and ws field. This example will listen for # 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)] #[page("404", NotFound)]
NotFound(page::not_found::Model), NotFound(page::not_found::Model),
#[page("info", Info)]
Info(page::info::Model),
#[page("lights", Lights)] #[page("lights", Lights)]
Lights(page::lights::Model), Lights(page::lights::Model),
} }
@@ -52,7 +49,6 @@ pub enum Pages {
#[derive(Debug)] #[derive(Debug)]
pub enum PageMsg { pub enum PageMsg {
NotFound(page::not_found::Msg), NotFound(page::not_found::Msg),
Info(page::info::Msg),
Lights(page::lights::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 crate::css::C;
use common::{BulbGroup, BulbGroupShape, BulbMap, BulbPrefs, ClientMessage, Param, ServerMessage}; use common::{BulbGroup, BulbGroupShape, BulbMap, BulbPrefs, ClientMessage, Param, ServerMessage};
use lighter_lib::{BulbId, BulbMode}; use lighter_lib::{BulbId, BulbMode};
use seed::{attrs, button, div, empty, h2, input, table, td, tr, C}; use seed::{attrs, button, div, empty, h1, input, label, span, table, td, tr, C};
use seed::{prelude::*, IF}; use seed::{prelude::*, IF};
use seed_router::Page; use seed_router::Page;
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
@@ -14,11 +14,16 @@ use std::iter::repeat;
pub struct Model { pub struct Model {
bulb_states: BTreeMap<BulbId, BulbState>, bulb_states: BTreeMap<BulbId, BulbState>,
select_mode: SelectMode,
bulb_map: BulbMap, bulb_map: BulbMap,
/// the currently selected bulb map groups /// the currently selected bulbs on the map
selected_groups: HashSet<usize>, selected_groups: HashSet<usize>,
/// the currently selected bulbs on the list
selected_bulbs: HashSet<BulbId>,
/// whether the currently selected map groups have been interacted with /// whether the currently selected map groups have been interacted with
groups_interacted: bool, groups_interacted: bool,
@@ -37,6 +42,9 @@ pub enum Msg {
SelectGroup(usize), SelectGroup(usize),
DeselectGroups, DeselectGroups,
SelectBulb(BulbId),
ColorPicker(ColorPickerMsg), ColorPicker(ColorPickerMsg),
SetBulbPower(bool), SetBulbPower(bool),
@@ -46,6 +54,15 @@ pub enum Msg {
name: String, name: String,
value: Param, value: Param,
}, },
SetSelectMode(SelectMode),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SelectMode {
#[default]
Map,
List,
} }
impl Page for Model { impl Page for Model {
@@ -70,8 +87,6 @@ impl Page for Model {
mode: new_mode, mode: new_mode,
prefs, prefs,
}; };
//color_picker.set_color(mode.color);
} }
ServerMessage::BulbMap(bulb_map) => { ServerMessage::BulbMap(bulb_map) => {
self.bulb_map = bulb_map; self.bulb_map = bulb_map;
@@ -106,6 +121,11 @@ impl Page for Model {
} }
} }
} }
Msg::SelectBulb(bulb) => {
if !self.selected_bulbs.remove(&bulb) {
self.selected_bulbs.insert(bulb);
}
}
Msg::ColorPicker(ColorPickerMsg::SetColor(color)) => { Msg::ColorPicker(ColorPickerMsg::SetColor(color)) => {
self.groups_interacted = true; self.groups_interacted = true;
self.for_selected_bulbs(|id, _| { self.for_selected_bulbs(|id, _| {
@@ -150,36 +170,13 @@ impl Page for Model {
orders.notify(message); orders.notify(message);
}); });
} }
Msg::SetSelectMode(mode) => {
self.select_mode = mode;
}
} }
} }
fn view(&self) -> Node<Self::Msg> { fn view(&self) -> Node<Self::Msg> {
//let view_bulb = |(id, (mode, color_picker)): (&BulbId, &(BulbMode, ColorPicker))| {
// div![
// C![C.bulb_box],
// h1![id],
// div![
// C![C.bulb_controls],
// {
// let id = id.clone();
// color_picker.view().map_msg(|msg| Msg::ColorPicker(id, msg))
// },
// button![
// if mode.power {
// C![C.bulb_power_button, C.bulb_power_button_on]
// } else {
// C![C.bulb_power_button]
// },
// {
// let id = id.clone();
// let power = !mode.power;
// ev(Ev::Click, move |_| Msg::SetBulbPower(id, power))
// },
// ],
// ],
// ]
//};
let bulb_map_width = self let bulb_map_width = self
.bulb_map .bulb_map
.groups .groups
@@ -196,7 +193,7 @@ impl Page for Model {
.max() .max()
.unwrap_or(0); .unwrap_or(0);
let view_bulb_group = |(i, group): (usize, &BulbGroup)| { let bulb_group_map = |(i, group): (usize, &BulbGroup)| {
let (w, h) = (group.shape.width(), group.shape.height()); let (w, h) = (group.shape.width(), group.shape.height());
let mut style = String::new(); let mut style = String::new();
write!( write!(
@@ -227,13 +224,51 @@ impl Page for Model {
] ]
}; };
let selected_bulb = self let bulb_group_list = |(_i, group): (usize, &BulbGroup)| {
.selected_groups div![
.iter() C![C.bulb_list_group],
.next() h1![&group.name],
.and_then(|&index| self.bulb_map.groups.get(index)) group.bulbs.iter().map(|bulb| {
.and_then(|group| group.bulbs.first()) let select_ev = || {
.and_then(|id| self.bulb_states.get(id)); let bulb = bulb.clone();
ev(Ev::Click, |_| Msg::SelectBulb(bulb))
};
button![
C![C.bulb_list_bulb],
select_ev(),
div![
C![C.bulb_list_checkbox],
label![
C![C.container],
input![
attrs! { At::Type => "checkbox" },
IF!(self.selected_bulbs.contains(bulb) => attrs! { At::Checked => true }),
select_ev(),
],
div![C![C.checkmark]],
],
],
span![bulb],
]
}),
]
};
// pick one (arbitrary) selected bulb to pull values for the controls from
let selected_bulb = if let SelectMode::Map = self.select_mode {
self.selected_groups
.iter()
.next()
.and_then(|&index| self.bulb_map.groups.get(index))
.and_then(|group| group.bulbs.first())
.and_then(|id| self.bulb_states.get(id))
} else {
self.selected_bulbs
.iter()
.next()
.and_then(|id| self.bulb_states.get(id))
};
let script_param = |script: &str, name: &str, value: &Param| { let script_param = |script: &str, name: &str, value: &Param| {
let name = name.to_string(); let name = name.to_string();
@@ -278,13 +313,35 @@ impl Page for Model {
div![ div![
C![C.bulb_box], C![C.bulb_box],
div![ div![
C![C.bulb_map], C![C.selector_selector],
attrs! { button![
At::Style => format!("min-width: {}rem; height: {}rem;", bulb_map_width, bulb_map_height), "Map",
}, ev(Ev::Click, move |_| Msg::SetSelectMode(SelectMode::Map))
ev(Ev::Click, |_| Msg::DeselectGroups), ],
self.bulb_map.groups.iter().enumerate().map(view_bulb_group), div![
C![C.selector_selector_arrow],
IF!(self.select_mode == SelectMode::Map =>
attrs! { At::Style => "transform: rotateY(180deg);"}),
],
button![
"List",
ev(Ev::Click, move |_| Msg::SetSelectMode(SelectMode::List))
],
], ],
match self.select_mode {
SelectMode::Map => div![
C![C.bulb_map],
attrs! {
At::Style => format!("min-width: {}rem; height: {}rem;", bulb_map_width, bulb_map_height),
},
ev(Ev::Click, |_| Msg::DeselectGroups),
self.bulb_map.groups.iter().enumerate().map(bulb_group_map),
],
SelectMode::List => div![
C![C.bulb_list],
self.bulb_map.groups.iter().enumerate().map(bulb_group_list),
],
},
div![ div![
C![C.bulb_controls], C![C.bulb_controls],
IF!(selected_bulb.is_none() => C![C.cross_out]), IF!(selected_bulb.is_none() => C![C.cross_out]),
@@ -309,7 +366,6 @@ impl Page for Model {
div![ div![
C![C.prefs_box], C![C.prefs_box],
IF!(selected_bulb.is_none() => C![C.cross_out]), IF!(selected_bulb.is_none() => C![C.cross_out]),
h2!["Settings"],
if let Some(selected_bulb) = selected_bulb { if let Some(selected_bulb) = selected_bulb {
table![selected_bulb table![selected_bulb
.prefs .prefs
@@ -326,15 +382,23 @@ impl Page for Model {
impl Model { impl Model {
fn for_selected_bulbs(&mut self, mut f: impl FnMut(&BulbId, &mut BulbState)) { fn for_selected_bulbs(&mut self, mut f: impl FnMut(&BulbId, &mut BulbState)) {
for &index in &self.selected_groups { if let SelectMode::Map = self.select_mode {
let Some(group) = self.bulb_map.groups.get(index) else { for &index in &self.selected_groups {
continue; let Some(group) = self.bulb_map.groups.get(index) else {
}; continue;
};
for id in group.bulbs.iter() { for id in group.bulbs.iter() {
if let Some(bulb) = self.bulb_states.get_mut(id) {
f(id, bulb);
}
}
}
} else {
for id in &self.selected_bulbs {
if let Some(bulb) = self.bulb_states.get_mut(id) { if let Some(bulb) = self.bulb_states.get_mut(id) {
f(id, bulb); f(id, bulb);
} };
} }
} }
} }

View File

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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
<ellipse style="vector-effect: non-scaling-stroke; stroke-width: 0px; stroke: rgb(245, 222, 179); fill: rgb(245, 222, 179);" cx="250" cy="250" rx="250" ry="250"/>
<path d="M 59.4 209.201 H 308.253 L 274.16 131.459 L 447.051 254.953 L 274.16 378.446 L 308.253 300.704 H 59.4 V 209.201 Z" style="transform-origin: 447.051px 254.952px; stroke-width: 16px; stroke: rgb(193, 168, 123);" bx:shape="arrow 59.4 131.459 387.651 246.987 91.503 172.891 34.093 1@d4e47a6f"/>
</svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -102,6 +102,42 @@ body {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
/* lol i'm so funny */
.selector_selector {
margin-bottom: 0;
display: flex;
justify-content: center;
//background: #534f44;
}
.selector_selector > button {
width: 10rem;
border: solid 0.1rem wheat;
background-color: #3a3743;
color: white;
font-size: 1.5rem;
border-left: solid white 0.1rem;
border-right: solid white 0.1rem;
}
.selector_selector > button:hover {
background-color: #4c4858;
}
.selector_selector > button:active {
background-color: #312e38;
}
.selector_selector > button:first-child {
border-top-left-radius: 0.5rem;
}
.selector_selector > button:last-child {
border-top-right-radius: 0.5rem;
}
.selector_selector_arrow {
position: absolute;
width: 2.15rem;
height: 2.15rem;
background: url("/images/circle-arrow.svg");
transition: transform 0.5s ease-in-out;
}
.bulb_controls { .bulb_controls {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -150,6 +186,47 @@ body {
} }
@keyframes to_width_90 { to { width: 90%; } } @keyframes to_width_90 { to { width: 90%; } }
.bulb_list {
display: flex;
flex-direction: column;
}
.bulb_list_group {
display: flex;
flex-direction: column;
}
.bulb_list_group > h1 {
margin-top: 0.6em;
margin-bottom: 0;
font-size: 1.3em;
border-bottom: solid 1px;
}
.bulb_list_bulb {
font-family: Ubuntu Mono;
display: flex;
font-size: 0.8em;
padding-top: 0.5em;
padding-bottom: 0.5em;
margin-top: 0.3em;
background-color: #3a3743;
border: solid 0.2em #45374f;
border-radius: 0.5em;
color: wheat;
}
.bulb_list_bulb:hover {
background-color: #4c4858;
}
.bulb_list_bulb:active {
background-color: #312e38;
}
.bulb_list_bulb > span {
margin: auto;
flex-grow: 1;
padding-right: 1em;
}
.bulb_map { .bulb_map {
background: url(images/blueprint_bg.png); background: url(images/blueprint_bg.png);
background-size: auto; background-size: auto;
@@ -371,3 +448,65 @@ body {
flex-shrink: 1; flex-shrink: 1;
} }
.bulb_list_checkbox {}
.bulb_list_checkbox input[type="checkbox"] {
visibility: hidden;
display: none;
}
.bulb_list_checkbox *,
.bulb_list_checkbox ::after,
.bulb_list_checkbox ::before {
box-sizing: border-box;
}
.bulb_list_checkbox .container {
display: block;
position: relative;
cursor: pointer;
font-size: 25px;
user-select: none;
}
/* Create a custom checkbox */
.bulb_list_checkbox .checkmark {
position: relative;
top: 0;
left: 0;
height: 1.3em;
width: 1.3em;
background: black;
border-radius: 50px;
transition: all 0.7s;
--spread: 10px;
}
/* When the checkbox is checked, add a blue background */
.bulb_list_checkbox .container input:checked ~ .checkmark {
background: black;
/* spawn a bunch of colored balls and blur them together */
box-shadow: -5px -5px var(--spread) 0px #5B51D8, 0 -5px var(--spread) 0px #833AB4, 5px -5px var(--spread) 0px #E1306C, 5px 0 var(--spread) 0px #FD1D1D, 5px 5px var(--spread) 0px #F77737, 0 5px var(--spread) 0px #FCAF45, -5px 5px var(--spread) 0px #FFDC80;
}
/* Create the checkmark/indicator (hidden when not checked) */
.bulb_list_checkbox .checkmark::after {
content: "";
position: absolute;
display: none;
}
/* Show the checkmark when checked */
.bulb_list_checkbox .container input:checked ~ .checkmark::after {
display: block;
}
/* Style the checkmark/indicator */
.bulb_list_checkbox .container .checkmark::after {
left: 0.5em;
top: 0.34em;
width: 0.25em;
height: 0.5em;
border: solid wheat;
border-width: 0 0.15em 0.15em 0;
transform: rotate(45deg);
}