diff --git a/Cargo.lock b/Cargo.lock index 7aba58b..0bed358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -217,6 +226,8 @@ version = "0.1.0" dependencies = [ "anyhow", "application", + "chrono", + "chrono-tz", "config-sqlite", "domain", "dotenvy", @@ -266,6 +277,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "cipher" version = "0.4.4" @@ -974,6 +1008,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1502,6 +1560,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2017,6 +2093,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2842,6 +2924,41 @@ dependencies = [ "wasite", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index b3f97e4..c086181 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,3 +57,5 @@ postcard = { version = "1.1", default-features = false, features = ["alloc"] } tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] } tower = "0.5" reqwest = { version = "0.12", features = ["json"] } +chrono = "0.4" +chrono-tz = "0.10" diff --git a/crates/adapters/config-sqlite/src/serialization/data_source.rs b/crates/adapters/config-sqlite/src/serialization/data_source.rs index 3ac2b2b..99866ed 100644 --- a/crates/adapters/config-sqlite/src/serialization/data_source.rs +++ b/crates/adapters/config-sqlite/src/serialization/data_source.rs @@ -18,6 +18,8 @@ pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str { DataSourceType::Rss => "rss", DataSourceType::HttpJson => "http_json", DataSourceType::Webhook => "webhook", + DataSourceType::Clock => "clock", + DataSourceType::StaticText => "static_text", } } @@ -28,6 +30,8 @@ fn data_source_type_from_str(s: &str) -> Result Ok(DataSourceType::Rss), "http_json" => Ok(DataSourceType::HttpJson), "webhook" => Ok(DataSourceType::Webhook), + "clock" => Ok(DataSourceType::Clock), + "static_text" => Ok(DataSourceType::StaticText), _ => Err(SqliteConfigError::Serialization(format!( "unknown source type: {s}" ))), @@ -38,33 +42,54 @@ pub fn data_source_config_to_json( config: &DataSourceConfig, secrets: Option<&(dyn SecretStore + Send + Sync)>, ) -> Result { - let api_key = config.api_key.as_ref().map(|k| match secrets { - Some(s) => s.encrypt(k), - None => k.clone(), - }); + let v = match config { + DataSourceConfig::External { + url, + headers, + api_key, + } => { + let api_key = api_key.as_ref().map(|k| match secrets { + Some(s) => s.encrypt(k), + None => k.clone(), + }); - let headers: Vec<(String, String)> = config - .headers - .iter() - .map(|(k, v)| { - let val = if is_sensitive_key(k) { - match secrets { - Some(s) => s.encrypt(v), - None => v.clone(), - } - } else { - v.clone() - }; - (k.clone(), val) - }) - .collect(); + let headers: Vec<(String, String)> = headers + .iter() + .map(|(k, v)| { + let val = if is_sensitive_key(k) { + match secrets { + Some(s) => s.encrypt(v), + None => v.clone(), + } + } else { + v.clone() + }; + (k.clone(), val) + }) + .collect(); - let v = serde_json::json!({ - "url": config.url, - "headers": headers, - "api_key": api_key, - "encrypted": secrets.is_some(), - }); + serde_json::json!({ + "type": "external", + "url": url, + "headers": headers, + "api_key": api_key, + "encrypted": secrets.is_some(), + }) + } + DataSourceConfig::Clock { format, timezone } => { + serde_json::json!({ + "type": "clock", + "format": format, + "timezone": timezone, + }) + } + DataSourceConfig::StaticText { text } => { + serde_json::json!({ + "type": "static_text", + "text": text, + }) + } + }; serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string())) } @@ -75,47 +100,61 @@ fn data_source_config_from_json( let v: serde_json::Value = serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?; - let encrypted = v["encrypted"].as_bool().unwrap_or(false); + let config_type = v["type"].as_str().unwrap_or("external"); - let url = v["url"].as_str().map(String::from); - - let api_key = v["api_key"].as_str().map(|k| { - if encrypted { - match secrets { - Some(s) => s.decrypt(k), - None => k.to_string(), - } - } else { - k.to_string() + match config_type { + "clock" => { + let format = v["format"].as_str().unwrap_or("%H:%M:%S").to_string(); + let timezone = v["timezone"].as_str().unwrap_or("UTC").to_string(); + Ok(DataSourceConfig::Clock { format, timezone }) } - }); + "static_text" => { + let text = v["text"].as_str().unwrap_or("").to_string(); + Ok(DataSourceConfig::StaticText { text }) + } + _ => { + let encrypted = v["encrypted"].as_bool().unwrap_or(false); + let url = v["url"].as_str().map(String::from); - let headers = match v["headers"].as_array() { - Some(arr) => arr - .iter() - .filter_map(|h| { - let pair = h.as_array()?; - let key: String = pair[0].as_str()?.into(); - let raw_val: &str = pair[1].as_str()?; - let val = if encrypted && is_sensitive_key(&key) { + let api_key = v["api_key"].as_str().map(|k| { + if encrypted { match secrets { - Some(s) => s.decrypt(raw_val), - None => raw_val.to_string(), + Some(s) => s.decrypt(k), + None => k.to_string(), } } else { - raw_val.to_string() - }; - Some((key, val)) - }) - .collect(), - None => vec![], - }; + k.to_string() + } + }); - Ok(DataSourceConfig { - url, - headers, - api_key, - }) + let headers = match v["headers"].as_array() { + Some(arr) => arr + .iter() + .filter_map(|h| { + let pair = h.as_array()?; + let key: String = pair[0].as_str()?.into(); + let raw_val: &str = pair[1].as_str()?; + let val = if encrypted && is_sensitive_key(&key) { + match secrets { + Some(s) => s.decrypt(raw_val), + None => raw_val.to_string(), + } + } else { + raw_val.to_string() + }; + Some((key, val)) + }) + .collect(), + None => vec![], + }; + + Ok(DataSourceConfig::External { + url, + headers, + api_key, + }) + } + } } pub fn data_source_from_row( diff --git a/crates/adapters/config-sqlite/tests/config_store_tests.rs b/crates/adapters/config-sqlite/tests/config_store_tests.rs index 334433b..9ce2429 100644 --- a/crates/adapters/config-sqlite/tests/config_store_tests.rs +++ b/crates/adapters/config-sqlite/tests/config_store_tests.rs @@ -36,7 +36,7 @@ fn weather_source() -> DataSource { name: "openweather".into(), source_type: DataSourceType::Weather, poll_interval: Duration::from_secs(300), - config: DataSourceConfig { + config: DataSourceConfig::External { url: Some("https://api.openweather.org".into()), headers: vec![], api_key: Some("test-key".into()), @@ -125,8 +125,13 @@ async fn save_and_retrieve_data_source() { assert_eq!(ds.name, "openweather"); assert_eq!(ds.source_type, DataSourceType::Weather); assert_eq!(ds.poll_interval, Duration::from_secs(300)); - assert_eq!(ds.config.url, Some("https://api.openweather.org".into())); - assert_eq!(ds.config.api_key, Some("test-key".into())); + match &ds.config { + DataSourceConfig::External { url, api_key, .. } => { + assert_eq!(*url, Some("https://api.openweather.org".into())); + assert_eq!(*api_key, Some("test-key".into())); + } + _ => panic!("expected External config"), + } } #[tokio::test] diff --git a/crates/adapters/http-api/tests/api_tests.rs b/crates/adapters/http-api/tests/api_tests.rs index 242e348..f6219ba 100644 --- a/crates/adapters/http-api/tests/api_tests.rs +++ b/crates/adapters/http-api/tests/api_tests.rs @@ -171,9 +171,7 @@ async fn create_and_get_data_source() { "name": "weather_api", "source_type": "weather", "poll_interval_secs": 300, - "url": "https://api.openweather.org", - "api_key": "test-key", - "headers": [] + "config": {"type": "external", "url": "https://api.openweather.org", "api_key": "test-key", "headers": []} }"#; let resp = app diff --git a/crates/adapters/http-json/src/lib.rs b/crates/adapters/http-json/src/lib.rs index 5f60b8c..a3efed6 100644 --- a/crates/adapters/http-json/src/lib.rs +++ b/crates/adapters/http-json/src/lib.rs @@ -47,15 +47,23 @@ impl DataSourcePort for HttpJsonAdapter { type Error = HttpJsonError; async fn poll(&self, source: &DataSource) -> Result { - let url = source.config.url.as_ref().ok_or(HttpJsonError::NoUrl)?; + let domain::DataSourceConfig::External { + ref url, + ref headers, + ref api_key, + } = source.config + else { + return Err(HttpJsonError::NoUrl); + }; + let url = url.as_ref().ok_or(HttpJsonError::NoUrl)?; let mut req = self.client.get(url); - for (key, val) in &source.config.headers { + for (key, val) in headers { req = req.header(key, val); } - if let Some(api_key) = &source.config.api_key { + if let Some(api_key) = api_key { req = req.header("Authorization", format!("Bearer {api_key}")); } diff --git a/crates/adapters/http-json/tests/http_json_tests.rs b/crates/adapters/http-json/tests/http_json_tests.rs index a843489..902abca 100644 --- a/crates/adapters/http-json/tests/http_json_tests.rs +++ b/crates/adapters/http-json/tests/http_json_tests.rs @@ -34,7 +34,7 @@ fn make_source(url: String) -> DataSource { name: "test".into(), source_type: DataSourceType::HttpJson, poll_interval: Duration::from_secs(60), - config: DataSourceConfig { + config: DataSourceConfig::External { url: Some(url), headers: vec![], api_key: None, @@ -82,7 +82,7 @@ async fn returns_error_when_no_url() { name: "bad".into(), source_type: DataSourceType::HttpJson, poll_interval: Duration::from_secs(60), - config: DataSourceConfig { + config: DataSourceConfig::External { url: None, headers: vec![], api_key: None, diff --git a/crates/adapters/media/src/lib.rs b/crates/adapters/media/src/lib.rs index 4da148c..50fbf8f 100644 --- a/crates/adapters/media/src/lib.rs +++ b/crates/adapters/media/src/lib.rs @@ -38,11 +38,19 @@ impl DataSourcePort for MediaAdapter { type Error = MediaError; async fn poll(&self, source: &DataSource) -> Result { - let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?; - let username = find_header(&source.config.headers, "username") - .ok_or(MediaError::MissingField("username"))?; - let password = find_header(&source.config.headers, "password") - .ok_or(MediaError::MissingField("password"))?; + let domain::DataSourceConfig::External { + ref url, + ref headers, + .. + } = source.config + else { + return Err(MediaError::NoUrl); + }; + let base_url = url.as_ref().ok_or(MediaError::NoUrl)?; + let username = + find_header(headers, "username").ok_or(MediaError::MissingField("username"))?; + let password = + find_header(headers, "password").ok_or(MediaError::MissingField("password"))?; let salt: String = (0..12).map(|_| fastrand::alphanumeric()).collect(); let token = subsonic_token(password, &salt); diff --git a/crates/adapters/media/tests/media_tests.rs b/crates/adapters/media/tests/media_tests.rs index ae3a935..4bad61b 100644 --- a/crates/adapters/media/tests/media_tests.rs +++ b/crates/adapters/media/tests/media_tests.rs @@ -45,7 +45,7 @@ fn make_source(url: String) -> DataSource { name: "navidrome".into(), source_type: DataSourceType::Media, poll_interval: Duration::from_secs(5), - config: DataSourceConfig { + config: DataSourceConfig::External { url: Some(url), headers: vec![ ("username".into(), "test".into()), diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index 4830cad..7ca2e3f 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -28,7 +28,10 @@ impl DataSourcePort for RssAdapter { type Error = RssError; async fn poll(&self, source: &DataSource) -> Result { - let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?; + let domain::DataSourceConfig::External { ref url, .. } = source.config else { + return Err(RssError::NoUrl); + }; + let url = url.as_ref().ok_or(RssError::NoUrl)?; let resp = self .client diff --git a/crates/api-types/src/data_source.rs b/crates/api-types/src/data_source.rs index 636ac57..5dd920b 100644 --- a/crates/api-types/src/data_source.rs +++ b/crates/api-types/src/data_source.rs @@ -2,60 +2,128 @@ use domain::*; use serde::{Deserialize, Serialize}; use std::time::Duration; +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum DataSourceConfigDto { + #[serde(rename = "external")] + External { + #[serde(default)] + url: Option, + #[serde(default)] + api_key: Option, + #[serde(default)] + headers: Vec<(String, String)>, + }, + #[serde(rename = "clock")] + Clock { + #[serde(default = "default_clock_format")] + format: String, + #[serde(default = "default_timezone")] + timezone: String, + }, + #[serde(rename = "static_text")] + StaticText { + #[serde(default)] + text: String, + }, +} + +fn default_clock_format() -> String { + "%H:%M:%S".into() +} + +fn default_timezone() -> String { + "UTC".into() +} + #[derive(Serialize, Deserialize)] pub struct DataSourceDto { pub id: u16, pub name: String, pub source_type: String, pub poll_interval_secs: u64, - pub url: Option, - pub api_key: Option, - pub headers: Vec<(String, String)>, + pub config: DataSourceConfigDto, +} + +fn source_type_to_str(t: &DataSourceType) -> &'static str { + match t { + DataSourceType::Weather => "weather", + DataSourceType::Media => "media", + DataSourceType::Rss => "rss", + DataSourceType::HttpJson => "http_json", + DataSourceType::Webhook => "webhook", + DataSourceType::Clock => "clock", + DataSourceType::StaticText => "static_text", + } +} + +fn source_type_from_str(s: &str) -> Result { + match s { + "weather" => Ok(DataSourceType::Weather), + "media" => Ok(DataSourceType::Media), + "rss" => Ok(DataSourceType::Rss), + "http_json" => Ok(DataSourceType::HttpJson), + "webhook" => Ok(DataSourceType::Webhook), + "clock" => Ok(DataSourceType::Clock), + "static_text" => Ok(DataSourceType::StaticText), + t => Err(format!("unknown source_type: {t}")), + } } impl From<&DataSource> for DataSourceDto { fn from(ds: &DataSource) -> Self { + let config = match &ds.config { + DataSourceConfig::External { + url, + api_key, + headers, + } => DataSourceConfigDto::External { + url: url.clone(), + api_key: api_key.clone(), + headers: headers.clone(), + }, + DataSourceConfig::Clock { format, timezone } => DataSourceConfigDto::Clock { + format: format.clone(), + timezone: timezone.clone(), + }, + DataSourceConfig::StaticText { text } => { + DataSourceConfigDto::StaticText { text: text.clone() } + } + }; Self { id: ds.id, name: ds.name.clone(), - source_type: match ds.source_type { - DataSourceType::Weather => "weather", - DataSourceType::Media => "media", - - DataSourceType::Rss => "rss", - DataSourceType::HttpJson => "http_json", - DataSourceType::Webhook => "webhook", - } - .into(), + source_type: source_type_to_str(&ds.source_type).into(), poll_interval_secs: ds.poll_interval.as_secs(), - url: ds.config.url.clone(), - api_key: ds.config.api_key.clone(), - headers: ds.config.headers.clone(), + config, } } } impl DataSourceDto { pub fn into_domain(self) -> Result { - let source_type = match self.source_type.as_str() { - "weather" => DataSourceType::Weather, - "media" => DataSourceType::Media, - - "rss" => DataSourceType::Rss, - "http_json" => DataSourceType::HttpJson, - "webhook" => DataSourceType::Webhook, - t => return Err(format!("unknown source_type: {t}")), + let source_type = source_type_from_str(&self.source_type)?; + let config = match self.config { + DataSourceConfigDto::External { + url, + api_key, + headers, + } => DataSourceConfig::External { + url, + api_key, + headers, + }, + DataSourceConfigDto::Clock { format, timezone } => { + DataSourceConfig::Clock { format, timezone } + } + DataSourceConfigDto::StaticText { text } => DataSourceConfig::StaticText { text }, }; Ok(DataSource { id: self.id, name: self.name, source_type, poll_interval: Duration::from_secs(self.poll_interval_secs), - config: DataSourceConfig { - url: self.url, - api_key: self.api_key, - headers: self.headers, - }, + config, }) } } diff --git a/crates/application/tests/config_service_tests.rs b/crates/application/tests/config_service_tests.rs index 025445d..00263e8 100644 --- a/crates/application/tests/config_service_tests.rs +++ b/crates/application/tests/config_service_tests.rs @@ -47,7 +47,7 @@ async fn create_data_source_rejects_invalid() { name: "bad".into(), source_type: DataSourceType::HttpJson, poll_interval: Duration::from_secs(60), - config: DataSourceConfig { + config: DataSourceConfig::External { url: None, headers: vec![], api_key: None, @@ -70,7 +70,7 @@ async fn create_data_source_persists_valid_and_emits_event() { name: "weather".into(), source_type: DataSourceType::Weather, poll_interval: Duration::from_secs(300), - config: DataSourceConfig { + config: DataSourceConfig::External { url: Some("https://api.weather.com".into()), headers: vec![], api_key: None, diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index dbe9962..ef55dbc 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -19,3 +19,5 @@ anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true dotenvy.workspace = true +chrono.workspace = true +chrono-tz.workspace = true diff --git a/crates/bootstrap/src/polling.rs b/crates/bootstrap/src/polling.rs index abb6561..97dc819 100644 --- a/crates/bootstrap/src/polling.rs +++ b/crates/bootstrap/src/polling.rs @@ -1,13 +1,16 @@ use anyhow::Result; use application::DataProjection; +use chrono::Utc; +use chrono_tz::Tz; use config_sqlite::SqliteConfigStore; use domain::{ - BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState, + BroadcastPort, ConfigRepository, DataSource, DataSourceConfig, DataSourcePort, DataSourceType, + Value, WidgetState, }; use http_json::HttpJsonAdapter; use media_adapter::MediaAdapter; use rss_adapter::RssAdapter; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::time::Duration; use tcp_server::TcpBroadcaster; @@ -117,14 +120,6 @@ async fn poll_loop( } }; - let layout = match config.get_layout().await { - Ok(l) => l, - Err(e) => { - warn!(error = %e, "failed to fetch layout"); - continue; - } - }; - let changed: Vec<(u16, WidgetState)> = projection .apply_poll_result(source.id, &result, &widgets) .await; @@ -137,9 +132,7 @@ async fn poll_loop( Some((*id, hint, state.clone())) }) .collect(); - if let Some(l) = &layout - && let Err(e) = broadcaster.push_screen_update(l, &with_hints).await - { + if let Err(e) = broadcaster.push_data_update(&with_hints).await { warn!(error = %e, "failed to push update"); } info!(source = %source.name, count = changed.len(), "pushed widget updates"); @@ -166,8 +159,33 @@ async fn poll_source( .poll(source) .await .map_err(|e| anyhow::anyhow!("{e}")), + DataSourceType::Clock => Ok(generate_clock(&source.config)), + DataSourceType::StaticText => Ok(generate_static_text(&source.config)), DataSourceType::Webhook => Err(anyhow::anyhow!( "webhook sources are push-based, not polled" )), } } + +fn generate_clock(config: &DataSourceConfig) -> Value { + let (fmt, tz_name) = match config { + DataSourceConfig::Clock { format, timezone } => (format.as_str(), timezone.as_str()), + _ => ("%H:%M:%S", "UTC"), + }; + let tz: Tz = tz_name.parse().unwrap_or(chrono_tz::UTC); + let now = Utc::now().with_timezone(&tz); + let formatted = now.format(fmt).to_string(); + let mut map = BTreeMap::new(); + map.insert("time".into(), Value::String(formatted)); + Value::Object(map) +} + +fn generate_static_text(config: &DataSourceConfig) -> Value { + let text = match config { + DataSourceConfig::StaticText { text } => text.clone(), + _ => String::new(), + }; + let mut map = BTreeMap::new(); + map.insert("text".into(), Value::String(text)); + Value::Object(map) +} diff --git a/crates/client-esp32/src/adapters/display.rs b/crates/client-esp32/src/adapters/display.rs index f19608d..f45916c 100644 --- a/crates/client-esp32/src/adapters/display.rs +++ b/crates/client-esp32/src/adapters/display.rs @@ -3,7 +3,7 @@ use embedded_graphics::{ mono_font::{ascii::FONT_6X10, ascii::FONT_10X20, MonoTextStyle}, pixelcolor::Rgb565, prelude::*, - primitives::{PrimitiveStyle, Rectangle}, + primitives::{Circle, PrimitiveStyle, Rectangle}, text::Text, }; @@ -27,6 +27,7 @@ pub struct Esp32DisplayAdapter { trait ErasedDisplay { fn draw_text_span(&mut self, text: &str, x: u16, y: u16, color: Rgb565, font: FontSize) -> Result<(), DisplayError>; fn fill_rect(&mut self, bounds: BoundingBox, color: Rgb565) -> Result<(), DisplayError>; + fn fill_circle(&mut self, x: u16, y: u16, diameter: u16, color: Rgb565) -> Result<(), DisplayError>; fn flush(&mut self) -> Result<(), DisplayError>; } @@ -57,6 +58,14 @@ where Ok(()) } + fn fill_circle(&mut self, x: u16, y: u16, diameter: u16, color: Rgb565) -> Result<(), DisplayError> { + Circle::new(Point::new(x as i32, y as i32), diameter as u32) + .into_styled(PrimitiveStyle::with_fill(color)) + .draw(self) + .map_err(|e| DisplayError::Draw(format!("{e:?}")))?; + Ok(()) + } + fn flush(&mut self) -> Result<(), DisplayError> { Ok(()) } @@ -100,3 +109,9 @@ impl DisplayPort for Esp32DisplayAdapter { self.inner.flush() } } + +impl Esp32DisplayAdapter { + pub fn fill_circle(&mut self, x: u16, y: u16, diameter: u16, color: Color) -> Result<(), DisplayError> { + self.inner.fill_circle(x, y, diameter, to_rgb565(color)) + } +} diff --git a/crates/client-esp32/src/main.rs b/crates/client-esp32/src/main.rs index 4ee9fb7..03634df 100644 --- a/crates/client-esp32/src/main.rs +++ b/crates/client-esp32/src/main.rs @@ -50,7 +50,7 @@ fn run_station( info!("Connecting WiFi..."); match hal::wifi::init(modem, sysloop.clone(), nvs.clone(), &cfg.wifi_ssid, &cfg.wifi_pass) { Ok(_wifi) => { - let (tx, rx) = mpsc::channel(); + let (tx, rx) = mpsc::channel::(); tasks::network::spawn(cfg.server_addr, tx); tasks::render::run(config::SCREEN, display, rx); } diff --git a/crates/client-esp32/src/tasks/mod.rs b/crates/client-esp32/src/tasks/mod.rs index 4ea0ac9..d6b6f24 100644 --- a/crates/client-esp32/src/tasks/mod.rs +++ b/crates/client-esp32/src/tasks/mod.rs @@ -1,2 +1,9 @@ pub mod network; pub mod render; + +use protocol::ServerMessage; + +pub enum RenderEvent { + Server(ServerMessage), + ConnectionStatus(bool), +} diff --git a/crates/client-esp32/src/tasks/network.rs b/crates/client-esp32/src/tasks/network.rs index 605f9c8..438cfa4 100644 --- a/crates/client-esp32/src/tasks/network.rs +++ b/crates/client-esp32/src/tasks/network.rs @@ -1,12 +1,13 @@ use std::sync::mpsc; use std::thread; use client_domain::NetworkPort; -use protocol::{ServerMessage, decode_server_message}; +use protocol::decode_server_message; +use super::RenderEvent; use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY}; use crate::adapters::network::Esp32Network; use log::*; -pub fn spawn(server_addr: String, tx: mpsc::Sender) { +pub fn spawn(server_addr: String, tx: mpsc::Sender) { thread::Builder::new() .stack_size(NET_THREAD_STACK_SIZE) .name("net".into()) @@ -14,16 +15,20 @@ pub fn spawn(server_addr: String, tx: mpsc::Sender) { .expect("failed to spawn network thread"); } -fn run(server_addr: String, tx: mpsc::Sender) { +fn run(server_addr: String, tx: mpsc::Sender) { let mut net = Esp32Network::new(); loop { if !net.is_connected() { info!("Connecting to server {server_addr}..."); match net.connect(&server_addr) { - Ok(()) => info!("Server connected"), + Ok(()) => { + info!("Server connected"); + let _ = tx.send(RenderEvent::ConnectionStatus(true)); + } Err(e) => { error!("Connection failed: {e}, retrying..."); + let _ = tx.send(RenderEvent::ConnectionStatus(false)); thread::sleep(NET_RECONNECT_DELAY); continue; } @@ -33,7 +38,7 @@ fn run(server_addr: String, tx: mpsc::Sender) { match net.receive() { Ok(Some(payload)) => { match decode_server_message(&payload) { - Ok(msg) => { let _ = tx.send(msg); } + Ok(msg) => { let _ = tx.send(RenderEvent::Server(msg)); } Err(e) => error!("Decode error: {e}"), } } @@ -43,6 +48,7 @@ fn run(server_addr: String, tx: mpsc::Sender) { Err(e) => { error!("Receive error: {e}, reconnecting..."); let _ = net.disconnect(); + let _ = tx.send(RenderEvent::ConnectionStatus(false)); thread::sleep(NET_RECONNECT_DELAY); } } diff --git a/crates/client-esp32/src/tasks/render.rs b/crates/client-esp32/src/tasks/render.rs index 99ac912..3ad005c 100644 --- a/crates/client-esp32/src/tasks/render.rs +++ b/crates/client-esp32/src/tasks/render.rs @@ -2,16 +2,21 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; use std::collections::HashMap; use client_domain::{ - BoundingBox, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig, + BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig, }; use client_application::{ClientApp, RepaintCommand}; use domain::{DisplayHint, Value}; use protocol::ServerMessage; +use super::RenderEvent; use crate::config::RENDER_POLL_INTERVAL; use crate::adapters::display::Esp32DisplayAdapter; use log::*; const SCROLL_TICK: Duration = Duration::from_millis(50); +const INDICATOR_DIAMETER: u16 = 8; +const INDICATOR_MARGIN: u16 = 4; +const COLOR_CONNECTED: Color = Color(0, 200, 0); +const COLOR_DISCONNECTED: Color = Color(200, 0, 0); struct WidgetCache { hint: DisplayHint, @@ -23,7 +28,7 @@ struct WidgetCache { pub fn run( screen: BoundingBox, mut display: Esp32DisplayAdapter, - rx: mpsc::Receiver, + rx: mpsc::Receiver, ) { let metrics = FontMetrics { small: (6, 10), @@ -34,13 +39,23 @@ pub fn run( let mut widgets: HashMap = HashMap::new(); let mut first_update = true; let mut last_tick = Instant::now(); + let mut connected = false; info!("Render loop started"); + draw_indicator(&mut display, screen, connected); + display.flush().unwrap(); loop { let timeout = RENDER_POLL_INTERVAL.min(SCROLL_TICK); match rx.recv_timeout(timeout) { - Ok(msg) => { + Ok(RenderEvent::ConnectionStatus(status)) => { + if status != connected { + connected = status; + draw_indicator(&mut display, screen, connected); + display.flush().unwrap(); + } + } + Ok(RenderEvent::Server(msg)) => { let is_screen_update = matches!(msg, ServerMessage::ScreenUpdate { .. }); let repaints = app.handle_message(msg); @@ -49,19 +64,20 @@ pub fn run( engine.set_theme(app.theme().clone()); } + let bg = engine.theme().background; if !repaints.is_empty() && (first_update || is_screen_update || theme_changed) { - let bg = engine.theme().background; display.fill_rect(screen, bg).unwrap(); first_update = false; } - for cmd in &repaints { let cache = update_cache(&engine, cmd); + display.fill_rect(cache.bounds, bg).unwrap(); draw_widget(&engine, &mut display, &cache); widgets.insert(cmd.widget_id, cache); } if !repaints.is_empty() { + draw_indicator(&mut display, screen, connected); display.flush().unwrap(); } } @@ -86,11 +102,19 @@ pub fn run( } } if needs_flush { + draw_indicator(&mut display, screen, connected); display.flush().unwrap(); } } } +fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) { + let color = if connected { COLOR_CONNECTED } else { COLOR_DISCONNECTED }; + let x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN; + let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN; + display.fill_circle(x, y, INDICATOR_DIAMETER, color).unwrap(); +} + fn update_cache(engine: &RenderEngine, cmd: &RepaintCommand) -> WidgetCache { let hint: DisplayHint = cmd.display_hint.clone().into(); let data: Vec<(String, Value)> = cmd.state.data diff --git a/crates/domain/src/entities/data_source.rs b/crates/domain/src/entities/data_source.rs index 40ac6be..53e7249 100644 --- a/crates/domain/src/entities/data_source.rs +++ b/crates/domain/src/entities/data_source.rs @@ -9,13 +9,24 @@ pub enum DataSourceType { Rss, HttpJson, Webhook, + Clock, + StaticText, } #[derive(Debug, Clone)] -pub struct DataSourceConfig { - pub url: Option, - pub headers: Vec<(String, String)>, - pub api_key: Option, +pub enum DataSourceConfig { + External { + url: Option, + headers: Vec<(String, String)>, + api_key: Option, + }, + Clock { + format: String, + timezone: String, + }, + StaticText { + text: String, + }, } #[derive(Debug, Clone)] @@ -38,18 +49,28 @@ impl DataSource { pub fn validate(&self) -> Vec { let mut errors = Vec::new(); - let is_webhook = self.source_type == DataSourceType::Webhook; - - if is_webhook { - if !self.poll_interval.is_zero() { - errors.push(DataSourceValidationError::PollIntervalNotAllowed); + match self.source_type { + DataSourceType::Webhook => { + if !self.poll_interval.is_zero() { + errors.push(DataSourceValidationError::PollIntervalNotAllowed); + } } - } else { - if self.poll_interval.is_zero() { - errors.push(DataSourceValidationError::PollIntervalRequired); + DataSourceType::Clock | DataSourceType::StaticText => { + // Internal sources: poll_interval optional, no url needed } - if self.requires_url() && self.config.url.is_none() { - errors.push(DataSourceValidationError::UrlRequired); + _ => { + if self.poll_interval.is_zero() { + errors.push(DataSourceValidationError::PollIntervalRequired); + } + if self.requires_url() { + let has_url = matches!( + &self.config, + DataSourceConfig::External { url: Some(_), .. } + ); + if !has_url { + errors.push(DataSourceValidationError::UrlRequired); + } + } } } diff --git a/crates/domain/src/entities/widget_config.rs b/crates/domain/src/entities/widget_config.rs index c1f5d03..75f24a2 100644 --- a/crates/domain/src/entities/widget_config.rs +++ b/crates/domain/src/entities/widget_config.rs @@ -35,6 +35,15 @@ impl WidgetConfig { } pub fn extract(&self, raw: &Value) -> WidgetState { + let has_mappings = self.mappings.iter().any(|m| !m.source_path.is_empty()); + if !has_mappings { + let data = match raw { + Value::Object(map) => map.clone(), + _ => BTreeMap::new(), + }; + return WidgetState { data, error: None }; + } + let budget = self.max_data_size as usize; let mut used = 0usize; let mut data = BTreeMap::new(); diff --git a/crates/domain/tests/data_source_tests.rs b/crates/domain/tests/data_source_tests.rs index ef4971a..a24c806 100644 --- a/crates/domain/tests/data_source_tests.rs +++ b/crates/domain/tests/data_source_tests.rs @@ -7,7 +7,7 @@ fn make_source(source_type: DataSourceType, url: Option<&str>, poll: Duration) - name: "test".into(), source_type, poll_interval: poll, - config: DataSourceConfig { + config: DataSourceConfig::External { url: url.map(Into::into), headers: vec![], api_key: None, diff --git a/spa/src/api/types.ts b/spa/src/api/types.ts index 3200705..c78124e 100644 --- a/spa/src/api/types.ts +++ b/spa/src/api/types.ts @@ -7,7 +7,7 @@ export interface DisplayHint { h_align: HAlign v_align: VAlign } -export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook" +export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook" | "clock" | "static_text" export type SizingType = "fixed" | "flex" export type Direction = "row" | "column" export type JustifyContent = "start" | "center" | "end" | "space_between" | "space_evenly" @@ -29,14 +29,17 @@ export interface Widget { export type CreateWidget = Widget +export type DataSourceConfig = + | { type: "external"; url: string | null; api_key: string | null; headers: [string, string][] } + | { type: "clock"; format: string; timezone: string } + | { type: "static_text"; text: string } + export interface DataSource { id: number name: string source_type: SourceType poll_interval_secs: number - url: string | null - api_key: string | null - headers: [string, string][] + config: DataSourceConfig } export interface Sizing { diff --git a/spa/src/pages/data-sources.tsx b/spa/src/pages/data-sources.tsx index 9f9d621..8df8b30 100644 --- a/spa/src/pages/data-sources.tsx +++ b/spa/src/pages/data-sources.tsx @@ -5,7 +5,7 @@ import { useUpdateDataSource, useDeleteDataSource, } from "@/api/data-sources" -import type { DataSource, SourceType } from "@/api/types" +import type { DataSource, DataSourceConfig, SourceType } from "@/api/types" import { Button } from "@/components/ui/button" import { Card, @@ -50,16 +50,24 @@ const SOURCE_TYPES: SourceType[] = [ "rss", "http_json", "webhook", + "clock", + "static_text", ] +const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"] + +function defaultConfigFor(sourceType: SourceType): DataSourceConfig { + if (sourceType === "clock") return { type: "clock", format: "%H:%M:%S", timezone: "UTC" } + if (sourceType === "static_text") return { type: "static_text", text: "" } + return { type: "external", url: null, api_key: null, headers: [] } +} + const EMPTY: DataSource = { id: 0, name: "", source_type: "http_json", poll_interval_secs: 300, - url: null, - api_key: null, - headers: [], + config: { type: "external", url: null, api_key: null, headers: [] }, } export function DataSourcesPage() { @@ -145,10 +153,10 @@ export function DataSourcesPage() { {ds.name} {ds.source_type} - every {ds.poll_interval_secs}s - {ds.url && ( + {ds.poll_interval_secs > 0 && every {ds.poll_interval_secs}s} + {ds.config.type === "external" && ds.config.url && ( - {ds.url} + {ds.config.url} )} @@ -196,9 +204,13 @@ export function DataSourcesPage() { onClick={save} disabled={ !editing?.name || - (editing.source_type !== "webhook" && + (EXTERNAL_TYPES.includes(editing.source_type) && + editing.source_type !== "webhook" && editing.poll_interval_secs <= 0) || - (editing.source_type !== "webhook" && !editing.url) + (EXTERNAL_TYPES.includes(editing.source_type) && + editing.source_type !== "webhook" && + editing.config.type === "external" && + !editing.config.url) } > Save @@ -302,6 +314,17 @@ function DataSourceForm({ const set = (k: K, v: DataSource[K]) => onChange({ ...value, [k]: v }) + const setConfig = (patch: Partial) => + onChange({ ...value, config: { ...value.config, ...patch } as DataSourceConfig }) + + const onSourceTypeChange = (t: SourceType) => { + onChange({ ...value, source_type: t, config: defaultConfigFor(t) }) + } + + const isExternal = value.config.type === "external" + const isClock = value.config.type === "clock" + const isStaticText = value.config.type === "static_text" + return (
@@ -316,7 +339,7 @@ function DataSourceForm({
-
- - set("url", e.target.value || null)} - placeholder="https://..." - /> -
-
- - set("api_key", e.target.value || null)} - placeholder="Optional" - /> -
+ + {isExternal && ( + <> +
+ + setConfig({ url: e.target.value || null })} + placeholder="https://..." + /> +
+
+ + setConfig({ api_key: e.target.value || null })} + placeholder="Optional" + /> +
+ + )} + + {isClock && ( + <> +
+ + setConfig({ format: e.target.value })} + placeholder="%H:%M:%S" + /> +
+
+ + setConfig({ timezone: e.target.value })} + placeholder="Europe/Warsaw" + /> +
+ + )} + + {isStaticText && ( +
+ + setConfig({ text: e.target.value })} + placeholder="Hello world" + /> +
+ )} +
-
-
- - + + {isExternal && ( +
+
+ + +
+ {value.config.headers.map(([k, v], i) => ( + { + const next = [...value.config.headers] as [string, string][] + next[i] = [newKey, v] + setConfig({ headers: next }) + }} + onChangeValue={(newVal) => { + const next = [...value.config.headers] as [string, string][] + next[i] = [k, newVal] + setConfig({ headers: next }) + }} + onRemove={() => + setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) }) + } + /> + ))}
- {value.headers.map(([k, v], i) => ( - { - const next = [...value.headers] as [string, string][] - next[i] = [newKey, v] - set("headers", next) - }} - onChangeValue={(newVal) => { - const next = [...value.headers] as [string, string][] - next[i] = [k, newVal] - set("headers", next) - }} - onRemove={() => - set("headers", value.headers.filter((_, idx) => idx !== i)) - } - /> - ))} -
+ )}
) } diff --git a/spa/src/pages/widgets.tsx b/spa/src/pages/widgets.tsx index ca0a154..e9690b6 100644 --- a/spa/src/pages/widgets.tsx +++ b/spa/src/pages/widgets.tsx @@ -199,8 +199,7 @@ export function WidgetsPage() { onClick={save} disabled={ !editing?.name || - !editing.data_source_id || - editing.mappings.length === 0 + !editing.data_source_id } > Save