Compare commits
15 Commits
d2054caf69
...
01a7576d7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
01a7576d7f
|
|||
|
1e55385645
|
|||
|
1ac0dbe210
|
|||
|
939e4d9785
|
|||
|
08bdeb693b
|
|||
| 2370faa718 | |||
|
91ca707245
|
|||
|
9b7541f71d
|
|||
|
1172e3da1f
|
|||
| 01a7c7b5ec | |||
|
a42025577e
|
|||
|
85b449dcdc
|
|||
|
1f05c6dc3d
|
|||
|
4273640cd3
|
|||
|
62887371c9
|
450
Cargo.lock
generated
450
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
persistence_dir = "/tmp/"
|
||||
|
||||
[mqtt]
|
||||
#address = "hostname"
|
||||
#port = 1883
|
||||
|
||||
@ -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
111
backend/src/persistence.rs
Normal 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
73
backend/src/tasks/info.rs
Normal 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
241
backend/src/tasks/lights.rs
Normal 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
5
backend/src/tasks/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod info;
|
||||
pub mod lights;
|
||||
|
||||
pub use info::info_task;
|
||||
pub use lights::lights_task;
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,10 +121,33 @@ 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> {
|
||||
|
||||
fn view(&self) -> Node<Self::Msg> {
|
||||
//let view_bulb = |(id, (mode, color_picker)): (&BulbId, &(BulbMode, ColorPicker))| {
|
||||
// div![
|
||||
// C![C.bulb_box],
|
||||
@ -185,16 +221,30 @@ impl Page for Model {
|
||||
]
|
||||
};
|
||||
|
||||
let (_color, power) = self
|
||||
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))
|
||||
.map(|bulb| (bulb.color, bulb.power))
|
||||
.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![
|
||||
@ -211,12 +261,12 @@ impl Page for Model {
|
||||
.view()
|
||||
.map_msg(|msg| Msg::ColorPicker(msg)),
|
||||
button![
|
||||
if power {
|
||||
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(!power)),
|
||||
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"],
|
||||
@ -224,12 +274,22 @@ impl Page for Model {
|
||||
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))
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user