Compare commits

...

2 Commits

Author SHA1 Message Date
a6152c9a9a update README.md to include clock and static text as data sources, and add widget alignment and connection indicator features 2026-06-19 12:35:10 +02:00
455d5da901 webhook through event system, extract data-generators adapter
webhook route now emits WebhookDataReceived event instead of directly
mutating DataProjection and broadcasting. event_handler applies data
and pushes incremental DataUpdate.

clock/static_text generators extracted to data-generators crate behind
DataSourcePort. chrono removed from bootstrap. polling adapters grouped
into Adapters struct.
2026-06-19 12:33:42 +02:00
10 changed files with 220 additions and 117 deletions

13
Cargo.lock generated
View File

@@ -226,9 +226,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"application", "application",
"chrono",
"chrono-tz",
"config-sqlite", "config-sqlite",
"data-generators",
"domain", "domain",
"dotenvy", "dotenvy",
"http-api", "http-api",
@@ -467,6 +466,16 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "data-generators"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"domain",
"thiserror",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"

View File

@@ -16,6 +16,7 @@ members = [
"crates/adapters/media", "crates/adapters/media",
"crates/adapters/auth", "crates/adapters/auth",
"crates/adapters/secret-store", "crates/adapters/secret-store",
"crates/adapters/data-generators",
"crates/api-types", "crates/api-types",
"crates/bootstrap", "crates/bootstrap",
"crates/client-desktop", "crates/client-desktop",
@@ -57,5 +58,4 @@ postcard = { version = "1.1", default-features = false, features = ["alloc"] }
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] } tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
tower = "0.5" tower = "0.5"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4" data-generators = { path = "crates/adapters/data-generators" }
chrono-tz = "0.10"

View File

@@ -25,6 +25,7 @@ Hexagonal / ports-and-adapters with full CQRS. Domain logic has zero framework d
│ tcp-server binary protocol broadcast │ │ tcp-server binary protocol broadcast │
│ http-json external API polling │ │ http-json external API polling │
│ media, rss source-specific adapters │ │ media, rss source-specific adapters │
│ data-generators clock, static text │
│ auth argon2 + JWT │ │ auth argon2 + JWT │
├─────────────────── Shared ───────────────────┤ ├─────────────────── Shared ───────────────────┤
│ protocol/ wire types, postcard serde │ │ protocol/ wire types, postcard serde │
@@ -53,10 +54,12 @@ See `docs/adr/` for architectural decision records and `CONTEXT.md` for the doma
## Features ## Features
- **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks - **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks, clock, static text
- **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items) - **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items)
- **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients - **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
- **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`) - **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`)
- **Widget alignment**: per-widget horizontal/vertical text alignment (left/center/right, top/middle/bottom), reflected in layout preview
- **Connection indicator**: green/red dot on ESP32 display showing server connectivity
- **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow - **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow
- **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning - **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
- **Auth**: argon2 password hashing, JWT tokens, protected API routes - **Auth**: argon2 password hashing, JWT tokens, protected API routes

View File

@@ -0,0 +1,10 @@
[package]
name = "data-generators"
version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
chrono = "0.4"
chrono-tz = "0.10"
thiserror.workspace = true

View File

@@ -0,0 +1,59 @@
use chrono::Utc;
use chrono_tz::Tz;
use domain::{DataSource, DataSourceConfig, DataSourcePort, Value};
use std::collections::BTreeMap;
#[derive(Default)]
pub struct ClockGenerator;
impl ClockGenerator {
pub fn new() -> Self {
Self
}
}
#[derive(Debug, thiserror::Error)]
pub enum GeneratorError {
#[error("wrong config type for generator")]
WrongConfig,
}
impl DataSourcePort for ClockGenerator {
type Error = GeneratorError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let (fmt, tz_name) = match &source.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));
Ok(Value::Object(map))
}
}
#[derive(Default)]
pub struct StaticTextGenerator;
impl StaticTextGenerator {
pub fn new() -> Self {
Self
}
}
impl DataSourcePort for StaticTextGenerator {
type Error = GeneratorError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let text = match &source.config {
DataSourceConfig::StaticText { text } => text.clone(),
_ => String::new(),
};
let mut map = BTreeMap::new();
map.insert("text".into(), Value::String(text));
Ok(Value::Object(map))
}
}

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Json; use axum::response::Json;
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader}; use domain::{ConfigRepository, DomainEvent, EventPublisher};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>; type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
@@ -16,9 +16,6 @@ where
C::Error: std::fmt::Debug, C::Error: std::fmt::Debug,
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
W: WidgetStateReader,
B: BroadcastPort,
B::Error: std::fmt::Debug,
{ {
let source = state let source = state
.config .config
@@ -34,37 +31,14 @@ where
)); ));
} }
let raw = json_to_domain_value(body); let data = json_to_domain_value(body);
let widgets = state
.config state
.list_widgets() .events
.publish(DomainEvent::WebhookDataReceived { source_id, data })
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let layout = state
.config
.get_layout()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let changed = state
.widget_states
.apply_raw_data(source_id, &raw, &widgets)
.await;
if !changed.is_empty()
&& let Some(l) = &layout
{
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, s)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, s.clone()))
})
.collect();
let _ = state.broadcaster.push_screen_update(l, &with_hints).await;
}
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }

View File

@@ -19,5 +19,4 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
dotenvy.workspace = true dotenvy.workspace = true
chrono.workspace = true data-generators.workspace = true
chrono-tz.workspace = true

View File

@@ -40,6 +40,37 @@ pub async fn run(
info!("layout changed, pushed screen update to clients"); info!("layout changed, pushed screen update to clients");
} }
Ok(DomainEvent::WebhookDataReceived { source_id, data }) => {
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
continue;
}
};
let changed = projection
.apply_poll_result(source_id, &data, &widgets)
.await;
if !changed.is_empty() {
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, state)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
error!(error = %e, "failed to push webhook data update");
}
info!(
source_id,
count = changed.len(),
"webhook data received, pushed update"
);
}
}
Ok(DomainEvent::ThemeChanged { theme }) => { Ok(DomainEvent::ThemeChanged { theme }) => {
if let Err(e) = broadcaster.push_theme_update(&theme).await { if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update"); error!(error = %e, "failed to push theme update");

View File

@@ -1,16 +1,14 @@
use anyhow::Result; use anyhow::Result;
use application::DataProjection; use application::DataProjection;
use chrono::Utc;
use chrono_tz::Tz;
use config_sqlite::SqliteConfigStore; use config_sqlite::SqliteConfigStore;
use data_generators::{ClockGenerator, StaticTextGenerator};
use domain::{ use domain::{
BroadcastPort, ConfigRepository, DataSource, DataSourceConfig, DataSourcePort, DataSourceType, BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState,
Value, WidgetState,
}; };
use http_json::HttpJsonAdapter; use http_json::HttpJsonAdapter;
use media_adapter::MediaAdapter; use media_adapter::MediaAdapter;
use rss_adapter::RssAdapter; use rss_adapter::RssAdapter;
use std::collections::{BTreeMap, HashMap}; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tcp_server::TcpBroadcaster; use tcp_server::TcpBroadcaster;
@@ -19,15 +17,63 @@ use tracing::{debug, info, warn};
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30); const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Clone)]
struct Adapters {
http: Arc<HttpJsonAdapter>,
media: Arc<MediaAdapter>,
rss: Arc<RssAdapter>,
clock: Arc<ClockGenerator>,
static_text: Arc<StaticTextGenerator>,
}
impl Adapters {
async fn poll(&self, source: &DataSource) -> Result<Value> {
match source.source_type {
DataSourceType::HttpJson | DataSourceType::Weather => self
.http
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Media => self
.media
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Rss => self
.rss
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Clock => self
.clock
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::StaticText => self
.static_text
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Webhook => Err(anyhow::anyhow!(
"webhook sources are push-based, not polled"
)),
}
}
}
pub async fn run( pub async fn run(
config: Arc<SqliteConfigStore>, config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>, broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>, projection: Arc<DataProjection>,
_poll_interval_secs: u64, _poll_interval_secs: u64,
) -> Result<()> { ) -> Result<()> {
let http_adapter = Arc::new(HttpJsonAdapter::new()); let adapters = Adapters {
let media_adapter = Arc::new(MediaAdapter::new()); http: Arc::new(HttpJsonAdapter::new()),
let rss_adapter = Arc::new(RssAdapter::new()); media: Arc::new(MediaAdapter::new()),
rss: Arc::new(RssAdapter::new()),
clock: Arc::new(ClockGenerator::new()),
static_text: Arc::new(StaticTextGenerator::new()),
};
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new(); let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
@@ -64,9 +110,7 @@ pub async fn run(
let config = config.clone(); let config = config.clone();
let broadcaster = broadcaster.clone(); let broadcaster = broadcaster.clone();
let projection = projection.clone(); let projection = projection.clone();
let http = http_adapter.clone(); let adapters = adapters.clone();
let media = media_adapter.clone();
let rss = rss_adapter.clone();
info!( info!(
source_id = source.id, source_id = source.id,
@@ -76,7 +120,7 @@ pub async fn run(
); );
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
poll_loop(source, config, broadcaster, projection, http, media, rss).await; poll_loop(source, config, broadcaster, projection, adapters).await;
}); });
running.insert(source_id, handle); running.insert(source_id, handle);
@@ -95,16 +139,14 @@ async fn poll_loop(
config: Arc<SqliteConfigStore>, config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>, broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>, projection: Arc<DataProjection>,
http_adapter: Arc<HttpJsonAdapter>, adapters: Adapters,
media_adapter: Arc<MediaAdapter>,
rss_adapter: Arc<RssAdapter>,
) { ) {
let interval = source.poll_interval; let interval = source.poll_interval;
loop { loop {
tokio::time::sleep(interval).await; tokio::time::sleep(interval).await;
let result = match poll_source(&http_adapter, &media_adapter, &rss_adapter, &source).await { let result = match adapters.poll(&source).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
warn!(source = %source.name, error = %e, "poll failed"); warn!(source = %source.name, error = %e, "poll failed");
@@ -139,53 +181,3 @@ async fn poll_loop(
} }
} }
} }
async fn poll_source(
http_adapter: &HttpJsonAdapter,
media_adapter: &MediaAdapter,
rss_adapter: &RssAdapter,
source: &DataSource,
) -> Result<Value> {
match source.source_type {
DataSourceType::HttpJson | DataSourceType::Weather => http_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Media => media_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Rss => rss_adapter
.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)
}

View File

@@ -1,17 +1,43 @@
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId}; use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
use crate::value_objects::{Layout, ThemeConfig}; use crate::value_objects::{Layout, ThemeConfig, Value};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum DomainEvent { pub enum DomainEvent {
WidgetCreated { id: WidgetId }, WidgetCreated {
WidgetUpdated { id: WidgetId }, id: WidgetId,
WidgetDeleted { id: WidgetId }, },
DataSourceAdded { id: DataSourceId }, WidgetUpdated {
DataSourceUpdated { id: DataSourceId }, id: WidgetId,
DataSourceRemoved { id: DataSourceId }, },
LayoutChanged { layout: Layout }, WidgetDeleted {
ThemeChanged { theme: ThemeConfig }, id: WidgetId,
LayoutPresetSaved { id: LayoutPresetId }, },
LayoutPresetLoaded { id: LayoutPresetId }, DataSourceAdded {
LayoutPresetDeleted { id: LayoutPresetId }, id: DataSourceId,
},
DataSourceUpdated {
id: DataSourceId,
},
DataSourceRemoved {
id: DataSourceId,
},
LayoutChanged {
layout: Layout,
},
ThemeChanged {
theme: ThemeConfig,
},
LayoutPresetSaved {
id: LayoutPresetId,
},
LayoutPresetLoaded {
id: LayoutPresetId,
},
LayoutPresetDeleted {
id: LayoutPresetId,
},
WebhookDataReceived {
source_id: DataSourceId,
data: Value,
},
} }