Compare commits
25 Commits
d2054caf69
...
0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
f7ec98f8e3
|
|||
|
924a14cdcb
|
|||
|
d5a221e1a3
|
|||
|
b8e05e33a6
|
|||
|
362534384a
|
|||
|
fee9c7a171
|
|||
|
e458c69c80
|
|||
|
548e7d3240
|
|||
|
ac306eece4
|
|||
|
4cf6b628ee
|
|||
|
01a7576d7f
|
|||
|
1e55385645
|
|||
|
1ac0dbe210
|
|||
|
939e4d9785
|
|||
|
08bdeb693b
|
|||
| 2370faa718 | |||
|
91ca707245
|
|||
|
9b7541f71d
|
|||
|
1172e3da1f
|
|||
| 01a7c7b5ec | |||
|
a42025577e
|
|||
|
85b449dcdc
|
|||
|
1f05c6dc3d
|
|||
|
4273640cd3
|
|||
|
62887371c9
|
1133
Cargo.lock
generated
1133
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,8 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["backend", "common", "frontend"]
|
members = ["backend", "common", "frontend"]
|
||||||
|
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
# Issue with const-generics
|
# Issue with const-generics
|
||||||
incremental = false
|
incremental = false
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
##################
|
##################
|
||||||
### BASE STAGE ###
|
### BASE STAGE ###
|
||||||
##################
|
##################
|
||||||
FROM rust:1.62.1 as base
|
FROM rust:1.72.1 as base
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies
|
||||||
RUN cargo install --locked cargo-make trunk strip_cargo_version
|
RUN cargo install --locked cargo-make trunk strip_cargo_version
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hemma"
|
name = "hemma"
|
||||||
version = "0.1.0"
|
version = "0.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@ -25,8 +25,7 @@ path = "../common"
|
|||||||
|
|
||||||
[dependencies.lighter_lib]
|
[dependencies.lighter_lib]
|
||||||
git = "https://git.nubo.sh/hulthe/lighter.git"
|
git = "https://git.nubo.sh/hulthe/lighter.git"
|
||||||
#path = "../../lighter/lib"
|
|
||||||
|
|
||||||
[dependencies.lighter_manager]
|
[dependencies.lighter_manager]
|
||||||
git = "https://git.nubo.sh/hulthe/lighter.git"
|
git = "https://git.nubo.sh/hulthe/lighter.git"
|
||||||
#path = "../../lighter/manager"
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
persistence_dir = "/tmp/"
|
||||||
|
|
||||||
[mqtt]
|
[mqtt]
|
||||||
#address = "hostname"
|
#address = "hostname"
|
||||||
#port = 1883
|
#port = 1883
|
||||||
|
|||||||
@ -144,7 +144,7 @@ fn wind_speed_to_beaufort(mps: f64) -> BeaufortScale {
|
|||||||
let index = beaufort_wind_speeds
|
let index = beaufort_wind_speeds
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.find_map(|(i, range)| range.contains(&mps).then(|| i))
|
.find_map(|(i, range)| range.contains(&mps).then_some(i))
|
||||||
.unwrap_or(beaufort_wind_speeds.len());
|
.unwrap_or(beaufort_wind_speeds.len());
|
||||||
|
|
||||||
BeaufortScale(index as u8)
|
BeaufortScale(index as u8)
|
||||||
|
|||||||
@ -1,21 +1,20 @@
|
|||||||
mod collector;
|
mod collector;
|
||||||
|
mod persistence;
|
||||||
|
mod tasks;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use collector::{Collector, CollectorConfig, MarkdownWeb, WeatherApi};
|
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::{
|
use lighter_manager::{manager::BulbsConfig, mqtt_conf::MqttConfig};
|
||||||
manager::{BulbCommand, BulbManager, BulbSelector, BulbsConfig},
|
|
||||||
mqtt_conf::MqttConfig,
|
|
||||||
};
|
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
|
use persistence::Persistence;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
use tokio::{fs, select, task};
|
use tokio::{fs, select, task};
|
||||||
use warp::ws::{self, WebSocket};
|
use warp::ws::{self, WebSocket};
|
||||||
use warp::{Filter, Rejection, Reply};
|
use warp::{Filter, Rejection, Reply};
|
||||||
@ -33,7 +32,7 @@ struct Opt {
|
|||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
|
|
||||||
#[clap(long, short, default_value = "127.0.0.0:8000")]
|
#[clap(long, short, default_value = "127.0.0.1:8000")]
|
||||||
bind: SocketAddr,
|
bind: SocketAddr,
|
||||||
|
|
||||||
#[clap(long, default_value = "./www")]
|
#[clap(long, default_value = "./www")]
|
||||||
@ -44,11 +43,13 @@ struct Opt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Config {
|
pub struct Config {
|
||||||
mqtt: MqttConfig,
|
mqtt: MqttConfig,
|
||||||
|
|
||||||
collectors: CollectorConfig,
|
collectors: CollectorConfig,
|
||||||
|
|
||||||
|
persistence_dir: Option<PathBuf>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
bulbs: BulbsConfig,
|
bulbs: BulbsConfig,
|
||||||
|
|
||||||
@ -56,8 +57,9 @@ struct Config {
|
|||||||
bulb_map: BulbMap,
|
bulb_map: BulbMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
pub struct State {
|
||||||
config: Config,
|
config: Config,
|
||||||
|
persistence: Persistence,
|
||||||
client_message: broadcast::Sender<ClientRequest>,
|
client_message: broadcast::Sender<ClientRequest>,
|
||||||
server_message: broadcast::Sender<ServerMessage>,
|
server_message: broadcast::Sender<ServerMessage>,
|
||||||
}
|
}
|
||||||
@ -88,14 +90,20 @@ async fn main() {
|
|||||||
let (client_message, _) = broadcast::channel(100);
|
let (client_message, _) = broadcast::channel(100);
|
||||||
|
|
||||||
let state = State {
|
let state = State {
|
||||||
config,
|
|
||||||
client_message,
|
client_message,
|
||||||
server_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));
|
let state = Box::leak(Box::new(state));
|
||||||
|
|
||||||
task::spawn(info_collector(state));
|
task::spawn(tasks::lights_task(state));
|
||||||
task::spawn(lights_collector(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.
|
||||||
@ -129,123 +137,6 @@ struct ClientRequest {
|
|||||||
response: mpsc::Sender<ServerMessage>,
|
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) {
|
async fn client_handler(mut socket: WebSocket, state: &State) {
|
||||||
let mut server_message = state.server_message.subscribe();
|
let mut server_message = state.server_message.subscribe();
|
||||||
let (server_responder, mut server_responses) = mpsc::channel(100);
|
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 {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
backend/src/tasks/lights.rs
Normal file
243
backend/src/tasks/lights.rs
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
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();
|
||||||
|
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_naive()
|
||||||
|
.and_time(time)
|
||||||
|
.and_local_timezone(Local)
|
||||||
|
.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]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.1.0"
|
version = "0.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0.137", features = ["derive"] }
|
serde = { version = "1.0.137", features = ["derive"] }
|
||||||
|
chrono = { version = "0.4.20", features = ["serde"] }
|
||||||
|
|
||||||
[dependencies.lighter_lib]
|
[dependencies.lighter_lib]
|
||||||
git = "https://git.nubo.sh/hulthe/lighter.git"
|
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 lighter_lib::{BulbColor, BulbId, BulbMode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -9,9 +12,10 @@ pub enum ServerMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Update the state of a bulb
|
/// Update the state of a bulb
|
||||||
BulbMode {
|
BulbState {
|
||||||
id: BulbId,
|
id: BulbId,
|
||||||
mode: BulbMode,
|
mode: BulbMode,
|
||||||
|
wake_schedule: HashMap<Weekday, NaiveTime>,
|
||||||
},
|
},
|
||||||
|
|
||||||
BulbMap(BulbMap),
|
BulbMap(BulbMap),
|
||||||
@ -23,8 +27,19 @@ pub enum ClientMessage {
|
|||||||
//SubscribeToInfo,
|
//SubscribeToInfo,
|
||||||
//SubscribeToBulbs,
|
//SubscribeToBulbs,
|
||||||
GetBulbs,
|
GetBulbs,
|
||||||
SetBulbColor { id: BulbId, color: BulbColor },
|
SetBulbColor {
|
||||||
SetBulbPower { id: BulbId, power: bool },
|
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
|
/// A geometric description of rooms/groups of light bulbs
|
||||||
@ -50,16 +65,16 @@ pub enum BulbGroupShape {
|
|||||||
|
|
||||||
impl BulbGroupShape {
|
impl BulbGroupShape {
|
||||||
pub fn height(&self) -> u32 {
|
pub fn height(&self) -> u32 {
|
||||||
match self {
|
match *self {
|
||||||
&Self::Circle { r } => r,
|
Self::Circle { r } => r,
|
||||||
&Self::Rectangle { h, .. } => h,
|
Self::Rectangle { h, .. } => h,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn width(&self) -> u32 {
|
pub fn width(&self) -> u32 {
|
||||||
match self {
|
match *self {
|
||||||
&Self::Circle { r } => r,
|
Self::Circle { r } => r,
|
||||||
&Self::Rectangle { w, .. } => w,
|
Self::Rectangle { w, .. } => w,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hemma_web"
|
name = "hemma_web"
|
||||||
version = "0.1.0"
|
version = "0.2.3"
|
||||||
authors = ["Joakim Hulthe <joakim@hulthe.net"]
|
authors = ["Joakim Hulthe <joakim@hulthe.net"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
seed = "0.9.1"
|
seed = "0.10.0"
|
||||||
wasm-bindgen = "=0.2.80" # 0.2.81 has a breaking change
|
wasm-bindgen = "=0.2.87" # must match Trunk.toml
|
||||||
serde = { version = "1", features = ['derive'] }
|
serde = { version = "1.0.0", features = ['derive'] }
|
||||||
serde_json = "1"
|
|
||||||
anyhow = "*"
|
|
||||||
ron = "0.7.1"
|
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"
|
||||||
|
|
||||||
[dependencies.css_typegen]
|
[dependencies.css_typegen]
|
||||||
git = "https://github.com/hulthe/css_typegen.git"
|
git = "https://github.com/hulthe/css_typegen.git"
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
[serve]
|
[serve]
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
wasm_bindgen = "0.2.87"
|
||||||
|
|
||||||
[[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
|
||||||
# WebSocket connections at `/api/ws` and proxy them to `ws://localhost:9000/api/ws`.
|
# WebSocket connections at `/api/ws` and proxy them to `ws://localhost:9000/api/ws`.
|
||||||
backend = "ws://localhost:8000/api/ws"
|
backend = "ws://127.0.0.1:8000/api/ws"
|
||||||
ws = true
|
ws = true
|
||||||
|
|
||||||
#[[proxy]]
|
#[[proxy]]
|
||||||
|
|||||||
4
frontend/rust-toolchain.toml
Normal file
4
frontend/rust-toolchain.toml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rust-src", "rustfmt", "rust-src"]
|
||||||
|
targets = ["wasm32-unknown-unknown"]
|
||||||
@ -1,10 +1,20 @@
|
|||||||
use crate::page;
|
use crate::page;
|
||||||
use common::{ClientMessage, ServerMessage};
|
use common::{ClientMessage, ServerMessage};
|
||||||
|
use gloo_console::error;
|
||||||
|
use gloo_net::websocket;
|
||||||
|
use gloo_net::websocket::futures::WebSocket;
|
||||||
use seed::app::orders::OrdersContainer;
|
use seed::app::orders::OrdersContainer;
|
||||||
|
use seed::futures::channel::mpsc::channel;
|
||||||
|
use seed::futures::channel::mpsc::Sender;
|
||||||
|
use seed::futures::select_biased;
|
||||||
|
use seed::futures::SinkExt;
|
||||||
|
use seed::futures::StreamExt;
|
||||||
use seed::prelude::*;
|
use seed::prelude::*;
|
||||||
use seed::{log, window};
|
use seed::window;
|
||||||
|
use seed::FutureExt;
|
||||||
use seed_router::Router;
|
use seed_router::Router;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
pub type AppOrders = OrdersContainer<Msg, Model, Vec<Node<Msg>>>;
|
pub type AppOrders = OrdersContainer<Msg, Model, Vec<Node<Msg>>>;
|
||||||
|
|
||||||
@ -14,8 +24,16 @@ const TIMEOUT_CONNECT_DELAYS: &[u32] = &[2, 5, 10, 10, 10, 20, 30, 60, 120, 300]
|
|||||||
pub struct Model {
|
pub struct Model {
|
||||||
page: Pages,
|
page: Pages,
|
||||||
send_queue: VecDeque<ClientMessage>,
|
send_queue: VecDeque<ClientMessage>,
|
||||||
socket: WebSocket,
|
|
||||||
|
/// Websocket URL
|
||||||
ws_url: String,
|
ws_url: String,
|
||||||
|
|
||||||
|
/// Channel to send websocket messages.
|
||||||
|
socket: Option<Sender<websocket::Message>>,
|
||||||
|
|
||||||
|
/// Handle to the websocket task.
|
||||||
|
socket_task: Option<CmdHandle>,
|
||||||
|
|
||||||
timeout_count: usize,
|
timeout_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,14 +65,14 @@ pub enum Msg {
|
|||||||
|
|
||||||
// Global
|
// Global
|
||||||
Connect,
|
Connect,
|
||||||
SocketOpened(),
|
SocketOpened(Sender<websocket::Message>),
|
||||||
SocketClosed(CloseEvent),
|
SocketClosed,
|
||||||
SocketError(),
|
SocketMessage(websocket::Message),
|
||||||
SocketMessage(WebSocketMessage),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
||||||
orders.subscribe(Msg::SendMessage);
|
orders.subscribe(Msg::SendMessage);
|
||||||
|
orders.send_msg(Msg::Connect);
|
||||||
|
|
||||||
let location = window().location();
|
let location = window().location();
|
||||||
let host = location.host().expect("Failed to get hostname");
|
let host = location.host().expect("Failed to get hostname");
|
||||||
@ -69,73 +87,109 @@ pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
|||||||
page: Pages::from_url(url, &mut orders.proxy(Msg::Page))
|
page: Pages::from_url(url, &mut orders.proxy(Msg::Page))
|
||||||
.unwrap_or(Pages::NotFound(Default::default())),
|
.unwrap_or(Pages::NotFound(Default::default())),
|
||||||
send_queue: Default::default(),
|
send_queue: Default::default(),
|
||||||
socket: open_socket(&ws_url, orders),
|
|
||||||
ws_url,
|
ws_url,
|
||||||
|
socket_task: None,
|
||||||
|
socket: None,
|
||||||
timeout_count: 0,
|
timeout_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_socket(url: &str, orders: &mut impl Orders<Msg>) -> WebSocket {
|
fn open_socket(url: String, orders: &mut impl Orders<Msg>) -> CmdHandle {
|
||||||
WebSocket::builder(url, orders)
|
let update_fn = orders.msg_sender();
|
||||||
.on_open(Msg::SocketOpened)
|
|
||||||
.on_close(Msg::SocketClosed)
|
orders.perform_cmd_with_handle(async move {
|
||||||
.on_error(Msg::SocketError)
|
let mut ws = WebSocket::open(&url).expect("Failed to open websocket");
|
||||||
.on_message(Msg::SocketMessage)
|
let (tx, mut rx) = channel(128);
|
||||||
.build_and_open()
|
update_fn(Some(Msg::SocketOpened(tx)));
|
||||||
.expect("failed to open websocket")
|
|
||||||
|
loop {
|
||||||
|
select_biased! {
|
||||||
|
message = rx.next().fuse() => {
|
||||||
|
let Some(message) = message else { return; };
|
||||||
|
if let Err(e) = ws.send(message).await {
|
||||||
|
error!(format!("websocket error: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message = ws.next().fuse() => match message {
|
||||||
|
Some(Ok(message)) => update_fn(Some(Msg::SocketMessage(message))),
|
||||||
|
Some(Err(e)) => {
|
||||||
|
error!(format!("websocket error: {e:?}"));
|
||||||
|
update_fn(Some(Msg::SocketClosed));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
update_fn(Some(Msg::SocketClosed));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(msg: Msg, model: &mut Model, orders: &mut AppOrders) {
|
pub fn update(msg: Msg, model: &mut Model, orders: &mut AppOrders) {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
log!(format!("{msg:?}"));
|
gloo_console::debug!(format!("{msg:?}"));
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Page(msg) => model.page.update(msg, &mut orders.proxy(Msg::Page)),
|
Msg::Page(msg) => model.page.update(msg, &mut orders.proxy(Msg::Page)),
|
||||||
Msg::FlushMessageQueue => {
|
Msg::FlushMessageQueue => {
|
||||||
while let Some(message) = model.send_queue.pop_front() {
|
if let Some(socket) = model.socket.as_mut() {
|
||||||
let serialized = ron::to_string(&message).unwrap();
|
while let Some(message) = model.send_queue.pop_front() {
|
||||||
|
let serialized = ron::to_string(&message).expect("failed to serialize ron");
|
||||||
|
|
||||||
if let Err(e) = model.socket.send_text(serialized) {
|
let ws_message = websocket::Message::Text(serialized);
|
||||||
model.send_queue.push_front(message);
|
if socket.try_send(ws_message).is_err() {
|
||||||
log!(e);
|
error!("websocket queue full");
|
||||||
return;
|
model.send_queue.push_front(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Msg::Connect => {
|
Msg::Connect => {
|
||||||
model.socket = open_socket(&model.ws_url, orders);
|
model.socket_task = Some(open_socket(model.ws_url.clone(), orders));
|
||||||
}
|
}
|
||||||
Msg::SendMessage(message) => {
|
Msg::SendMessage(message) => {
|
||||||
model.send_queue.push_back(message);
|
model.send_queue.push_back(message);
|
||||||
orders.send_msg(Msg::FlushMessageQueue);
|
orders.send_msg(Msg::FlushMessageQueue);
|
||||||
}
|
}
|
||||||
Msg::SocketOpened() => {
|
Msg::SocketOpened(socket) => {
|
||||||
|
model.socket = Some(socket);
|
||||||
model.timeout_count = 0;
|
model.timeout_count = 0;
|
||||||
orders.send_msg(Msg::FlushMessageQueue);
|
orders.send_msg(Msg::FlushMessageQueue);
|
||||||
}
|
}
|
||||||
Msg::SocketClosed(_event) => {
|
Msg::SocketClosed => {
|
||||||
|
model.socket_task = None;
|
||||||
|
model.socket = None;
|
||||||
|
|
||||||
let timeout_sec = TIMEOUT_CONNECT_DELAYS[model.timeout_count];
|
let timeout_sec = TIMEOUT_CONNECT_DELAYS[model.timeout_count];
|
||||||
let timeout_ms = timeout_sec * 1000;
|
let timeout_ms = timeout_sec * 1000;
|
||||||
orders.perform_cmd(cmds::timeout(timeout_ms, || Msg::Connect));
|
orders.perform_cmd(cmds::timeout(timeout_ms, || Msg::Connect));
|
||||||
|
|
||||||
log!(format!(
|
error!(format!(
|
||||||
"Socket closed, trying to reconnect in {timeout_sec} seconds"
|
"Socket closed, trying to reconnect in {timeout_sec} seconds"
|
||||||
));
|
));
|
||||||
|
|
||||||
model.timeout_count = TIMEOUT_CONNECT_DELAYS.len().min(model.timeout_count + 1);
|
model.timeout_count = TIMEOUT_CONNECT_DELAYS.len().min(model.timeout_count + 1);
|
||||||
}
|
}
|
||||||
Msg::SocketError() => {}
|
|
||||||
Msg::SocketMessage(message) => {
|
Msg::SocketMessage(message) => {
|
||||||
if let Err(e) = handle_ws_msg(message, orders) {
|
if let Err(e) = handle_ws_msg(message, orders) {
|
||||||
log!(e);
|
error!(format!("{e:?}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_ws_msg(message: WebSocketMessage, orders: &mut impl Orders<Msg>) -> anyhow::Result<()> {
|
fn handle_ws_msg(
|
||||||
let message = message.text().map_err(|e| anyhow::format_err!("{e:?}"))?;
|
message: websocket::Message,
|
||||||
let message: ServerMessage = ron::from_str(&message)?;
|
orders: &mut impl Orders<Msg>,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let websocket::Message::Text(text) = &message else {
|
||||||
|
return Err("Server is sending us raw bytes on the websocket! Argh!".into());
|
||||||
|
};
|
||||||
|
let message: ServerMessage = ron::from_str(text)?;
|
||||||
orders.notify(message);
|
orders.notify(message);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -143,9 +197,4 @@ fn handle_ws_msg(message: WebSocketMessage, orders: &mut impl Orders<Msg>) -> an
|
|||||||
|
|
||||||
pub fn view(model: &Model) -> Vec<Node<Msg>> {
|
pub fn view(model: &Model) -> Vec<Node<Msg>> {
|
||||||
vec![model.page.view().map_msg(Msg::Page)]
|
vec![model.page.view().map_msg(Msg::Page)]
|
||||||
//match &model.page {
|
|
||||||
// Pages::NotFound => vec![h1!["Not Found"]],
|
|
||||||
// Pages::InfoScreen => vec![div![C![C.info_box], raw![&model.info_page]]],
|
|
||||||
// Pages::Lights(page) => vec![],
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ pub struct ColorPicker {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
enum ColorPickerSetting {
|
enum ColorPickerSetting {
|
||||||
#[default]
|
#[default]
|
||||||
HSB,
|
Hsb,
|
||||||
Kelvin,
|
Kelvin,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,14 +55,14 @@ impl ColorPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match self.dragging.take() {
|
match self.dragging.take() {
|
||||||
Some(ColorPickerAttr::HueSat) => self.mode = ColorPickerSetting::HSB,
|
Some(ColorPickerAttr::HueSat) => self.mode = ColorPickerSetting::Hsb,
|
||||||
Some(ColorPickerAttr::Brightness) => {}
|
Some(ColorPickerAttr::Brightness) => {}
|
||||||
Some(ColorPickerAttr::Temperature) => self.mode = ColorPickerSetting::Kelvin,
|
Some(ColorPickerAttr::Temperature) => self.mode = ColorPickerSetting::Kelvin,
|
||||||
None => return,
|
None => return,
|
||||||
}
|
}
|
||||||
|
|
||||||
let color = match self.mode {
|
let color = match self.mode {
|
||||||
ColorPickerSetting::HSB => {
|
ColorPickerSetting::Hsb => {
|
||||||
BulbColor::hsb(self.hue, self.saturation, self.brightness)
|
BulbColor::hsb(self.hue, self.saturation, self.brightness)
|
||||||
}
|
}
|
||||||
ColorPickerSetting::Kelvin => {
|
ColorPickerSetting::Kelvin => {
|
||||||
@ -130,10 +130,12 @@ impl ColorPicker {
|
|||||||
|
|
||||||
let (r, g, b) = hsb_to_rgb(self.hue, 1.0, 1.0);
|
let (r, g, b) = hsb_to_rgb(self.hue, 1.0, 1.0);
|
||||||
let saturation_gradient = match self.mode {
|
let saturation_gradient = match self.mode {
|
||||||
ColorPickerSetting::HSB => {
|
ColorPickerSetting::Hsb => {
|
||||||
format!("background: linear-gradient(0deg, #000, rgba({r},{g},{b},1));")
|
format!("background: linear-gradient(0deg, #000, rgba({r},{g},{b},1));")
|
||||||
}
|
}
|
||||||
ColorPickerSetting::Kelvin => format!("background: linear-gradient(0deg, #000, #fff);"),
|
ColorPickerSetting::Kelvin => {
|
||||||
|
"background: linear-gradient(0deg, #000, #fff);".to_string()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
div![
|
div![
|
||||||
@ -186,7 +188,7 @@ impl ColorPicker {
|
|||||||
self.hue = h;
|
self.hue = h;
|
||||||
self.saturation = s;
|
self.saturation = s;
|
||||||
self.brightness = b;
|
self.brightness = b;
|
||||||
self.mode = ColorPickerSetting::HSB;
|
self.mode = ColorPickerSetting::Hsb;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_kelvin(&mut self, t: f32, b: f32) {
|
pub fn set_kelvin(&mut self, t: f32, b: f32) {
|
||||||
|
|||||||
@ -3,10 +3,8 @@ mod components;
|
|||||||
mod css;
|
mod css;
|
||||||
mod page;
|
mod page;
|
||||||
|
|
||||||
use seed::prelude::wasm_bindgen;
|
|
||||||
use seed::App;
|
use seed::App;
|
||||||
|
|
||||||
#[wasm_bindgen(start)]
|
pub fn main() {
|
||||||
pub fn start() {
|
|
||||||
App::start("app", app::init, app::update, app::view);
|
App::start("app", app::init, app::update, app::view);
|
||||||
}
|
}
|
||||||
@ -1,29 +1,36 @@
|
|||||||
use crate::components::color_picker::{ColorPicker, ColorPickerMsg};
|
use crate::components::color_picker::{ColorPicker, ColorPickerMsg};
|
||||||
use crate::css::C;
|
use crate::css::C;
|
||||||
|
use chrono::{NaiveTime, Weekday};
|
||||||
use common::{BulbGroup, BulbGroupShape, BulbMap, ClientMessage, ServerMessage};
|
use common::{BulbGroup, BulbGroupShape, BulbMap, ClientMessage, ServerMessage};
|
||||||
use lighter_lib::{BulbId, BulbMode};
|
use lighter_lib::{BulbId, BulbMode};
|
||||||
use seed::prelude::*;
|
use seed::{attrs, button, div, h2, input, table, td, tr, C};
|
||||||
use seed::{attrs, button, div, C};
|
use seed::{prelude::*, IF};
|
||||||
use seed_router::Page;
|
use seed_router::Page;
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
/// /lights page
|
/// /lights page
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
bulb_states: BTreeMap<BulbId, BulbMode>,
|
bulb_states: BTreeMap<BulbId, BulbState>,
|
||||||
|
|
||||||
bulb_map: BulbMap,
|
bulb_map: BulbMap,
|
||||||
|
|
||||||
/// The currently selected bulb map groups
|
/// the currently selected bulb map groups
|
||||||
selected_groups: HashSet<usize>,
|
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,
|
groups_interacted: bool,
|
||||||
|
|
||||||
color_picker: ColorPicker,
|
color_picker: ColorPicker,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct BulbState {
|
||||||
|
mode: BulbMode,
|
||||||
|
wake_schedule: HashMap<Weekday, NaiveTime>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
ServerMessage(ServerMessage),
|
ServerMessage(ServerMessage),
|
||||||
@ -32,6 +39,7 @@ pub enum Msg {
|
|||||||
DeselectGroups,
|
DeselectGroups,
|
||||||
ColorPicker(ColorPickerMsg),
|
ColorPicker(ColorPickerMsg),
|
||||||
SetBulbPower(bool),
|
SetBulbPower(bool),
|
||||||
|
LightTime(String, Weekday),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Page for Model {
|
impl Page for Model {
|
||||||
@ -47,8 +55,16 @@ impl Page for Model {
|
|||||||
fn update(&mut self, msg: Self::Msg, orders: &mut impl Orders<Self::Msg>) {
|
fn update(&mut self, msg: Self::Msg, orders: &mut impl Orders<Self::Msg>) {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ServerMessage(msg) => match msg {
|
Msg::ServerMessage(msg) => match msg {
|
||||||
ServerMessage::BulbMode { id, mode: new_mode } => {
|
ServerMessage::BulbState {
|
||||||
*self.bulb_states.entry(id).or_default() = new_mode
|
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);
|
//color_picker.set_color(mode.color);
|
||||||
}
|
}
|
||||||
ServerMessage::BulbMap(bulb_map) => {
|
ServerMessage::BulbMap(bulb_map) => {
|
||||||
@ -80,7 +96,7 @@ impl Page for Model {
|
|||||||
.and_then(|id| self.bulb_states.get(id));
|
.and_then(|id| self.bulb_states.get(id));
|
||||||
|
|
||||||
if let Some(bulb) = bulb {
|
if let Some(bulb) = bulb {
|
||||||
self.color_picker.set_color(bulb.color);
|
self.color_picker.set_color(bulb.mode.color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -96,7 +112,7 @@ impl Page for Model {
|
|||||||
}
|
}
|
||||||
Msg::ColorPicker(msg) => {
|
Msg::ColorPicker(msg) => {
|
||||||
self.color_picker
|
self.color_picker
|
||||||
.update(msg, &mut orders.proxy(|msg| Msg::ColorPicker(msg)));
|
.update(msg, &mut orders.proxy(Msg::ColorPicker));
|
||||||
}
|
}
|
||||||
Msg::SetBulbPower(power) => {
|
Msg::SetBulbPower(power) => {
|
||||||
self.groups_interacted = true;
|
self.groups_interacted = true;
|
||||||
@ -108,6 +124,27 @@ impl Page for Model {
|
|||||||
orders.notify(message);
|
orders.notify(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Msg::LightTime(time, day) => {
|
||||||
|
if time.is_empty() {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,38 +222,54 @@ impl Page for Model {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
let (_color, power) = self
|
let selected_bulb = self
|
||||||
.selected_groups
|
.selected_groups
|
||||||
.iter()
|
.iter()
|
||||||
.next()
|
.next()
|
||||||
.and_then(|&index| self.bulb_map.groups.get(index))
|
.and_then(|&index| self.bulb_map.groups.get(index))
|
||||||
.and_then(|group| group.bulbs.first())
|
.and_then(|group| group.bulbs.first())
|
||||||
.and_then(|id| self.bulb_states.get(id))
|
.and_then(|id| self.bulb_states.get(id));
|
||||||
.map(|bulb| (bulb.color, bulb.power))
|
|
||||||
.unwrap_or_default();
|
let calendar_day = |day: Weekday| {
|
||||||
|
let time = selected_bulb
|
||||||
|
.and_then(|b| b.wake_schedule.get(&day))
|
||||||
|
.map(|t| t.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
tr![
|
||||||
|
C![C.calendar_day],
|
||||||
|
td![day.to_string()],
|
||||||
|
td![input![
|
||||||
|
C![C.calendar_time_input],
|
||||||
|
attrs! {At::Placeholder => time},
|
||||||
|
input_ev(Ev::Input, move |input| Msg::LightTime(input, day))
|
||||||
|
]],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
div![
|
div![
|
||||||
C![C.bulb_box],
|
C![C.bulb_box],
|
||||||
div![
|
div![
|
||||||
C![C.bulb_map],
|
C![C.bulb_map],
|
||||||
attrs! {
|
attrs! {
|
||||||
At::Style => format!("width: {}rem; height: {}rem;", bulb_map_width, bulb_map_height),
|
At::Style => format!("min-width: {}rem; height: {}rem;", bulb_map_width, bulb_map_height),
|
||||||
},
|
},
|
||||||
ev(Ev::Click, |_| Msg::DeselectGroups),
|
ev(Ev::Click, |_| Msg::DeselectGroups),
|
||||||
self.bulb_map.groups.iter().enumerate().map(view_bulb_group),
|
self.bulb_map.groups.iter().enumerate().map(view_bulb_group),
|
||||||
],
|
],
|
||||||
div![
|
div![
|
||||||
C![C.bulb_controls],
|
C![C.bulb_controls],
|
||||||
self.color_picker
|
IF!(selected_bulb.is_none() => C![C.cross_out]),
|
||||||
.view()
|
self.color_picker.view().map_msg(Msg::ColorPicker),
|
||||||
.map_msg(|msg| Msg::ColorPicker(msg)),
|
|
||||||
button![
|
button![
|
||||||
if power {
|
if selected_bulb.map(|b| b.mode.power).unwrap_or(false) {
|
||||||
C![C.bulb_power_button, C.bulb_power_button_on]
|
C![C.bulb_power_button, C.bulb_power_button_on]
|
||||||
} else {
|
} else {
|
||||||
C![C.bulb_power_button]
|
C![C.bulb_power_button]
|
||||||
},
|
},
|
||||||
ev(Ev::Click, move |_| Msg::SetBulbPower(!power)),
|
{
|
||||||
|
let target_power = selected_bulb.map(|b| !b.mode.power);
|
||||||
|
ev(Ev::Click, move |_| target_power.map(Msg::SetBulbPower))
|
||||||
|
},
|
||||||
div![attrs! { At::Id => "switch_socket" }],
|
div![attrs! { At::Id => "switch_socket" }],
|
||||||
div![attrs! { At::Id => "off_label" }, "Off"],
|
div![attrs! { At::Id => "off_label" }, "Off"],
|
||||||
div![attrs! { At::Id => "on_label" }, "On"],
|
div![attrs! { At::Id => "on_label" }, "On"],
|
||||||
@ -224,12 +277,26 @@ impl Page for Model {
|
|||||||
div![attrs! { At::Id => "lever_face" }],
|
div![attrs! { At::Id => "lever_face" }],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
div![
|
||||||
|
C![C.calendar_box],
|
||||||
|
IF!(selected_bulb.is_none() => C![C.cross_out]),
|
||||||
|
h2!["Wake Schedule"],
|
||||||
|
table![
|
||||||
|
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 {
|
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
|
self.selected_groups
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&index| self.bulb_map.groups.get(index))
|
.filter_map(|&index| self.bulb_map.groups.get(index))
|
||||||
|
|||||||
@ -19,6 +19,16 @@ body {
|
|||||||
font-size: x-large;
|
font-size: x-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* filthy hacks to make the page stop overflowing on mobile */
|
||||||
|
@media (width <= 430px) {
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
scale: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (width <= 390px) { body { scale: 0.85; } }
|
||||||
|
|
||||||
.info_box {
|
.info_box {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
@ -84,16 +94,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bulb_box {
|
.bulb_box {
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-around;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulb_box > * {
|
.bulb_box > * {
|
||||||
margin: 0.5em;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulb_controls {
|
.bulb_controls {
|
||||||
@ -101,24 +107,62 @@ body {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: 0.5em;
|
padding-right: 0.5em;
|
||||||
background: #56636e;
|
background: #00000000;
|
||||||
border: solid 0.25em #5b3f63;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cross_out {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross_out:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
background: #00000000;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross_out:after {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
background: #56636e;
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
-webkit-transform: rotate(-30deg);
|
||||||
|
transform: rotate(-30deg);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
z-index: 91;
|
||||||
|
box-shadow: black 0.5rem 0.6rem 1rem 0.3rem;
|
||||||
|
animation: to_width_90 .2s ease-out 0s 1 forwards;
|
||||||
|
}
|
||||||
|
@keyframes to_width_90 { to { width: 90%; } }
|
||||||
|
|
||||||
.bulb_map {
|
.bulb_map {
|
||||||
background: url(images/blueprint_bg.png);
|
background: url(images/blueprint_bg.png);
|
||||||
background-size: auto;
|
background-size: auto;
|
||||||
background-size: 1em;
|
background-size: 1em;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border: solid 0.4rem #5b3f63;
|
border: solid 0.1rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulb_map::after {
|
.bulb_map::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
content: #0003;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulb_group {
|
.bulb_group {
|
||||||
@ -130,6 +174,7 @@ body {
|
|||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
box-shadow: #0006 0.1em 0.1em 0.1em;
|
box-shadow: #0006 0.1em 0.1em 0.1em;
|
||||||
|
user-select: none;
|
||||||
transition: 0.3s ease-in-out;
|
transition: 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,3 +304,36 @@ body {
|
|||||||
|
|
||||||
transition: margin 0.1s ease-out;
|
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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar_box > h2 {
|
||||||
|
writing-mode: sideways-lr;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
.calendar_box > * {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user