15 Commits

13 changed files with 902 additions and 485 deletions

450
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "hemma"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[dependencies]
@ -25,8 +25,7 @@ path = "../common"
[dependencies.lighter_lib]
git = "https://git.nubo.sh/hulthe/lighter.git"
#path = "../../lighter/lib"
[dependencies.lighter_manager]
git = "https://git.nubo.sh/hulthe/lighter.git"
#path = "../../lighter/manager"

View File

@ -1,3 +1,5 @@
persistence_dir = "/tmp/"
[mqtt]
#address = "hostname"
#port = 1883

View File

@ -1,21 +1,20 @@
mod collector;
mod persistence;
mod tasks;
use clap::Parser;
use collector::{Collector, CollectorConfig, MarkdownWeb, WeatherApi};
use collector::CollectorConfig;
use common::{BulbMap, ClientMessage, ServerMessage};
use futures_util::{SinkExt, StreamExt};
use lighter_manager::{
manager::{BulbCommand, BulbManager, BulbSelector, BulbsConfig},
mqtt_conf::MqttConfig,
};
use lighter_manager::{manager::BulbsConfig, mqtt_conf::MqttConfig};
use log::LevelFilter;
use persistence::Persistence;
use serde::Deserialize;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::PathBuf;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, mpsc};
use tokio::time::{sleep, Duration};
use tokio::{fs, select, task};
use warp::ws::{self, WebSocket};
use warp::{Filter, Rejection, Reply};
@ -44,11 +43,13 @@ struct Opt {
}
#[derive(Deserialize)]
struct Config {
pub struct Config {
mqtt: MqttConfig,
collectors: CollectorConfig,
persistence_dir: Option<PathBuf>,
#[serde(flatten)]
bulbs: BulbsConfig,
@ -56,8 +57,9 @@ struct Config {
bulb_map: BulbMap,
}
struct State {
pub struct State {
config: Config,
persistence: Persistence,
client_message: broadcast::Sender<ClientRequest>,
server_message: broadcast::Sender<ServerMessage>,
}
@ -88,14 +90,20 @@ async fn main() {
let (client_message, _) = broadcast::channel(100);
let state = State {
config,
client_message,
server_message,
persistence: match &config.persistence_dir {
Some(path) => Persistence::new_persistence(path.to_owned())
.await
.expect("Failed to open persistence dir"),
None => Persistence::new().await,
},
config,
};
let state = Box::leak(Box::new(state));
task::spawn(info_collector(state));
task::spawn(lights_collector(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.
@ -129,123 +137,6 @@ struct ClientRequest {
response: mpsc::Sender<ServerMessage>,
}
async fn lights_collector(state: &State) {
let config = &state.config;
let server_message = &state.server_message;
let mut client_message = state.client_message.subscribe();
let (cmd, bulb_states) = BulbManager::launch(config.bulbs.clone(), config.mqtt.clone())
.await
.expect("Failed to launch bulb manager");
loop {
let notify = bulb_states.notify_on_change();
sleep(Duration::from_millis(1000 / 10)).await; // limit to 10 updates/second
select! {
_ = notify => {
for (id, mode) in bulb_states.bulbs().await.clone().into_iter() {
if let Err(e) = server_message.send(ServerMessage::BulbMode { id, mode }) {
error!("broadcast channel error: {e}");
return;
}
}
}
request = client_message.recv() => {
let request = match request {
Ok(r) => r,
Err(_) => continue,
};
match request.message {
ClientMessage::SetBulbColor { id, color } => {
if let Err(e) = cmd.send(BulbCommand::SetColor(BulbSelector::Id(id), color)).await {
error!("bulb manager error: {e}");
}
}
ClientMessage::SetBulbPower { id, power } => {
if let Err(e) = cmd.send(BulbCommand::SetPower(BulbSelector::Id(id), power)).await {
error!("bulb manager error: {e}");
}
}
ClientMessage::GetBulbs => {
if let Err(e) = request.response.send(ServerMessage::BulbMap(config.bulb_map.clone())).await {
error!("GetBulbs response channel error: {e}");
return;
}
for (id, mode) in bulb_states.bulbs().await.clone().into_iter() {
if let Err(e) = request.response.send(ServerMessage::BulbMode { id, mode }).await {
error!("GetBulbs response channel error: {e}");
return;
}
}
}
_ => {}
}
}
}
}
}
async fn info_collector(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();
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;
}
}
}
async fn client_handler(mut socket: WebSocket, state: &State) {
let mut server_message = state.server_message.subscribe();
let (server_responder, mut server_responses) = mpsc::channel(100);
@ -257,7 +148,7 @@ async fn client_handler(mut socket: WebSocket, state: &State) {
};
if let Err(e) = socket.send(ws::Message::text(message)).await {
return warn!("client error: {e}");
warn!("client error: {e}");
}
}

111
backend/src/persistence.rs Normal file
View File

@ -0,0 +1,111 @@
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use serde::{de::DeserializeOwned, Serialize};
use tokio::{
fs::{self, File},
io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
};
#[derive(Clone)]
pub struct Persistence {
state: Arc<PersistenceState>,
}
pub struct PersistenceFile<S> {
content: S,
file: Option<File>,
}
enum PersistenceState {
NoPersistence,
Persistence { directory: PathBuf },
}
impl Persistence {
pub async fn new() -> Persistence {
Persistence {
state: Arc::new(PersistenceState::NoPersistence),
}
}
pub async fn new_persistence(directory: PathBuf) -> io::Result<Persistence> {
let _ = fs::read_dir(&directory).await?;
Ok(Persistence {
state: Arc::new(PersistenceState::Persistence { directory }),
})
}
pub async fn open<S: Default + Serialize + DeserializeOwned>(
&self,
name: String,
) -> Result<PersistenceFile<S>, io::Error> {
Ok(match &*self.state {
PersistenceState::NoPersistence => PersistenceFile {
content: S::default(),
file: None,
},
PersistenceState::Persistence { directory } => {
let file_path = directory.join(&name);
let mut file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(file_path)
.await?;
let mut content = String::new();
file.read_to_string(&mut content).await?;
let content = ron::from_str(&content).unwrap_or_else(|_| {
info!("Failed to load persistence {name}, using default");
S::default()
});
PersistenceFile {
content,
file: Some(file),
}
}
})
}
}
impl<S> PersistenceFile<S>
where
S: Serialize,
{
pub fn get(&self) -> &S {
&self.content
}
pub async fn set(&mut self, s: S) -> anyhow::Result<()> {
self.content = s;
if let Some(file) = &mut self.file {
let serialized = ron::ser::to_string_pretty(&self.content, Default::default())?;
file.rewind().await?;
file.set_len(0).await?;
file.write_all(serialized.as_bytes()).await?;
}
Ok(())
}
pub async fn update(&mut self, f: impl FnOnce(&mut S)) -> anyhow::Result<()>
where
S: Clone + PartialEq,
{
let mut new = self.content.clone();
f(&mut new);
if new != self.content {
self.set(new).await?;
}
Ok(())
}
}

73
backend/src/tasks/info.rs Normal file
View File

@ -0,0 +1,73 @@
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;
}
}
}

241
backend/src/tasks/lights.rs Normal file
View File

@ -0,0 +1,241 @@
use std::collections::HashMap;
use chrono::{DateTime, Datelike, Local, NaiveTime, Weekday};
use common::{ClientMessage, ServerMessage};
use lighter_lib::{BulbColor, BulbId};
use lighter_manager::manager::{BulbCommand, BulbManager, BulbSelector};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::select;
use tokio::sync::{broadcast, mpsc};
use tokio::task::{spawn, JoinHandle};
use tokio::time::sleep;
use crate::persistence::PersistenceFile;
use crate::{ClientRequest, State};
#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct LightsState {
wake_schedule: HashMap<BulbId, HashMap<Weekday, NaiveTime>>,
}
pub async fn lights_task(state: &State) {
let config = &state.config;
let server_message = &state.server_message;
let mut client_message = state.client_message.subscribe();
let mut lights_state: PersistenceFile<LightsState> = state
.persistence
.open("lights".into())
.await
.expect("Failed to open lights config");
let (cmd, bulb_states) = BulbManager::launch(config.bulbs.clone(), config.mqtt.clone())
.await
.expect("Failed to launch bulb manager");
let mut wake_tasks: HashMap<(BulbId, Weekday), JoinHandle<()>> = lights_state
.get()
.wake_schedule
.iter()
.flat_map(|(bulb, schedule)| schedule.iter().map(move |(day, time)| (bulb, day, time)))
.map(|(bulb, day, time)| {
let handle = spawn(wake_task(
state.client_message.clone(),
cmd.clone(),
bulb.clone(),
*day,
*time,
));
((bulb.clone(), *day), handle)
})
.collect();
loop {
let notify = bulb_states.notify_on_change();
sleep(tokio::time::Duration::from_millis(1000 / 10)).await; // limit to 10 updates/second
select! {
_ = notify => {
let lights_state = lights_state.get();
for (id, mode) in bulb_states.bulbs().await.clone().into_iter() {
let wake_schedule = lights_state.wake_schedule.get(&id).cloned().unwrap_or_default();
let msg = ServerMessage::BulbState { id, mode, wake_schedule };
if let Err(e) = server_message.send(msg) {
error!("broadcast channel error: {e}");
return;
}
}
}
request = client_message.recv() => {
let request = match request {
Ok(r) => r,
Err(_) => continue,
};
match request.message {
ClientMessage::SetBulbColor { id, color } => {
if let Err(e) = cmd.send(BulbCommand::SetColor(BulbSelector::Id(id), color)).await {
error!("bulb manager error: {e}");
}
}
ClientMessage::SetBulbPower { id, power } => {
if let Err(e) = cmd.send(BulbCommand::SetPower(BulbSelector::Id(id), power)).await {
error!("bulb manager error: {e}");
}
}
ClientMessage::GetBulbs => {
if let Err(e) = request.response.send(ServerMessage::BulbMap(config.bulb_map.clone())).await {
error!("GetBulbs response channel error: {e}");
return;
}
let lights_state = lights_state.get();
for (id, mode) in bulb_states.bulbs().await.clone().into_iter() {
let wake_schedule = lights_state.wake_schedule.get(&id).cloned().unwrap_or_default();
let msg = ServerMessage::BulbState { id, mode, wake_schedule };
if let Err(e) = request.response.send(msg).await {
error!("GetBulbs response channel error: {e}");
return;
}
}
}
ClientMessage::SetBulbWakeTime { id, day, time } => {
if let Err(e) = lights_state.update(|lights_state| {
let schedule = lights_state.wake_schedule.entry(id.clone()).or_default();
if let Some(time) = time {
schedule.insert(day, time);
}
else {
schedule.remove(&day);
}
}).await {
error!("Failed to save wake schedule: {e}");
};
if let Some(time) = time {
let handle = spawn(wake_task(
state.client_message.clone(),
cmd.clone(),
id.clone(),
day,
time,
));
if let Some(old_handle) = wake_tasks.insert((id, day), handle) {
old_handle.abort();
}} else {
if let Some(old_handle) = wake_tasks.remove(&(id, day)) {
old_handle.abort();
}
}
}
_ => {}
}
}
}
}
}
async fn wake_task(
client_messages: broadcast::Sender<ClientRequest>,
cmd: mpsc::Sender<BulbCommand>,
id: BulbId,
day: Weekday,
time: NaiveTime,
) {
let mut alarm = next_alarm(Local::now(), day, time);
loop {
info!("sleeping until {alarm}");
sleep((alarm - Local::now()).to_std().unwrap()).await;
// slowly turn up brightness of bulb
for brightness in (1..=75).map(|i| (i as f32) * 0.01) {
select! {
// abort if the client pokes the bulb
_ = wait_for_bulb_command(&id, client_messages.subscribe()) => break,
_ = sleep(Duration::from_secs(12)) => {}
};
if cmd
.send(BulbCommand::SetColor(
BulbSelector::Id(id.clone()),
BulbColor::Kelvin {
t: 0.0,
b: brightness,
},
))
.await
.is_err()
{
return;
};
}
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().and_time(time).unwrap();
if alarm <= now {
alarm += chrono::Duration::weeks(1);
}
alarm
}
/// Wait until we receive a client request that mutates the given bulb
async fn wait_for_bulb_command(
bulb_id: &BulbId,
mut client_messages: broadcast::Receiver<ClientRequest>,
) {
loop {
match client_messages.recv().await {
Err(_) => return,
Ok(request) => match request.message {
ClientMessage::SetBulbColor { id, .. }
| ClientMessage::SetBulbPower { id, .. }
| ClientMessage::SetBulbWakeTime { id, .. }
if &id == bulb_id =>
{
break
}
_ => continue,
},
}
}
}
#[cfg(test)]
mod test {
use chrono::{offset::TimeZone, Local, NaiveTime, Weekday};
use super::next_alarm;
#[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
);
}
}
}

5
backend/src/tasks/mod.rs Normal file
View File

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

View File

@ -1,10 +1,11 @@
[package]
name = "common"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[dependencies]
serde = { version = "1.0.137", features = ["derive"] }
chrono = { version = "0.4.20", features = ["serde"] }
[dependencies.lighter_lib]
git = "https://git.nubo.sh/hulthe/lighter.git"

View File

@ -1,3 +1,6 @@
use std::collections::HashMap;
use chrono::{NaiveTime, Weekday};
use lighter_lib::{BulbColor, BulbId, BulbMode};
use serde::{Deserialize, Serialize};
@ -9,9 +12,10 @@ pub enum ServerMessage {
},
/// Update the state of a bulb
BulbMode {
BulbState {
id: BulbId,
mode: BulbMode,
wake_schedule: HashMap<Weekday, NaiveTime>,
},
BulbMap(BulbMap),
@ -23,8 +27,19 @@ pub enum ClientMessage {
//SubscribeToInfo,
//SubscribeToBulbs,
GetBulbs,
SetBulbColor { id: BulbId, color: BulbColor },
SetBulbPower { id: BulbId, power: bool },
SetBulbColor {
id: BulbId,
color: BulbColor,
},
SetBulbPower {
id: BulbId,
power: bool,
},
SetBulbWakeTime {
id: BulbId,
day: Weekday,
time: Option<NaiveTime>,
},
}
/// A geometric description of rooms/groups of light bulbs
@ -50,16 +65,16 @@ pub enum BulbGroupShape {
impl BulbGroupShape {
pub fn height(&self) -> u32 {
match self {
&Self::Circle { r } => r,
&Self::Rectangle { h, .. } => h,
match *self {
Self::Circle { r } => r,
Self::Rectangle { h, .. } => h,
}
}
pub fn width(&self) -> u32 {
match self {
&Self::Circle { r } => r,
&Self::Rectangle { w, .. } => w,
match *self {
Self::Circle { r } => r,
Self::Rectangle { w, .. } => w,
}
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "hemma_web"
version = "0.1.0"
version = "0.2.0"
authors = ["Joakim Hulthe <joakim@hulthe.net"]
edition = "2021"
@ -14,6 +14,7 @@ serde = { version = "1", features = ['derive'] }
serde_json = "1"
anyhow = "*"
ron = "0.7.1"
chrono = { version = "0.4.20", features = ["serde"] }
[dependencies.css_typegen]
git = "https://github.com/hulthe/css_typegen.git"

View File

@ -1,27 +1,35 @@
use crate::components::color_picker::{ColorPicker, ColorPickerMsg};
use crate::css::C;
use chrono::{NaiveTime, Weekday};
use common::{BulbGroup, BulbGroupShape, BulbMap, ClientMessage, ServerMessage};
use lighter_lib::{BulbId, BulbMode};
use seed::prelude::*;
use seed::{attrs, button, div, C};
use seed::{attrs, button, div, input, C};
use seed_router::Page;
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, HashSet, HashMap};
use std::fmt::Write;
/// /lights page
#[derive(Default)]
pub struct Model {
bulb_states: BTreeMap<BulbId, BulbMode>,
bulb_states: BTreeMap<BulbId, BulbState>,
bulb_map: BulbMap,
/// The currently selected bulb map groups
/// the currently selected bulb map groups
selected_groups: HashSet<usize>,
/// Whether the currently selected map groups have been interacted with
/// whether the currently selected map groups have been interacted with
groups_interacted: bool,
color_picker: ColorPicker,
}
#[derive(Default, Clone)]
struct BulbState {
mode: BulbMode,
wake_schedule: HashMap<Weekday, NaiveTime>,
}
#[derive(Debug)]
@ -32,6 +40,7 @@ pub enum Msg {
DeselectGroups,
ColorPicker(ColorPickerMsg),
SetBulbPower(bool),
LightTime(String, Weekday),
}
impl Page for Model {
@ -47,8 +56,12 @@ impl Page for Model {
fn update(&mut self, msg: Self::Msg, orders: &mut impl Orders<Self::Msg>) {
match msg {
Msg::ServerMessage(msg) => match msg {
ServerMessage::BulbMode { id, mode: new_mode } => {
*self.bulb_states.entry(id).or_default() = new_mode
ServerMessage::BulbState { id, mode: new_mode, wake_schedule } => {
*self.bulb_states.entry(id).or_default() = BulbState {
mode: new_mode,
wake_schedule,
};
//color_picker.set_color(mode.color);
}
ServerMessage::BulbMap(bulb_map) => {
@ -80,7 +93,7 @@ impl Page for Model {
.and_then(|id| self.bulb_states.get(id));
if let Some(bulb) = bulb {
self.color_picker.set_color(bulb.color);
self.color_picker.set_color(bulb.mode.color);
}
}
}
@ -108,128 +121,175 @@ impl Page for Model {
orders.notify(message);
});
}
Msg::LightTime(time, day) => {
if time == "" {
self.for_selected_bulbs(|id, _| {
let message = ClientMessage::SetBulbWakeTime {
id: id.clone(),
day,
time: None,
};
orders.notify(message);
});
}
else if let Ok(time) = NaiveTime::parse_from_str(&time, "%H:%M") {
self.for_selected_bulbs(|id, _| {
let message = ClientMessage::SetBulbWakeTime {
id: id.clone(),
day,
time: Some(time),
};
orders.notify(message);
});
}
}
}
}
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
.bulb_map
.groups
.iter()
.map(|group| group.x + group.shape.width())
.max()
.unwrap_or(0);
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_height = self
.bulb_map
.groups
.iter()
.map(|group| group.y + group.shape.height())
.max()
.unwrap_or(0);
let bulb_map_width = self
.bulb_map
.groups
.iter()
.map(|group| group.x + group.shape.width())
.max()
.unwrap_or(0);
let view_bulb_group = |(i, group): (usize, &BulbGroup)| {
let (w, h) = (group.shape.width(), group.shape.height());
let mut style = String::new();
write!(
&mut style,
"margin-left: {}rem; margin-top: {}rem; width: {}rem; height: {}rem;",
group.x, group.y, w, h
)
.ok();
let bulb_map_height = self
.bulb_map
.groups
.iter()
.map(|group| group.y + group.shape.height())
.max()
.unwrap_or(0);
if let BulbGroupShape::Circle { r } = group.shape {
write!(&mut style, " border-radius: {r}rem;").ok();
}
let view_bulb_group = |(i, group): (usize, &BulbGroup)| {
let (w, h) = (group.shape.width(), group.shape.height());
let mut style = String::new();
write!(
&mut style,
"margin-left: {}rem; margin-top: {}rem; width: {}rem; height: {}rem;",
group.x, group.y, w, h
)
.ok();
div![
&group.name[..1],
if self.selected_groups.contains(&i) {
C![C.bulb_group, C.bulb_group_selected]
} else {
C![C.bulb_group]
},
attrs! {
At::Style => style,
},
ev(Ev::Click, move |event| {
event.stop_propagation();
Msg::SelectGroup(i)
}),
]
};
let (_color, power) = 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))
.map(|bulb| (bulb.color, bulb.power))
.unwrap_or_default();
if let BulbGroupShape::Circle { r } = group.shape {
write!(&mut style, " border-radius: {r}rem;").ok();
}
div![
C![C.bulb_box],
div![
C![C.bulb_map],
attrs! {
At::Style => format!("width: {}rem; height: {}rem;", bulb_map_width, bulb_map_height),
},
ev(Ev::Click, |_| Msg::DeselectGroups),
self.bulb_map.groups.iter().enumerate().map(view_bulb_group),
],
div![
C![C.bulb_controls],
self.color_picker
.view()
.map_msg(|msg| Msg::ColorPicker(msg)),
button![
if power {
C![C.bulb_power_button, C.bulb_power_button_on]
} else {
C![C.bulb_power_button]
},
ev(Ev::Click, move |_| Msg::SetBulbPower(!power)),
div![attrs! { At::Id => "switch_socket" }],
div![attrs! { At::Id => "off_label" }, "Off"],
div![attrs! { At::Id => "on_label" }, "On"],
div![attrs! { At::Id => "lever_stem" }],
div![attrs! { At::Id => "lever_face" }],
],
&group.name[..1],
if self.selected_groups.contains(&i) {
C![C.bulb_group, C.bulb_group_selected]
} else {
C![C.bulb_group]
},
attrs! {
At::Style => style,
},
ev(Ev::Click, move |event| {
event.stop_propagation();
Msg::SelectGroup(i)
}),
]
};
let selected_bulb = 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))
.cloned() // TODO: remove clone
.unwrap_or_default();
let calendar_day = |day: Weekday| {
let time = selected_bulb.wake_schedule.get(&day).map(|t| t.to_string()).unwrap_or_default();
div![
C![C.calendar_day],
day.to_string(),
input![
C![C.calendar_time_input],
attrs! {At::Placeholder => time},
input_ev(Ev::Input, move |input| Msg::LightTime(input, day))
],
]
}
};
div![
C![C.bulb_box],
div![
C![C.bulb_map],
attrs! {
At::Style => format!("width: {}rem; height: {}rem;", bulb_map_width, bulb_map_height),
},
ev(Ev::Click, |_| Msg::DeselectGroups),
self.bulb_map.groups.iter().enumerate().map(view_bulb_group),
],
div![
C![C.bulb_controls],
self.color_picker
.view()
.map_msg(|msg| Msg::ColorPicker(msg)),
button![
if selected_bulb.mode.power {
C![C.bulb_power_button, C.bulb_power_button_on]
} else {
C![C.bulb_power_button]
},
ev(Ev::Click, move |_| Msg::SetBulbPower(!selected_bulb.mode.power)),
div![attrs! { At::Id => "switch_socket" }],
div![attrs! { At::Id => "off_label" }, "Off"],
div![attrs! { At::Id => "on_label" }, "On"],
div![attrs! { At::Id => "lever_stem" }],
div![attrs! { At::Id => "lever_face" }],
],
],
div![
C![C.calendar_box],
calendar_day(Weekday::Mon),
calendar_day(Weekday::Tue),
calendar_day(Weekday::Wed),
calendar_day(Weekday::Thu),
calendar_day(Weekday::Fri),
calendar_day(Weekday::Sat),
calendar_day(Weekday::Sun),
],
]
}
}
impl Model {
fn for_selected_bulbs(&self, mut f: impl FnMut(&BulbId, &BulbMode)) {
fn for_selected_bulbs(&self, mut f: impl FnMut(&BulbId, &BulbState)) {
self.selected_groups
.iter()
.filter_map(|&index| self.bulb_map.groups.get(index))

View File

@ -259,3 +259,25 @@ body {
transition: margin 0.1s ease-out;
}
.calendar_day {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: .3em;
}
.calendar_time_input {
background: #453f4b;
border: solid 0.35em #5b3f63;
border-radius: .3em;
color: white;
height: 15;
width: 5em;
align: right;
//margin-bottom: .7em;
margin-left: .5em;
}
.calendar_box {
with: 10em;
}