diff --git a/Cargo.lock b/Cargo.lock index 2c709c8..b125145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6127248204b9aba09a362f6c930ef6a78f2c1b2215f8a7b398c06e1083f17af0" +dependencies = [ + "js-sys", + "num-integer", + "num-traits", + "serde", + "time 0.1.44", + "wasm-bindgen", + "winapi", +] + [[package]] name = "clap" version = "3.2.6" @@ -179,7 +194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" dependencies = [ "percent-encoding", - "time", + "time 0.3.11", "version_check 0.9.4", ] @@ -402,7 +417,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -514,6 +529,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "clap", "common", "futures", @@ -791,7 +807,7 @@ checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -827,6 +843,25 @@ dependencies = [ "twoway", ] +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.13.1" @@ -1399,6 +1434,17 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.3.11" @@ -1726,6 +1772,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 957bf92..1540001 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,7 +10,7 @@ pretty_env_logger = "0.4.0" tokio = { version = "1.19.2", features = ["full"] } warp = "0.3.2" ron = "0.7.1" -reqwest = { version = "0.11.11", default_features = false, features = ["rustls-tls"] } +reqwest = { version = "0.11.11", default_features = false, features = ["rustls-tls", "json"] } async-trait = "0.1.56" anyhow = "1.0.58" markdown = "0.3.0" @@ -18,6 +18,7 @@ clap = { version = "3.2.6", features = ["derive"] } toml = "0.5.9" serde = { version = "1.0.138", features = ["derive"] } futures = "0.3.21" +chrono = { version = "0.4.20", features = ["serde"] } [dependencies.common] path = "../common" diff --git a/backend/example.config.toml b/backend/example.config.toml index b75a662..53d77ba 100644 --- a/backend/example.config.toml +++ b/backend/example.config.toml @@ -9,6 +9,12 @@ markdown_web_links = [ "https://example.org/lmao.md" ] +#weatherapi_key = "" +#weatherapi_locations = [ +# "London", +#] + + [[bulbs]] id = "light/bedroom" diff --git a/backend/src/collector.rs b/backend/src/collector.rs index 27c4e19..2f84153 100644 --- a/backend/src/collector.rs +++ b/backend/src/collector.rs @@ -1,6 +1,8 @@ mod markdown_web; +mod weatherapi; pub use markdown_web::MarkdownWeb; +pub use weatherapi::WeatherApi; use serde::Deserialize; @@ -12,4 +14,7 @@ pub trait Collector { #[derive(Deserialize)] pub struct CollectorConfig { pub markdown_web_links: Vec, + + pub weatherapi_key: Option, + pub weatherapi_locations: Vec, } diff --git a/backend/src/collector/weatherapi.rs b/backend/src/collector/weatherapi.rs new file mode 100644 index 0000000..85d8387 --- /dev/null +++ b/backend/src/collector/weatherapi.rs @@ -0,0 +1,151 @@ +use crate::collector::Collector; +use chrono::{serde::ts_seconds, DateTime, Utc}; +use reqwest::get; +use serde::Deserialize; +use std::fmt::{self, Display, Formatter}; + +#[derive(Deserialize)] +struct Response { + current: CurrentBody, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct Condition { + text: String, + icon: String, + code: i64, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct CurrentBody { + #[serde(with = "ts_seconds")] + last_updated_epoch: DateTime, + last_updated: String, + temp_c: f64, + is_day: i8, + condition: Condition, + wind_kph: f64, + wind_degree: f64, + wind_dir: String, + pressure_mb: f64, + precip_mm: f64, + humidity: f64, + cloud: f64, + feelslike_c: f64, + vis_km: f64, + uv: f64, + gust_kph: f64, +} + +pub struct WeatherApi { + pub api_key: String, + pub location: String, +} + +#[async_trait::async_trait] +impl Collector for WeatherApi { + async fn collect(&mut self) -> anyhow::Result { + let path = "https://api.weatherapi.com/v1/current.json"; + let api_key = &self.api_key; + let location = &self.location; + let url = format!("{path}?key={api_key}&q={location}&aqi=no"); + let Response { + current: + CurrentBody { + last_updated_epoch, + temp_c, + feelslike_c, + wind_kph, + precip_mm, + humidity, + cloud, + .. + }, + .. + } = get(&url).await?.json().await?; + + let wind_mps = (wind_kph / 3.6).round(); + let wind = wind_speed_to_beaufort(wind_mps); + + let temp = if feelslike_c != temp_c { + format!("**{temp_c}°** (känns som {feelslike_c}°)") + } else { + format!("**{temp_c}°**") + }; + + let time_of_day = last_updated_epoch.naive_local().format("%H:%M"); + + let markdown = format!( + r#" +# Väder + +## {location} kl {time_of_day} + +{temp}. **{wind}** ({wind_mps} m/s). + +{cloud}% molntäcke, {precip_mm} mm regn, {humidity}% luftfuktighet. + + "# + ); + + let html = markdown::to_html(markdown.trim()); + + Ok(html) + } +} + +/// Wind speed expressed on the beaufort scale +struct BeaufortScale(u8); + +impl Display for BeaufortScale { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let name = match self { + BeaufortScale(0) => "Lugnt", + BeaufortScale(1 | 2) => "Svag vind", + BeaufortScale(3 | 4) => "Måttlig vind", + BeaufortScale(5 | 6) => "Frisk vind", + BeaufortScale(7 | 8) => "Hård vind", + BeaufortScale(9) => "Mycket hård vind", + BeaufortScale(10) => "Storm", + BeaufortScale(11) => "Svår storm", + BeaufortScale(12..) => "Orkan", + }; + + write!(f, "{name}") + } +} + +fn wind_speed_to_beaufort(mps: f64) -> BeaufortScale { + let beaufort_wind_speeds = [ + 0.0..0.3, + 0.3..1.6, + 1.6..3.4, + 3.4..5.5, + 5.5..8.8, + 8.0..10.8, + 10.8..13.9, + 13.9..17.2, + 17.2..20.8, + 20.8..24.5, + 24.5..28.5, + 28.5..32.7, + 32.7..37.0, + 37.0..41.5, + 41.5..46.2, + 46.2..51.0, + 51.0..56.1, + 56.1..61.3, + ]; + + let mps = mps.max(0.0); + + let index = beaufort_wind_speeds + .iter() + .enumerate() + .find_map(|(i, range)| range.contains(&mps).then(|| i)) + .unwrap_or(beaufort_wind_speeds.len()); + + BeaufortScale(index as u8) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 503571d..201c369 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,7 +1,7 @@ mod collector; use clap::Parser; -use collector::{Collector, CollectorConfig, MarkdownWeb}; +use collector::{Collector, CollectorConfig, MarkdownWeb, WeatherApi}; use common::{BulbMap, ClientMessage, ServerMessage}; use futures_util::{SinkExt, StreamExt}; use lighter_manager::{ @@ -195,6 +195,22 @@ async fn info_collector(state: &State) { })); } + 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; @@ -266,7 +282,17 @@ async fn client_handler(mut socket: WebSocket, state: &State) { None => return info!("stream closed"), Some(Err(e)) => return warn!("client error: {e}"), Some(Ok(message)) => { - let message = ron::from_str(message.to_str().unwrap()).unwrap(); + let message = match message.to_str().ok() { + Some(text) => text, + None => continue, + }; + + let message = match ron::from_str(message) { + Ok(message) => message, + Err(e) => { + return error!("failed to deserialize websocket message: {e}"); + } + }; let request = ClientRequest { message, @@ -277,7 +303,6 @@ async fn client_handler(mut socket: WebSocket, state: &State) { return error!("client message handlers error: {e}"); } } - } } }