Compare commits

...

14 Commits

Author SHA1 Message Date
fa097771d4 arch: push wire types out of ClientApp, extract event_service, cleanup dead code
- ClientApp stores domain types, RepaintCommand carries DisplayHint + Vec<(String,Value)>
- adapters no longer convert Wire→Domain (eliminated duplication in esp32 + desktop)
- event_service in application layer handles LayoutChanged/WebhookDataReceived/ThemeChanged
- bootstrap event_handler reduced to 10-line dispatcher
- polling_service reuses event_service::apply_and_broadcast (deduplicated broadcast pattern)
- AppState.config_service() replaces 11 inline ConfigService::new() calls
- delete unused poll_interval_secs parameter chain
- delete unused StoragePort/ClientConfig (zero implementations)
2026-06-19 18:30:14 +02:00
7001b5e911 arch: split ConfigRepository, extract polling, consolidate conversions, decouple protocol
- Value↔JSON: From impls on domain Value behind `json` feature, delete 4 duplicate converters
- ConfigRepository split into ConfigRepository (12), UserRepository (3), WidgetStateCache (2)
- polling orchestration moved from bootstrap to application::polling_service
- WidgetRenderer in client-domain owns scroll/cache, both clients use it
- network loop consolidated into client-application::run_connection_loop
- protocol crate drops domain dep, Wire↔Domain conversions move to adapters
2026-06-19 18:12:50 +02:00
1c854d127f fix bottom scroll artifacts, slow scroll for readability 2026-06-19 13:35:46 +02:00
838e29702a fix scroll artifacts at widget edges, disable esp-mesh 2026-06-19 13:32:44 +02:00
5bcf4c4e0d strip unused esp32 deps, fix render loop power waste 2026-06-19 13:22:12 +02:00
27c1fe3f37 optimize esp32 release binary size: 1.7MB -> 1.1MB 2026-06-19 13:13:23 +02:00
b964801765 remove all modals, inline editing, live layout preview, clock preview
all Dialog/AlertDialog removed from widgets, data-sources, presets,
layout-builder pages. replaced with inline card expansion for
edit/create and inline confirm bars for delete.

data source form: live clock format preview with 1s tick, timezone
validation against Intl.supportedValuesOf.

layout preview: fetches live widget data via useWidgetPreview, renders
formatted content based on display_hint kind instead of widget names.
2026-06-19 13:08:00 +02:00
13497dd53c state recovery, polling optimizations, error rendering
widget states cached to SQLite, loaded on startup to seed DataProjection
so server restart preserves last-known data for reconnecting clients.

polling: first poll runs immediately, widget list cached per-task with
30s refresh, static text polled once inline instead of looping.

poll failures propagate WidgetError::SourceUnavailable to clients.
render engine prepends [offline] prefix in accent color, stale data
preserved below.
2026-06-19 12:56:12 +02:00
8b1dac9669 update README: wiring table, new features, data-generators in arch diagram 2026-06-19 12:37:30 +02:00
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
437056cfc4 clean up 2026-06-19 11:32:49 +02:00
a51d22649a internal data sources (clock, static text), connection indicator, rendering fixes
DataSourceConfig refactored to enum: External/Clock/StaticText. Clock
generates formatted time via chrono, static text emits configured string.

ESP32: connection status indicator (green/red dot bottom-right), per-widget
clear before redraw, RenderEvent enum for local + server messages.

Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state.
Empty mappings passthrough raw source data for internal sources.
2026-06-19 11:26:49 +02:00
b448fa15fe expose h_align/v_align through full stack
display_hint becomes {kind, h_align, v_align} object in API, SQLite
gets alignment columns, SPA widget form gets alignment selects, layout
preview reflects actual alignment instead of hardcoded center
2026-06-19 10:28:09 +02:00
95 changed files with 2897 additions and 8228 deletions

View File

@@ -2,7 +2,6 @@
KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc
KFRAME_TCP_ADDR=0.0.0.0:2699
KFRAME_HTTP_ADDR=0.0.0.0:3000
KFRAME_POLL_INTERVAL_SECS=5
# Auth (required)
JWT_SECRET=change-me-to-a-random-secret

132
Cargo.lock generated
View File

@@ -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"
@@ -70,9 +79,11 @@ dependencies = [
name = "application"
version = "0.1.0"
dependencies = [
"anyhow",
"domain",
"thiserror",
"tokio",
"tracing",
]
[[package]]
@@ -218,6 +229,7 @@ dependencies = [
"anyhow",
"application",
"config-sqlite",
"data-generators",
"domain",
"dotenvy",
"http-api",
@@ -266,6 +278,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"
@@ -433,6 +468,16 @@ dependencies = [
"cipher",
]
[[package]]
name = "data-generators"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"domain",
"thiserror",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -483,6 +528,9 @@ dependencies = [
[[package]]
name = "domain"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "dotenvy"
@@ -974,6 +1022,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 +1574,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"
@@ -1602,7 +1692,6 @@ dependencies = [
name = "protocol"
version = "0.1.0"
dependencies = [
"domain",
"postcard",
"serde",
]
@@ -2017,6 +2106,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 +2937,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"

View File

@@ -16,6 +16,7 @@ members = [
"crates/adapters/media",
"crates/adapters/auth",
"crates/adapters/secret-store",
"crates/adapters/data-generators",
"crates/api-types",
"crates/bootstrap",
"crates/client-desktop",
@@ -57,3 +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"] }
tower = "0.5"
reqwest = { version = "0.12", features = ["json"] }
data-generators = { path = "crates/adapters/data-generators" }

View File

@@ -25,6 +25,7 @@ Hexagonal / ports-and-adapters with full CQRS. Domain logic has zero framework d
│ tcp-server binary protocol broadcast │
│ http-json external API polling │
│ media, rss source-specific adapters │
│ data-generators clock, static text │
│ auth argon2 + JWT │
├─────────────────── Shared ───────────────────┤
│ protocol/ wire types, postcard serde │
@@ -53,10 +54,12 @@ See `docs/adr/` for architectural decision records and `CONTEXT.md` for the doma
## 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)
- **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
- **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
- **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
- **Auth**: argon2 password hashing, JWT tokens, protected API routes
@@ -86,6 +89,23 @@ cd spa && bun install && bun run dev
# 5. Add a data source, create widgets, build a layout
```
### ESP32 wiring
2.4" ILI9341 SPI LCD module → ESP-WROOM-32:
| LCD Pin | ESP32 GPIO | Function |
|---------|------------|----------|
| VCC | 3V3 | Power |
| GND | GND | Ground |
| DIN | GPIO23 | SPI MOSI |
| CLK | GPIO18 | SPI SCLK |
| CS | GPIO26 | Chip select |
| DC | GPIO21 | Data/command |
| RST | GPIO22 | Reset |
| BL | 3V3 | Backlight (always on) |
Uses SPI2 (HSPI) at 26 MHz. Pin assignments are in `crates/client-esp32/src/main.rs`.
### ESP32 client
```bash

View File

@@ -1,6 +1,6 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId,
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
use std::collections::HashMap;
use std::sync::RwLock;
@@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore {
guard.remove(&id);
Ok(())
}
}
impl UserRepository for MemoryConfigStore {
type Error = MemoryConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
let guard = self
@@ -204,3 +208,18 @@ impl ConfigRepository for MemoryConfigStore {
Ok(guard.len() as u32)
}
}
impl WidgetStateCache for MemoryConfigStore {
type Error = MemoryConfigError;
async fn save_widget_states(
&self,
_states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
Ok(())
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
Ok(vec![])
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -96,6 +96,23 @@ impl SqliteConfigStore {
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS widget_state_cache (
widget_id INTEGER PRIMARY KEY,
state_json TEXT NOT NULL
)",
)
.execute(&self.pool)
.await?;
// Add alignment columns to widgets (idempotent)
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'")
.execute(&self.pool)
.await;
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN v_align TEXT NOT NULL DEFAULT 'top'")
.execute(&self.pool)
.await;
Ok(())
}
}

View File

@@ -3,13 +3,14 @@ mod layout;
mod presets;
mod theme;
mod users;
mod widget_state_cache;
mod widgets;
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId,
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
impl ConfigRepository for SqliteConfigStore {
@@ -78,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore {
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
self.save_theme_impl(theme).await
}
}
impl UserRepository for SqliteConfigStore {
type Error = SqliteConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
self.get_user_by_username_impl(username).await
@@ -91,3 +96,18 @@ impl ConfigRepository for SqliteConfigStore {
self.count_users_impl().await
}
}
impl WidgetStateCache for SqliteConfigStore {
type Error = SqliteConfigError;
async fn save_widget_states(
&self,
states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
self.save_widget_states_impl(states).await
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
self.load_widget_states_impl().await
}
}

View File

@@ -0,0 +1,60 @@
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{Value, WidgetId, WidgetState};
use sqlx::Row;
use std::collections::BTreeMap;
impl SqliteConfigStore {
pub(crate) async fn save_widget_states_impl(
&self,
states: &[(WidgetId, WidgetState)],
) -> Result<(), SqliteConfigError> {
for (id, state) in states {
let json = domain_state_to_json(state)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
sqlx::query(
"INSERT OR REPLACE INTO widget_state_cache (widget_id, state_json) VALUES (?, ?)",
)
.bind(*id as i64)
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
}
Ok(())
}
pub(crate) async fn load_widget_states_impl(
&self,
) -> Result<Vec<(WidgetId, WidgetState)>, SqliteConfigError> {
let rows = sqlx::query("SELECT widget_id, state_json FROM widget_state_cache")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
let mut result = Vec::new();
for row in &rows {
let id: i64 = row.get("widget_id");
let json_str: String = row.get("state_json");
let state = json_to_domain_state(&json_str)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
result.push((id as WidgetId, state));
}
Ok(result)
}
}
fn domain_state_to_json(state: &WidgetState) -> Result<String, serde_json::Error> {
let data: serde_json::Map<String, serde_json::Value> = state
.data
.iter()
.map(|(k, v)| (k.clone(), v.into()))
.collect();
serde_json::to_string(&data)
}
fn json_to_domain_state(json: &str) -> Result<WidgetState, serde_json::Error> {
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
Ok(WidgetState { data, error: None })
}

View File

@@ -34,15 +34,19 @@ impl SqliteConfigStore {
config: &WidgetConfig,
) -> Result<(), SqliteConfigError> {
let mappings_json = ser::mappings_to_json(&config.mappings)?;
let hint_str = ser::display_hint_to_str(&config.display_hint);
let hint_str = ser::display_hint_kind_to_str(&config.display_hint);
let h_align_str = ser::h_align_to_str(config.display_hint.h_align);
let v_align_str = ser::v_align_to_str(config.display_hint.v_align);
sqlx::query(
"INSERT OR REPLACE INTO widgets (id, name, display_hint, data_source_id, mappings, max_data_size)
VALUES (?, ?, ?, ?, ?, ?)"
"INSERT OR REPLACE INTO widgets (id, name, display_hint, h_align, v_align, data_source_id, mappings, max_data_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(config.id as i64)
.bind(&config.name)
.bind(hint_str)
.bind(h_align_str)
.bind(v_align_str)
.bind(config.data_source_id as i64)
.bind(&mappings_json)
.bind(config.max_data_size as i64)

View File

@@ -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<DataSourceType, SqliteConfigErro
"rss" => 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<String, SqliteConfigError> {
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(

View File

@@ -1,9 +1,9 @@
use crate::error::SqliteConfigError;
use domain::{DisplayHint, DisplayHintKind, KeyMapping, WidgetConfig};
use domain::{DisplayHint, DisplayHintKind, HAlign, KeyMapping, VAlign, WidgetConfig};
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
pub fn display_hint_kind_to_str(hint: &DisplayHint) -> &'static str {
match hint.kind {
DisplayHintKind::IconValue => "icon_value",
DisplayHintKind::TextBlock => "text_block",
@@ -11,17 +11,55 @@ pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
}
}
fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
pub fn h_align_to_str(a: HAlign) -> &'static str {
match a {
HAlign::Left => "left",
HAlign::Center => "center",
HAlign::Right => "right",
}
}
pub fn v_align_to_str(a: VAlign) -> &'static str {
match a {
VAlign::Top => "top",
VAlign::Middle => "middle",
VAlign::Bottom => "bottom",
}
}
fn hint_kind_from_str(s: &str) -> Result<DisplayHintKind, SqliteConfigError> {
match s {
"icon_value" => Ok(DisplayHint::new(DisplayHintKind::IconValue)),
"text_block" => Ok(DisplayHint::new(DisplayHintKind::TextBlock)),
"key_value" => Ok(DisplayHint::new(DisplayHintKind::KeyValue)),
"icon_value" => Ok(DisplayHintKind::IconValue),
"text_block" => Ok(DisplayHintKind::TextBlock),
"key_value" => Ok(DisplayHintKind::KeyValue),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown display hint: {s}"
))),
}
}
fn h_align_from_str(s: &str) -> Result<HAlign, SqliteConfigError> {
match s {
"left" => Ok(HAlign::Left),
"center" => Ok(HAlign::Center),
"right" => Ok(HAlign::Right),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown h_align: {s}"
))),
}
}
fn v_align_from_str(s: &str) -> Result<VAlign, SqliteConfigError> {
match s {
"top" => Ok(VAlign::Top),
"middle" => Ok(VAlign::Middle),
"bottom" => Ok(VAlign::Bottom),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown v_align: {s}"
))),
}
}
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
let entries: Vec<serde_json::Value> = mappings
.iter()
@@ -60,6 +98,8 @@ pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigErro
let id: i64 = row.get("id");
let name: String = row.get("name");
let hint_str: String = row.get("display_hint");
let h_align_str: String = row.get("h_align");
let v_align_str: String = row.get("v_align");
let ds_id: i64 = row.get("data_source_id");
let mappings_json: String = row.get("mappings");
let max_size: i64 = row.get("max_data_size");
@@ -67,7 +107,11 @@ pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigErro
Ok(WidgetConfig {
id: id as u16,
name,
display_hint: display_hint_from_str(&hint_str)?,
display_hint: DisplayHint {
kind: hint_kind_from_str(&hint_str)?,
h_align: h_align_from_str(&h_align_str)?,
v_align: v_align_from_str(&v_align_str)?,
},
data_source_id: ds_id as u16,
mappings: mappings_from_json(&mappings_json)?,
max_data_size: max_size as u16,

View File

@@ -2,7 +2,7 @@ use config_sqlite::SqliteConfigStore;
use domain::{
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache,
};
use std::time::Duration;
@@ -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]

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

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
application.workspace = true
api-types.workspace = true
axum.workspace = true

View File

@@ -1,10 +1,11 @@
pub mod extractors;
mod routes;
use application::ConfigService;
use axum::Router;
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
UserRepository, WidgetStateCache, WidgetStateReader,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
@@ -36,10 +37,23 @@ impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
}
}
impl<C, E, W, B, R, A, H> AppState<C, E, W, B, R, A, H>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{
pub fn config_service(&self) -> ConfigService<'_, C, E> {
ConfigService::new(self.config.as_ref(), self.events.as_ref())
}
}
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
@@ -69,8 +83,9 @@ pub async fn serve<C, E, W, B, R, A, H>(
state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
use domain::{AuthPort, PasswordHashPort, UserRepository};
use serde::{Deserialize, Serialize};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
@@ -28,7 +28,7 @@ pub async fn login<C, E, W, B, R, A, H>(
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
A: AuthPort,
H: PasswordHashPort,
@@ -51,7 +51,7 @@ pub async fn register<C, E, W, B, R, A, H>(
Json(body): Json<LoginRequest>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
H: PasswordHashPort,
{
@@ -71,7 +71,7 @@ pub async fn auth_status<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<StatusResponse>, StatusCode>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
{
let count = state

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::DataSourceDto;
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -65,7 +65,7 @@ where
let source = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.create_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let source = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.delete_data_source(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
@@ -39,7 +39,7 @@ where
let layout = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_layout(layout)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -12,13 +12,14 @@ use axum::Router;
use axum::routing::{get, post};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
UserRepository, WidgetStateCache, WidgetStateReader,
};
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -65,7 +65,7 @@ where
let preset = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.save_preset(preset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -83,7 +83,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.delete_preset(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -101,7 +101,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.load_preset(id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::ThemeDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
@@ -38,7 +38,7 @@ where
E::Error: std::fmt::Debug,
{
let theme = body.into_domain();
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_theme(theme)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use axum::extract::{Path, State};
use axum::http::StatusCode;
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>>;
@@ -16,9 +16,6 @@ where
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
W: WidgetStateReader,
B: BroadcastPort,
B::Error: std::fmt::Debug,
{
let source = state
.config
@@ -34,55 +31,13 @@ where
));
}
let raw = json_to_domain_value(body);
let widgets = state
.config
.list_widgets()
let data: domain::Value = body.into();
state
.events
.publish(DomainEvent::WebhookDataReceived { source_id, data })
.await
.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)
}
fn json_to_domain_value(json: serde_json::Value) -> domain::Value {
match json {
serde_json::Value::Null => domain::Value::Null,
serde_json::Value::Bool(b) => domain::Value::Bool(b),
serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => domain::Value::String(s),
serde_json::Value::Array(arr) => {
domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect())
}
serde_json::Value::Object(obj) => {
let map = obj
.into_iter()
.map(|(k, v)| (k, json_to_domain_value(v)))
.collect();
domain::Value::Object(map)
}
}
}

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -65,7 +65,7 @@ where
let widget = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.create_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let widget = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.delete_widget(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -126,32 +126,10 @@ where
{
match state.widget_states.get_widget_state(id).await {
Some(ws) => {
let map: serde_json::Map<String, serde_json::Value> = ws
.data
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
let map: serde_json::Map<String, serde_json::Value> =
ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect();
Ok(Json(serde_json::Value::Object(map)))
}
None => Err(StatusCode::NOT_FOUND),
}
}
fn domain_value_to_json(v: &domain::Value) -> serde_json::Value {
match v {
domain::Value::Null => serde_json::Value::Null,
domain::Value::Bool(b) => serde_json::Value::Bool(*b),
domain::Value::Number(n) => serde_json::json!(n),
domain::Value::String(s) => serde_json::Value::String(s.clone()),
domain::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
}
domain::Value::Object(obj) => {
let map = obj
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}

View File

@@ -86,7 +86,7 @@ async fn create_and_get_widget() {
let body = r#"{
"id": 1,
"name": "weather",
"display_hint": "icon_value",
"display_hint": {"kind": "icon_value", "h_align": "left", "v_align": "top"},
"data_source_id": 10,
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
}"#;
@@ -115,8 +115,8 @@ async fn create_and_get_widget() {
async fn list_widgets() {
let app = test_app();
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
let w1 = r#"{"id":1,"name":"a","display_hint":{"kind":"icon_value"},"data_source_id":1,"mappings":[]}"#;
let w2 = r#"{"id":2,"name":"b","display_hint":{"kind":"key_value"},"data_source_id":2,"mappings":[]}"#;
app.clone()
.oneshot(authed_json_request("POST", "/api/widgets", Some(w1)))
@@ -142,8 +142,7 @@ async fn list_widgets() {
async fn delete_widget() {
let app = test_app();
let body =
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
let body = r#"{"id":1,"name":"a","display_hint":{"kind":"icon_value"},"data_source_id":1,"mappings":[]}"#;
app.clone()
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
.await
@@ -172,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

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View File

@@ -28,40 +28,33 @@ impl HttpJsonAdapter {
}
}
fn json_to_value(json: serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, json_to_value(v)))
.collect(),
),
}
}
impl DataSourcePort for HttpJsonAdapter {
type Error = HttpJsonError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
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}"));
}
let resp = req.send().await.map_err(HttpJsonError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
Ok(json_to_value(json))
Ok(json.into())
}
}

View File

@@ -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,

View File

@@ -38,11 +38,19 @@ impl DataSourcePort for MediaAdapter {
type Error = MediaError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
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);

View File

@@ -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()),

View File

@@ -28,7 +28,10 @@ impl DataSourcePort for RssAdapter {
type Error = RssError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
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

View File

@@ -1,6 +1,7 @@
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireTheme, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster {
@@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster {
layout: &Layout,
widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_layout = layout_to_wire(&layout.root);
let wire_widgets: Vec<WidgetDescriptor> = widgets
.iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: hint.into(),
state: state.into(),
display_hint: display_hint_to_wire(hint),
state: widget_state_to_wire(state),
})
.collect();
@@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster {
.iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: hint.into(),
state: state.into(),
display_hint: display_hint_to_wire(hint),
state: widget_state_to_wire(state),
})
.collect();

View File

@@ -0,0 +1,103 @@
use domain::value_objects::{
AlignItems, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, LayoutNode,
Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind,
WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing,
WireVAlign, WireValue, WireWidgetError, WireWidgetState,
};
pub fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
pub fn widget_error_to_wire(e: &WidgetError) -> WireWidgetError {
match e {
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
}
}
pub fn widget_state_to_wire(s: &WidgetState) -> WireWidgetState {
WireWidgetState {
data: s
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: s.error.as_ref().map(widget_error_to_wire),
}
}
pub fn display_hint_to_wire(h: &DisplayHint) -> WireDisplayHint {
WireDisplayHint {
kind: match h.kind {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
},
h_align: match h.h_align {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
},
v_align: match h.v_align {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
},
}
}
pub fn layout_to_wire(n: &LayoutNode) -> WireLayoutNode {
match n {
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
direction: match c.direction {
Direction::Row => WireDirection::Row,
Direction::Column => WireDirection::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
},
children: c
.children
.iter()
.map(|ch| WireLayoutChild {
sizing: match ch.sizing {
Sizing::Fixed(px) => WireSizing::Fixed(px),
Sizing::Flex(w) => WireSizing::Flex(w),
},
node: layout_to_wire(&ch.node),
})
.collect(),
}),
}
}

View File

@@ -1,5 +1,6 @@
mod broadcaster;
mod client_tracker;
mod conversions;
mod error;
mod event_bus;
mod server;

View File

@@ -1,8 +1,9 @@
use crate::broadcaster::domain_theme_to_wire;
use crate::client_tracker::ClientTracker;
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{ConfigRepository, WidgetStateReader};
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
use protocol::{ServerMessage, WidgetDescriptor, encode};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
@@ -87,14 +88,14 @@ where
}
};
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_layout = layout_to_wire(&layout.root);
let mut wire_widgets = Vec::new();
for w in &widgets {
if let Some(s) = widget_states.get_widget_state(w.id).await {
wire_widgets.push(WidgetDescriptor {
id: w.id,
display_hint: (&w.display_hint).into(),
state: (&s).into(),
display_hint: display_hint_to_wire(&w.display_hint),
state: widget_state_to_wire(&s),
});
}
}

View File

@@ -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<String>,
#[serde(default)]
api_key: Option<String>,
#[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<String>,
pub api_key: Option<String>,
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<DataSourceType, String> {
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<DataSource, String> {
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,
})
}
}

View File

@@ -7,11 +7,28 @@ pub struct KeyMappingDto {
pub target_key: String,
}
#[derive(Serialize, Deserialize)]
pub struct DisplayHintDto {
pub kind: String,
#[serde(default = "default_h_align")]
pub h_align: String,
#[serde(default = "default_v_align")]
pub v_align: String,
}
fn default_h_align() -> String {
"left".into()
}
fn default_v_align() -> String {
"top".into()
}
#[derive(Serialize, Deserialize)]
pub struct WidgetDto {
pub id: u16,
pub name: String,
pub display_hint: String,
pub display_hint: DisplayHintDto,
pub data_source_id: u16,
pub mappings: Vec<KeyMappingDto>,
pub max_data_size: u16,
@@ -21,7 +38,7 @@ pub struct WidgetDto {
pub struct CreateWidgetDto {
pub id: u16,
pub name: String,
pub display_hint: String,
pub display_hint: DisplayHintDto,
pub data_source_id: u16,
pub mappings: Vec<KeyMappingDto>,
#[serde(default = "default_max_data_size")]
@@ -32,17 +49,40 @@ fn default_max_data_size() -> u16 {
2048
}
fn kind_to_str(kind: &DisplayHintKind) -> &'static str {
match kind {
DisplayHintKind::IconValue => "icon_value",
DisplayHintKind::TextBlock => "text_block",
DisplayHintKind::KeyValue => "key_value",
}
}
fn h_align_to_str(a: HAlign) -> &'static str {
match a {
HAlign::Left => "left",
HAlign::Center => "center",
HAlign::Right => "right",
}
}
fn v_align_to_str(a: VAlign) -> &'static str {
match a {
VAlign::Top => "top",
VAlign::Middle => "middle",
VAlign::Bottom => "bottom",
}
}
impl From<&WidgetConfig> for WidgetDto {
fn from(w: &WidgetConfig) -> Self {
Self {
id: w.id,
name: w.name.clone(),
display_hint: match w.display_hint.kind {
DisplayHintKind::IconValue => "icon_value",
DisplayHintKind::TextBlock => "text_block",
DisplayHintKind::KeyValue => "key_value",
}
.into(),
display_hint: DisplayHintDto {
kind: kind_to_str(&w.display_hint.kind).into(),
h_align: h_align_to_str(w.display_hint.h_align).into(),
v_align: v_align_to_str(w.display_hint.v_align).into(),
},
data_source_id: w.data_source_id,
mappings: w
.mappings
@@ -59,16 +99,32 @@ impl From<&WidgetConfig> for WidgetDto {
impl CreateWidgetDto {
pub fn into_domain(self) -> Result<WidgetConfig, String> {
let hint = match self.display_hint.as_str() {
"icon_value" => DisplayHint::new(DisplayHintKind::IconValue),
"text_block" => DisplayHint::new(DisplayHintKind::TextBlock),
"key_value" => DisplayHint::new(DisplayHintKind::KeyValue),
h => return Err(format!("unknown display_hint: {h}")),
let kind = match self.display_hint.kind.as_str() {
"icon_value" => DisplayHintKind::IconValue,
"text_block" => DisplayHintKind::TextBlock,
"key_value" => DisplayHintKind::KeyValue,
h => return Err(format!("unknown display_hint kind: {h}")),
};
let h_align = match self.display_hint.h_align.as_str() {
"left" => HAlign::Left,
"center" => HAlign::Center,
"right" => HAlign::Right,
h => return Err(format!("unknown h_align: {h}")),
};
let v_align = match self.display_hint.v_align.as_str() {
"top" => VAlign::Top,
"middle" => VAlign::Middle,
"bottom" => VAlign::Bottom,
v => return Err(format!("unknown v_align: {v}")),
};
Ok(WidgetConfig {
id: self.id,
name: self.name,
display_hint: hint,
display_hint: DisplayHint {
kind,
h_align,
v_align,
},
data_source_id: self.data_source_id,
mappings: self
.mappings

View File

@@ -7,6 +7,8 @@ edition = "2024"
domain.workspace = true
thiserror.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -1,4 +1,4 @@
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
use domain::{AuthPort, PasswordHashPort, User, UserRepository};
pub enum AuthError<E> {
InvalidCredentials,
@@ -26,7 +26,7 @@ pub async fn login<C, A, H>(
password: &str,
) -> Result<String, AuthError<C::Error>>
where
C: ConfigRepository,
C: UserRepository,
A: AuthPort,
H: PasswordHashPort,
{
@@ -55,7 +55,7 @@ pub async fn register<C, H>(
password: &str,
) -> Result<(), AuthError<C::Error>>
where
C: ConfigRepository,
C: UserRepository,
H: PasswordHashPort,
{
let count = config.count_users().await.map_err(AuthError::Repository)?;

View File

@@ -19,6 +19,13 @@ impl DataProjection {
Self::default()
}
pub async fn seed(&self, states: Vec<(WidgetId, WidgetState)>) {
let mut current = self.current.lock().await;
for (id, state) in states {
current.insert(id, state);
}
}
pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
self.current.lock().await.get(&widget_id).cloned()
}

View File

@@ -0,0 +1,123 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DomainEvent, Layout, Value, WidgetConfig, WidgetState,
};
use std::sync::Arc;
use tracing::{error, info, warn};
pub async fn handle_event<C, B>(
event: DomainEvent,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
match event {
DomainEvent::LayoutChanged { layout } => {
handle_layout_changed(&layout, config, broadcaster, projection).await;
}
DomainEvent::WebhookDataReceived { source_id, data } => {
handle_webhook_data(source_id, &data, config, broadcaster, projection).await;
}
DomainEvent::ThemeChanged { theme } => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
_ => {}
}
}
async fn handle_layout_changed<C, B>(
layout: &Layout,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
return;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster.push_screen_update(layout, &widget_states).await {
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
}
async fn handle_webhook_data<C, B>(
source_id: u16,
data: &Value,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
return;
}
};
let changed = apply_and_broadcast(source_id, data, &widgets, broadcaster, projection).await;
if !changed.is_empty() {
info!(source_id, count = changed.len(), "webhook data pushed");
}
}
pub async fn apply_and_broadcast<B>(
source_id: u16,
data: &Value,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) -> Vec<(u16, WidgetState)>
where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let changed: Vec<(u16, WidgetState)> =
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 {
warn!(error = %e, "failed to push update");
}
}
changed
}

View File

@@ -1,6 +1,8 @@
pub mod auth_service;
mod config_service;
mod data_projection;
pub mod event_service;
pub mod polling_service;
pub use config_service::ConfigService;
pub use data_projection::DataProjection;

View File

@@ -0,0 +1,254 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DataSource, Value, WidgetConfig, WidgetError, WidgetState,
WidgetStateCache,
};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
pub async fn run<C, B, P, F>(
config: Arc<C>,
broadcaster: Arc<B>,
projection: Arc<DataProjection>,
poller: Arc<P>,
) where
C: ConfigRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Display + Send,
<C as WidgetStateCache>::Error: std::fmt::Display + Send,
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Display + Send,
P: Fn(&DataSource) -> F + Send + Sync + 'static,
F: Future<Output = Result<Value, anyhow::Error>> + Send,
{
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
let mut static_done: HashSet<u16> = HashSet::new();
info!("polling manager started");
loop {
let sources = match config.list_data_sources().await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "failed to list data sources");
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
continue;
}
};
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
running.retain(|id, handle| {
if !current_ids.contains(id) {
info!(source_id = id, "stopping poll for removed source");
handle.abort();
false
} else {
true
}
});
static_done.retain(|id| current_ids.contains(id));
for source in &sources {
if source.source_type == domain::DataSourceType::Webhook {
continue;
}
if source.source_type == domain::DataSourceType::StaticText {
if static_done.contains(&source.id) {
continue;
}
poll_and_broadcast(&*poller, source, &config, &broadcaster, &projection).await;
static_done.insert(source.id);
continue;
}
if running.contains_key(&source.id) {
continue;
}
let source_id = source.id;
let source = source.clone();
let config = config.clone();
let broadcaster = broadcaster.clone();
let projection = projection.clone();
let poller = poller.clone();
info!(
source_id = source.id,
name = %source.name,
interval_secs = source.poll_interval.as_secs(),
"starting poll task"
);
let handle = tokio::spawn(async move {
poll_loop(source, config, broadcaster, projection, poller).await;
});
running.insert(source_id, handle);
}
if running.is_empty() && static_done.is_empty() {
debug!("no pollable sources, waiting");
}
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
}
}
async fn poll_and_broadcast<C, B, P, F>(
poller: &P,
source: &DataSource,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository + WidgetStateCache,
<C as ConfigRepository>::Error: std::fmt::Display,
<C as WidgetStateCache>::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
P: Fn(&DataSource) -> F,
F: Future<Output = Result<Value, anyhow::Error>>,
{
let result = match poller(source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
return;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch widgets");
return;
}
};
let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
broadcaster,
projection,
)
.await;
if !changed.is_empty() {
if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
}
async fn poll_loop<C, B, P, F>(
source: DataSource,
config: Arc<C>,
broadcaster: Arc<B>,
projection: Arc<DataProjection>,
poller: Arc<P>,
) where
C: ConfigRepository + WidgetStateCache,
<C as ConfigRepository>::Error: std::fmt::Display,
<C as WidgetStateCache>::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
P: Fn(&DataSource) -> F,
F: Future<Output = Result<Value, anyhow::Error>>,
{
let interval = source.poll_interval;
let mut widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch initial widget list");
vec![]
}
};
let mut last_refresh = tokio::time::Instant::now();
loop {
let result = match poller(&source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
broadcast_errors(&source, &widgets, &broadcaster, &projection).await;
tokio::time::sleep(interval).await;
continue;
}
};
if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL {
if let Ok(w) = config.list_widgets().await {
widgets = w;
}
last_refresh = tokio::time::Instant::now();
}
let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
&broadcaster,
&projection,
)
.await;
if !changed.is_empty() {
if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
tokio::time::sleep(interval).await;
}
}
async fn broadcast_errors<B>(
source: &DataSource,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let affected: Vec<_> = widgets
.iter()
.filter(|w| w.data_source_id == source.id)
.collect();
if affected.is_empty() {
return;
}
let mut error_states = Vec::new();
for w in &affected {
let mut state = projection
.get_state(w.id)
.await
.unwrap_or_else(|| WidgetState {
data: std::collections::BTreeMap::new(),
error: None,
});
state.error = Some(WidgetError::SourceUnavailable);
error_states.push((w.id, state));
}
projection.seed(error_states.clone()).await;
let with_hints: Vec<_> = error_states
.iter()
.filter_map(|(id, state)| {
let hint = affected.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 {
warn!(error = %e, "failed to push error update");
}
}

View File

@@ -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,

View File

@@ -1,6 +1,7 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId,
LayoutPresetId, ThemeConfig, User, UserRepository, WidgetConfig, WidgetId, WidgetState,
WidgetStateCache,
};
use std::collections::HashMap;
use std::sync::Mutex;
@@ -123,6 +124,10 @@ impl ConfigRepository for InMemoryConfigRepository {
self.presets.lock().unwrap().remove(&id);
Ok(())
}
}
impl UserRepository for InMemoryConfigRepository {
type Error = Never;
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
Ok(None)
@@ -137,6 +142,21 @@ impl ConfigRepository for InMemoryConfigRepository {
}
}
impl WidgetStateCache for InMemoryConfigRepository {
type Error = Never;
async fn save_widget_states(
&self,
_states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
Ok(())
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
Ok(vec![])
}
}
pub struct InMemoryEventPublisher {
events: Mutex<Vec<DomainEvent>>,
}

View File

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

View File

@@ -4,7 +4,6 @@ pub struct ServerConfig {
pub database_url: String,
pub tcp_addr: String,
pub http_addr: String,
pub poll_interval_secs: u64,
pub spa_dir: Option<String>,
}
@@ -15,10 +14,6 @@ impl ServerConfig {
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()),
tcp_addr: env::var("KFRAME_TCP_ADDR").unwrap_or_else(|_| "0.0.0.0:2699".into()),
http_addr: env::var("KFRAME_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
poll_interval_secs: env::var("KFRAME_POLL_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
}
}

View File

@@ -1,9 +1,8 @@
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
use std::sync::Arc;
use tcp_server::{TcpBroadcaster, TcpEventBus};
use tracing::{error, info, warn};
use tracing::{error, warn};
pub async fn run(
event_bus: Arc<TcpEventBus>,
@@ -15,38 +14,10 @@ pub async fn run(
loop {
match rx.recv().await {
Ok(DomainEvent::LayoutChanged { layout }) => {
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
continue;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster
.push_screen_update(&layout, &widget_states)
.await
{
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
Ok(event) => {
application::event_service::handle_event(event, &config, &broadcaster, &projection)
.await;
}
Ok(DomainEvent::ThemeChanged { theme }) => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "event handler lagged, missed events");
}

View File

@@ -5,12 +5,13 @@ mod polling;
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::WidgetStateCache;
use http_api::AppState;
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
use secret_store::AesSecretStore;
use std::sync::Arc;
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
use tracing::{error, info};
use tracing::{error, info, warn};
#[tokio::main]
async fn main() -> Result<()> {
@@ -40,6 +41,15 @@ async fn main() -> Result<()> {
let auth = Arc::new(JwtAuthService::new(auth_config));
let hasher = Arc::new(Argon2Hasher);
match config_store.load_widget_states().await {
Ok(states) if !states.is_empty() => {
info!(count = states.len(), "loaded cached widget states");
projection.seed(states).await;
}
Ok(_) => {}
Err(e) => warn!(error = %e, "failed to load cached widget states"),
}
let tcp_addr = cfg.tcp_addr.clone();
let tcp_bc = broadcaster.clone();
let tcp_tracker = tracker.clone();
@@ -80,11 +90,5 @@ async fn main() -> Result<()> {
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
});
polling::run(
config_store,
broadcaster,
projection,
cfg.poll_interval_secs,
)
.await
polling::run(config_store, broadcaster, projection).await
}

View File

@@ -1,173 +1,77 @@
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::{
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState,
};
use data_generators::{ClockGenerator, StaticTextGenerator};
use domain::{DataSource, DataSourcePort, DataSourceType, Value};
use http_json::HttpJsonAdapter;
use media_adapter::MediaAdapter;
use rss_adapter::RssAdapter;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tcp_server::TcpBroadcaster;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
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(
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
_poll_interval_secs: u64,
) -> Result<()> {
let http_adapter = Arc::new(HttpJsonAdapter::new());
let media_adapter = Arc::new(MediaAdapter::new());
let rss_adapter = Arc::new(RssAdapter::new());
let adapters = Adapters {
http: Arc::new(HttpJsonAdapter::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 poller = Arc::new(move |source: &DataSource| {
let adapters = adapters.clone();
let source = source.clone();
async move { adapters.poll(&source).await }
});
info!("polling manager started");
loop {
let sources = config
.list_data_sources()
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
running.retain(|id, handle| {
if !current_ids.contains(id) {
info!(source_id = id, "stopping poll for removed source");
handle.abort();
false
} else {
true
}
});
for source in &sources {
if source.source_type == DataSourceType::Webhook {
continue;
}
if running.contains_key(&source.id) {
continue;
}
let source_id = source.id;
let source = source.clone();
let config = config.clone();
let broadcaster = broadcaster.clone();
let projection = projection.clone();
let http = http_adapter.clone();
let media = media_adapter.clone();
let rss = rss_adapter.clone();
info!(
source_id = source.id,
name = %source.name,
interval_secs = source.poll_interval.as_secs(),
"starting poll task"
);
let handle = tokio::spawn(async move {
poll_loop(source, config, broadcaster, projection, http, media, rss).await;
});
running.insert(source_id, handle);
}
if running.is_empty() {
debug!("no pollable sources, waiting");
}
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
}
}
async fn poll_loop(
source: DataSource,
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
http_adapter: Arc<HttpJsonAdapter>,
media_adapter: Arc<MediaAdapter>,
rss_adapter: Arc<RssAdapter>,
) {
let interval = source.poll_interval;
loop {
tokio::time::sleep(interval).await;
let result = match poll_source(&http_adapter, &media_adapter, &rss_adapter, &source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
continue;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch widgets");
continue;
}
};
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;
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 Some(l) = &layout
&& let Err(e) = broadcaster.push_screen_update(l, &with_hints).await
{
warn!(error = %e, "failed to push update");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
}
}
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::Webhook => Err(anyhow::anyhow!(
"webhook sources are push-based, not polled"
)),
}
application::polling_service::run(config, broadcaster, projection, poller).await;
Ok(())
}

View File

@@ -1,24 +1,24 @@
use crate::conversions::{wire_to_display_hint, wire_to_layout, wire_to_widget_state};
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use domain::LayoutNode;
use protocol::{
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
};
use domain::{DisplayHint, Value, WidgetError, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode};
use std::collections::HashMap;
pub struct ClientApp {
screen: BoundingBox,
render_tree: Option<RenderTree>,
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
widget_states: HashMap<u16, (DisplayHint, WidgetState)>,
theme: ThemeConfig,
theme_changed: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone)]
pub struct RepaintCommand {
pub widget_id: u16,
pub bounds: BoundingBox,
pub display_hint: WireDisplayHint,
pub state: WireWidgetState,
pub display_hint: DisplayHint,
pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
}
impl ClientApp {
@@ -73,13 +73,14 @@ impl ClientApp {
wire_layout: WireLayoutNode,
widgets: Vec<WidgetDescriptor>,
) -> Vec<RepaintCommand> {
let layout: LayoutNode = wire_layout.into();
let layout = wire_to_layout(wire_layout);
let new_tree = LayoutEngine::compute(&layout, self.screen);
self.widget_states.clear();
for w in &widgets {
self.widget_states
.insert(w.id, (w.display_hint.clone(), w.state.clone()));
for w in widgets {
let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
self.widget_states.insert(w.id, (hint, state));
}
let repaints = self.build_repaints_for_all(&new_tree);
@@ -96,21 +97,19 @@ impl ClientApp {
let mut repaints = Vec::new();
for w in widgets {
let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
let changed = self
.widget_states
.get(&w.id)
.is_none_or(|(_, prev_state)| *prev_state != w.state);
.is_none_or(|(_, prev)| *prev != state);
if changed {
if let Some(bounds) = tree.get_widget_bounds(w.id) {
repaints.push(RepaintCommand {
widget_id: w.id,
bounds: *bounds,
display_hint: w.display_hint.clone(),
state: w.state.clone(),
});
repaints.push(Self::make_repaint(w.id, *bounds, &hint, &state));
}
self.widget_states.insert(w.id, (w.display_hint, w.state));
self.widget_states.insert(w.id, (hint, state));
}
}
@@ -122,18 +121,32 @@ impl ClientApp {
for (id, (hint, state)) in &self.widget_states {
if let Some(bounds) = tree.get_widget_bounds(*id) {
repaints.push(RepaintCommand {
widget_id: *id,
bounds: *bounds,
display_hint: hint.clone(),
state: state.clone(),
});
repaints.push(Self::make_repaint(*id, *bounds, hint, state));
}
}
repaints.sort_by_key(|r| r.widget_id);
repaints
}
fn make_repaint(
id: u16,
bounds: BoundingBox,
hint: &DisplayHint,
state: &WidgetState,
) -> RepaintCommand {
RepaintCommand {
widget_id: id,
bounds,
display_hint: hint.clone(),
data: state
.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
error: state.error.clone(),
}
}
}
fn wire_color(c: WireColor) -> Color {

View File

@@ -0,0 +1,42 @@
use client_domain::NetworkPort;
use protocol::{ServerMessage, decode_server_message};
use std::thread;
use std::time::Duration;
pub fn run_connection_loop<N: NetworkPort>(
net: &mut N,
server_addr: &str,
poll_interval: Duration,
reconnect_delay: Duration,
mut on_message: impl FnMut(ServerMessage),
mut on_connection_change: impl FnMut(bool),
) {
loop {
if !net.is_connected() {
match net.connect(server_addr) {
Ok(()) => on_connection_change(true),
Err(_) => {
on_connection_change(false);
thread::sleep(reconnect_delay);
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => {
if let Ok(msg) = decode_server_message(&payload) {
on_message(msg);
}
}
Ok(None) => {
thread::sleep(poll_interval);
}
Err(_) => {
let _ = net.disconnect();
on_connection_change(false);
thread::sleep(reconnect_delay);
}
}
}
}

View File

@@ -0,0 +1,100 @@
use domain::value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireDirection, WireDisplayHint, WireDisplayHintKind, WireHAlign,
WireJustifyContent, WireLayoutNode, WireSizing, WireVAlign, WireValue, WireWidgetError,
WireWidgetState,
};
pub fn wire_to_value(w: WireValue) -> Value {
match w {
WireValue::Null => Value::Null,
WireValue::Bool(b) => Value::Bool(b),
WireValue::Number(n) => Value::Number(n),
WireValue::String(s) => Value::String(s),
WireValue::Array(arr) => Value::Array(arr.into_iter().map(wire_to_value).collect()),
WireValue::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, wire_to_value(v)))
.collect(),
),
}
}
pub fn wire_to_widget_error(w: WireWidgetError) -> WidgetError {
match w {
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
}
}
pub fn wire_to_widget_state(w: WireWidgetState) -> WidgetState {
WidgetState {
data: w
.data
.into_iter()
.map(|kv| (kv.key, wire_to_value(kv.value)))
.collect(),
error: w.error.map(wire_to_widget_error),
}
}
pub fn wire_to_display_hint(w: WireDisplayHint) -> DisplayHint {
DisplayHint {
kind: match w.kind {
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
},
h_align: match w.h_align {
WireHAlign::Left => HAlign::Left,
WireHAlign::Center => HAlign::Center,
WireHAlign::Right => HAlign::Right,
},
v_align: match w.v_align {
WireVAlign::Top => VAlign::Top,
WireVAlign::Middle => VAlign::Middle,
WireVAlign::Bottom => VAlign::Bottom,
},
}
}
pub fn wire_to_layout(w: WireLayoutNode) -> LayoutNode {
match w {
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
direction: match c.direction {
WireDirection::Row => Direction::Row,
WireDirection::Column => Direction::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
WireJustifyContent::Start => JustifyContent::Start,
WireJustifyContent::Center => JustifyContent::Center,
WireJustifyContent::End => JustifyContent::End,
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
WireAlignItems::Start => AlignItems::Start,
WireAlignItems::Center => AlignItems::Center,
WireAlignItems::End => AlignItems::End,
WireAlignItems::Stretch => AlignItems::Stretch,
},
children: c
.children
.into_iter()
.map(|ch| LayoutChild {
sizing: match ch.sizing {
WireSizing::Fixed(px) => Sizing::Fixed(px),
WireSizing::Flex(weight) => Sizing::Flex(weight),
},
node: wire_to_layout(ch.node),
})
.collect(),
}),
}
}

View File

@@ -1,3 +1,6 @@
mod client_app;
mod connection_loop;
pub mod conversions;
pub use client_app::{ClientApp, RepaintCommand};
pub use connection_loop::run_connection_loop;

View File

@@ -1,4 +1,4 @@
use client_application::{ClientApp, RepaintCommand};
use client_application::ClientApp;
use client_domain::BoundingBox;
use protocol::{
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
@@ -84,8 +84,8 @@ fn data_update_only_repaints_changed_widgets() {
assert_eq!(repaints.len(), 1);
assert_eq!(repaints[0].widget_id, 1);
assert_eq!(
repaints[0].state.data[0].value,
WireValue::String("6.1°C".into())
repaints[0].data[0],
("temperature".into(), domain::Value::String("6.1°C".into()))
);
}

View File

@@ -0,0 +1,151 @@
use client_application::conversions::{
wire_to_display_hint, wire_to_layout, wire_to_value, wire_to_widget_state,
};
use domain::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
};
use protocol::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
};
use std::collections::BTreeMap;
fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
#[test]
fn value_converts_to_wire_and_back() {
let original = Value::Object(BTreeMap::from([(
"items".into(),
Value::Array(vec![
Value::String("hello".into()),
Value::Number(42.0),
Value::Bool(true),
Value::Null,
]),
)]));
let wire = value_to_wire(&original);
let roundtripped = wire_to_value(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn widget_state_with_error_converts_to_wire_and_back() {
let original = WidgetState {
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
error: Some(WidgetError::SourceUnavailable),
};
let wire = WireWidgetState {
data: original
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: Some(WireWidgetError::SourceUnavailable),
};
let roundtripped = wire_to_widget_state(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn layout_tree_converts_to_wire_and_back() {
let original = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(100),
node: LayoutNode::Container(ContainerNode {
direction: Direction::Column,
gap: 2,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
}],
}),
},
],
});
let wire = WireLayoutNode::Container(WireContainerNode {
direction: WireDirection::Row,
gap: 4,
padding: 2,
justify_content: protocol::WireJustifyContent::Start,
align_items: protocol::WireAlignItems::Stretch,
children: vec![
WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(1),
},
WireLayoutChild {
sizing: WireSizing::Fixed(100),
node: WireLayoutNode::Container(WireContainerNode {
direction: WireDirection::Column,
gap: 2,
padding: 0,
justify_content: protocol::WireJustifyContent::Start,
align_items: protocol::WireAlignItems::Stretch,
children: vec![WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(2),
}],
}),
},
],
});
let roundtripped = wire_to_layout(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn display_hint_converts_to_wire_and_back() {
for (hint, wire_kind) in [
(
DisplayHintKind::IconValue,
protocol::WireDisplayHintKind::IconValue,
),
(
DisplayHintKind::TextBlock,
protocol::WireDisplayHintKind::TextBlock,
),
(
DisplayHintKind::KeyValue,
protocol::WireDisplayHintKind::KeyValue,
),
] {
let original = DisplayHint::new(hint);
let wire = WireDisplayHint::new(wire_kind);
let roundtripped = wire_to_display_hint(wire);
assert_eq!(original, roundtripped);
}
}

View File

@@ -1,14 +1,25 @@
use client_application::ClientApp;
use client_domain::NetworkPort;
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
use client_application::{ClientApp, RepaintCommand, run_connection_loop};
use client_domain::{
BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer,
};
use display_terminal::TerminalDisplay;
use domain::DisplayHint;
use protocol::decode_server_message;
use protocol::ServerMessage;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use tcp_client::StdTcpClient;
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
RepaintRequest {
widget_id: cmd.widget_id,
bounds: cmd.bounds,
display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
}
fn main() {
let screen = BoundingBox::screen(240, 320);
let mut app = ClientApp::new(screen);
@@ -18,52 +29,33 @@ fn main() {
large: (10, 20),
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut renderer = WidgetRenderer::new();
println!("=== K-Frame Desktop Client ===");
println!("Screen: {}x{}", screen.width, screen.height);
let (tx, rx) = mpsc::channel();
let (tx, rx) = mpsc::channel::<ServerMessage>();
thread::spawn(move || {
let server_addr = "127.0.0.1:2699";
let mut net = StdTcpClient::new();
loop {
if !net.is_connected() {
println!("[NET] Connecting to {server_addr}...");
match net.connect(server_addr) {
Ok(()) => println!("[NET] Connected!"),
Err(e) => {
println!("[NET] Connection failed: {e}, retrying in 2s...");
thread::sleep(Duration::from_secs(2));
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => match decode_server_message(&payload) {
Ok(msg) => {
let _ = tx.send(msg);
}
Err(e) => println!("[NET] Decode error: {e}"),
},
Ok(None) => {
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
println!("[NET] Receive error: {e}, reconnecting...");
let _ = net.disconnect();
thread::sleep(Duration::from_secs(2));
}
}
}
let tx_clone = tx.clone();
run_connection_loop(
&mut net,
"127.0.0.1:2699",
Duration::from_millis(50),
Duration::from_secs(2),
move |msg| {
let _ = tx_clone.send(msg);
},
|_connected| {},
);
});
println!("[RENDER] Render loop started");
let mut last_tick = Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(msg) => {
let repaints = app.handle_message(msg);
@@ -73,20 +65,13 @@ fn main() {
if !repaints.is_empty() {
println!("\n--- Repaint ({} widgets) ---", repaints.len());
let requests: Vec<_> = repaints.iter().map(to_request).collect();
let bg = engine.theme().background;
for cmd in &repaints {
display.fill_rect(cmd.bounds, bg).unwrap();
let hint: DisplayHint = cmd.display_hint.clone().into();
let data: Vec<(String, domain::Value)> = cmd
.state
.data
.iter()
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
.collect();
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
for dc in &draw_cmds {
let updates = renderer.apply_repaints(&engine, requests);
for update in &updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
@@ -98,5 +83,23 @@ fn main() {
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
let now = Instant::now();
let elapsed = now.duration_since(last_tick);
last_tick = now;
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
if !scroll_updates.is_empty() {
let bg = engine.theme().background;
for update in &scroll_updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
}
}
display.flush().unwrap();
}
}
}

View File

@@ -10,6 +10,7 @@ mod render_tree;
mod scroll;
mod text_layout;
mod theme;
mod widget_renderer;
pub use alignment::align_offset;
pub use bounding_box::BoundingBox;
@@ -18,9 +19,10 @@ pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine;
pub use markup::{TextSpan, parse_markup};
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
pub use ports::{DisplayPort, NetworkPort};
pub use render_engine::{DrawCommand, RenderEngine};
pub use render_tree::RenderTree;
pub use scroll::ScrollState;
pub use text_layout::wrap_lines;
pub use theme::ThemeConfig;
pub use widget_renderer::{RenderUpdate, RepaintRequest, WidgetRenderer};

View File

@@ -1,7 +1,5 @@
mod display;
mod network;
mod storage;
pub use display::DisplayPort;
pub use network::NetworkPort;
pub use storage::{ClientConfig, StoragePort};

View File

@@ -1,11 +0,0 @@
pub struct ClientConfig {
pub wifi_ssid: String,
pub wifi_password: String,
pub server_addr: String,
}
pub trait StoragePort {
type Error;
fn load_config(&self) -> Result<ClientConfig, Self::Error>;
}

View File

@@ -2,7 +2,7 @@ use crate::{
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
markup::parse_markup, text_layout::wrap_lines,
};
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value, WidgetError};
#[derive(Debug, Clone, PartialEq)]
pub struct DrawCommand {
@@ -92,26 +92,38 @@ impl RenderEngine {
data: &[(String, Value)],
bounds: BoundingBox,
scroll_offset: u16,
error: Option<&WidgetError>,
) -> Vec<DrawCommand> {
let text = self.format_widget(hint, data);
let mut text = self.format_widget(hint, data);
if error.is_some() {
text = format!("{{accent}}[offline]{{/}}\n{text}");
}
let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
if scroll_offset > 0 {
for cmd in &mut cmds {
cmd.y = cmd.y.saturating_sub(scroll_offset);
}
// Drop commands that scrolled above bounds
cmds.retain(|cmd| {
cmd.y + self.metrics.char_height(cmd.font) > bounds.y
&& cmd.y < bounds.y + bounds.height
});
}
cmds.retain(|cmd| {
let line_h = self.metrics.char_height(cmd.font);
cmd.y >= bounds.y && cmd.y + line_h <= bounds.y + bounds.height
});
cmds
}
pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 {
let text = self.format_widget(hint, data);
pub fn content_height(
&self,
hint: &DisplayHint,
data: &[(String, Value)],
width: u16,
error: Option<&WidgetError>,
) -> u16 {
let mut text = self.format_widget(hint, data);
if error.is_some() {
text = format!("{{accent}}[offline]{{/}}\n{text}");
}
let plain: String = parse_markup(&text, &self.theme)
.iter()
.map(|s| s.text.as_str())

View File

@@ -1,7 +1,7 @@
use std::time::Duration;
const PAUSE_DURATION: Duration = Duration::from_secs(2);
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0;
const PAUSE_DURATION: Duration = Duration::from_secs(3);
const SCROLL_SPEED_PX_PER_SEC: f32 = 15.0;
#[derive(Debug)]
pub struct ScrollState {

View File

@@ -0,0 +1,113 @@
use crate::{BoundingBox, DrawCommand, RenderEngine, ScrollState};
use domain::{DisplayHint, Value, WidgetError};
use std::collections::HashMap;
use std::time::Duration;
pub struct RenderUpdate {
pub bounds: BoundingBox,
pub commands: Vec<DrawCommand>,
}
struct WidgetCache {
hint: DisplayHint,
data: Vec<(String, Value)>,
error: Option<WidgetError>,
bounds: BoundingBox,
scroll: ScrollState,
}
pub struct RepaintRequest {
pub widget_id: u16,
pub bounds: BoundingBox,
pub display_hint: DisplayHint,
pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
}
pub struct WidgetRenderer {
widgets: HashMap<u16, WidgetCache>,
}
impl Default for WidgetRenderer {
fn default() -> Self {
Self::new()
}
}
impl WidgetRenderer {
pub fn new() -> Self {
Self {
widgets: HashMap::new(),
}
}
pub fn apply_repaints(
&mut self,
engine: &RenderEngine,
repaints: Vec<RepaintRequest>,
) -> Vec<RenderUpdate> {
let mut updates = Vec::new();
for req in repaints {
let content_h = engine.content_height(
&req.display_hint,
&req.data,
req.bounds.width,
req.error.as_ref(),
);
let scroll = ScrollState::new(req.bounds.height, content_h);
let cmds = engine.render_widget(
&req.display_hint,
&req.data,
req.bounds,
scroll.offset(),
req.error.as_ref(),
);
updates.push(RenderUpdate {
bounds: req.bounds,
commands: cmds,
});
self.widgets.insert(
req.widget_id,
WidgetCache {
hint: req.display_hint,
data: req.data,
error: req.error,
bounds: req.bounds,
scroll,
},
);
}
updates
}
pub fn tick_scroll(&mut self, engine: &RenderEngine, elapsed: Duration) -> Vec<RenderUpdate> {
let mut updates = Vec::new();
for cache in self.widgets.values_mut() {
if cache.scroll.tick(elapsed) {
let cmds = engine.render_widget(
&cache.hint,
&cache.data,
cache.bounds,
cache.scroll.offset(),
cache.error.as_ref(),
);
updates.push(RenderUpdate {
bounds: cache.bounds,
commands: cmds,
});
}
}
updates
}
pub fn has_active_scrollers(&self) -> bool {
self.widgets.values().any(|c| c.scroll.is_active())
}
pub fn clear(&mut self) {
self.widgets.clear();
}
}

View File

@@ -238,17 +238,13 @@ dependencies = [
"client-domain",
"domain",
"embedded-graphics",
"embedded-hal-bus",
"embedded-text",
"embuild",
"esp-idf-hal",
"esp-idf-svc",
"esp-idf-sys",
"log",
"mipidsi",
"postcard",
"protocol",
"serde",
]
[[package]]
@@ -489,16 +485,6 @@ dependencies = [
"embedded-hal 1.0.0",
]
[[package]]
name = "embedded-hal-bus"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22"
dependencies = [
"critical-section",
"embedded-hal 1.0.0",
]
[[package]]
name = "embedded-hal-nb"
version = "1.0.0"
@@ -561,17 +547,6 @@ dependencies = [
"strum 0.27.2",
]
[[package]]
name = "embedded-text"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cf5c72c52db2f7dbe4a9c1ed81cd21301e8d66311b194fa41c04fb4f71843ba"
dependencies = [
"az",
"embedded-graphics",
"object-chain",
]
[[package]]
name = "embuild"
version = "0.33.1"
@@ -1126,12 +1101,6 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "object-chain"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41af26158b0f5530f7b79955006c2727cd23d0d8e7c3109dc316db0a919784dd"
[[package]]
name = "once_cell"
version = "1.21.4"

View File

@@ -15,16 +15,18 @@ esp-idf-sys = "0.37"
mipidsi = "0.10"
embedded-graphics = "0.8"
embedded-text = "0.7"
embedded-hal-bus = "0.3"
serde = { version = "1.0", default-features = false, features = [
"derive",
"alloc",
] }
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
log = "0.4"
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
codegen-units = 1
[profile.dev]
panic = "abort"
[build-dependencies]
embuild = "0.33"

View File

@@ -19,3 +19,13 @@ CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
# Disable Bluetooth (unused)
CONFIG_BT_ENABLED=n
# Disable ESP-MESH (unused)
CONFIG_ESP_WIFI_MESH_SUPPORT=n
# Reduce log verbosity
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_MAXIMUM_LEVEL_INFO=y

View File

@@ -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))
}
}

View File

@@ -41,12 +41,6 @@ fn fill_triangles<D: DrawTarget<Color = Rgb565>>(
}
}
// Outer hexagon vertices (150x150 viewBox)
const OUTER_HEX: [(f32, f32); 6] = [
(75.0, 0.0), (150.0, 37.5), (150.0, 112.5),
(75.0, 150.0), (0.0, 112.5), (0.0, 37.5),
];
// Converted from SVG: (150,75)(112.5,150)(37.5,150)(0,75)(37.5,0)(112.5,0)
// Reordered to start from top for cleaner fan
const OUTER_HEX_SVG: [(f32, f32); 6] = [

View File

@@ -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::RenderEvent>();
tasks::network::spawn(cfg.server_addr, tx);
tasks::render::run(config::SCREEN, display, rx);
}

View File

@@ -1,2 +1,9 @@
pub mod network;
pub mod render;
use protocol::ServerMessage;
pub enum RenderEvent {
Server(ServerMessage),
ConnectionStatus(bool),
}

View File

@@ -1,50 +1,26 @@
use std::sync::mpsc;
use std::thread;
use client_domain::NetworkPort;
use protocol::{ServerMessage, decode_server_message};
use client_application::run_connection_loop;
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<ServerMessage>) {
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
thread::Builder::new()
.stack_size(NET_THREAD_STACK_SIZE)
.name("net".into())
.spawn(move || run(server_addr, tx))
.spawn(move || {
let mut net = Esp32Network::new();
let tx_msg = tx.clone();
let tx_status = tx.clone();
run_connection_loop(
&mut net,
&server_addr,
NET_POLL_INTERVAL,
NET_RECONNECT_DELAY,
move |msg| { let _ = tx_msg.send(RenderEvent::Server(msg)); },
move |connected| { let _ = tx_status.send(RenderEvent::ConnectionStatus(connected)); },
);
})
.expect("failed to spawn network thread");
}
fn run(server_addr: String, tx: mpsc::Sender<ServerMessage>) {
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"),
Err(e) => {
error!("Connection failed: {e}, retrying...");
thread::sleep(NET_RECONNECT_DELAY);
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => {
match decode_server_message(&payload) {
Ok(msg) => { let _ = tx.send(msg); }
Err(e) => error!("Decode error: {e}"),
}
}
Ok(None) => {
thread::sleep(NET_POLL_INTERVAL);
}
Err(e) => {
error!("Receive error: {e}, reconnecting...");
let _ = net.disconnect();
thread::sleep(NET_RECONNECT_DELAY);
}
}
}
}

View File

@@ -1,29 +1,38 @@
use std::sync::mpsc;
use std::time::{Duration, Instant};
use std::collections::HashMap;
use client_application::RepaintCommand;
use client_domain::{
BoundingBox, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer,
};
use client_application::{ClientApp, RepaintCommand};
use domain::{DisplayHint, Value};
use client_application::ClientApp;
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,
data: Vec<(String, Value)>,
bounds: BoundingBox,
scroll: ScrollState,
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
RepaintRequest {
widget_id: cmd.widget_id,
bounds: cmd.bounds,
display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
}
pub fn run(
screen: BoundingBox,
mut display: Esp32DisplayAdapter,
rx: mpsc::Receiver<ServerMessage>,
rx: mpsc::Receiver<RenderEvent>,
) {
let metrics = FontMetrics {
small: (6, 10),
@@ -31,16 +40,31 @@ pub fn run(
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut app = ClientApp::new(screen);
let mut widgets: HashMap<u16, WidgetCache> = HashMap::new();
let mut renderer = WidgetRenderer::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);
let has_scrollers = renderer.has_active_scrollers();
let timeout = if has_scrollers {
SCROLL_TICK
} else {
RENDER_POLL_INTERVAL
};
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 +73,25 @@ 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);
draw_widget(&engine, &mut display, &cache);
widgets.insert(cmd.widget_id, cache);
let requests: Vec<_> = repaints.iter().map(to_request).collect();
let updates = renderer.apply_repaints(&engine, requests);
for update in &updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
}
}
if !repaints.is_empty() {
if !updates.is_empty() {
draw_indicator(&mut display, screen, connected);
display.flush().unwrap();
}
}
@@ -76,52 +106,32 @@ pub fn run(
let elapsed = now.duration_since(last_tick);
last_tick = now;
let mut needs_flush = false;
for cache in widgets.values_mut() {
if cache.scroll.tick(elapsed) {
let bg = engine.theme().background;
display.fill_rect(cache.bounds, bg).unwrap();
draw_widget(&engine, &mut display, cache);
needs_flush = true;
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
if !scroll_updates.is_empty() {
let bg = engine.theme().background;
for update in &scroll_updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
}
}
}
if needs_flush {
draw_indicator(&mut display, screen, connected);
display.flush().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
.iter()
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
.collect();
let content_h = engine.content_height(&hint, &data, cmd.bounds.width);
let scroll = ScrollState::new(cmd.bounds.height, content_h);
WidgetCache {
hint,
data,
bounds: cmd.bounds,
scroll,
}
}
fn draw_widget(
engine: &RenderEngine,
display: &mut Esp32DisplayAdapter,
cache: &WidgetCache,
) {
let draw_cmds = engine.render_widget(
&cache.hint,
&cache.data,
cache.bounds,
cache.scroll.offset(),
);
for dc in &draw_cmds {
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).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();
}

View File

@@ -3,6 +3,10 @@ name = "domain"
version = "0.1.0"
edition = "2024"
[features]
json = ["serde_json"]
[dependencies]
serde_json = { workspace = true, optional = true }
[dev-dependencies]

View File

@@ -9,13 +9,24 @@ pub enum DataSourceType {
Rss,
HttpJson,
Webhook,
Clock,
StaticText,
}
#[derive(Debug, Clone)]
pub struct DataSourceConfig {
pub url: Option<String>,
pub headers: Vec<(String, String)>,
pub api_key: Option<String>,
pub enum DataSourceConfig {
External {
url: Option<String>,
headers: Vec<(String, String)>,
api_key: Option<String>,
},
Clock {
format: String,
timezone: String,
},
StaticText {
text: String,
},
}
#[derive(Debug, Clone)]
@@ -38,18 +49,28 @@ impl DataSource {
pub fn validate(&self) -> Vec<DataSourceValidationError> {
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);
}
}
}
}

View File

@@ -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();

View File

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

View File

@@ -12,7 +12,8 @@ pub use entities::{
pub use events::DomainEvent;
pub use ports::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
EventPublisher, PasswordHashPort, SecretStore, UserRepository, WidgetStateCache,
WidgetStateReader,
};
pub use value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,

View File

@@ -1,5 +1,5 @@
use crate::entities::{
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
};
use crate::value_objects::{Layout, ThemeConfig};
use std::future::Future;
@@ -56,11 +56,4 @@ pub trait ConfigRepository {
&self,
theme: &ThemeConfig,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn get_user_by_username(
&self,
username: &str,
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
}

View File

@@ -5,6 +5,8 @@ mod config_repository;
mod data_source_port;
mod event;
mod secret_store;
mod user_repository;
mod widget_state_cache;
mod widget_state_reader;
pub use auth::{AuthPort, PasswordHashPort};
@@ -14,4 +16,6 @@ pub use config_repository::ConfigRepository;
pub use data_source_port::DataSourcePort;
pub use event::EventPublisher;
pub use secret_store::SecretStore;
pub use user_repository::UserRepository;
pub use widget_state_cache::WidgetStateCache;
pub use widget_state_reader::WidgetStateReader;

View File

@@ -0,0 +1,13 @@
use crate::entities::User;
use std::future::Future;
pub trait UserRepository {
type Error;
fn get_user_by_username(
&self,
username: &str,
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
}

View File

@@ -0,0 +1,15 @@
use crate::entities::WidgetId;
use crate::value_objects::WidgetState;
use std::future::Future;
pub trait WidgetStateCache {
type Error;
fn save_widget_states(
&self,
states: &[(WidgetId, WidgetState)],
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn load_widget_states(
&self,
) -> impl Future<Output = Result<Vec<(WidgetId, WidgetState)>, Self::Error>> + Send;
}

View File

@@ -1,5 +1,39 @@
use std::collections::BTreeMap;
#[cfg(feature = "json")]
impl From<serde_json::Value> for Value {
fn from(json: serde_json::Value) -> Self {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => {
Value::Array(arr.into_iter().map(Into::into).collect())
}
serde_json::Value::Object(map) => {
Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect())
}
}
}
}
#[cfg(feature = "json")]
impl From<&Value> for serde_json::Value {
fn from(v: &Value) -> Self {
match v {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Number(n) => serde_json::json!(*n),
Value::String(s) => serde_json::Value::String(s.clone()),
Value::Array(arr) => serde_json::Value::Array(arr.iter().map(Into::into).collect()),
Value::Object(map) => {
serde_json::Value::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect())
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Null,

View File

@@ -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,

View File

@@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
serde.workspace = true
postcard.workspace = true

View File

@@ -1,7 +1,3 @@
use domain::value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -15,60 +11,12 @@ pub enum WireValue {
Object(BTreeMap<String, WireValue>),
}
impl From<&Value> for WireValue {
fn from(v: &Value) -> Self {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(Into::into).collect()),
Value::Object(map) => {
WireValue::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect())
}
}
}
}
impl From<WireValue> for Value {
fn from(w: WireValue) -> Self {
match w {
WireValue::Null => Value::Null,
WireValue::Bool(b) => Value::Bool(b),
WireValue::Number(n) => Value::Number(n),
WireValue::String(s) => Value::String(s),
WireValue::Array(arr) => Value::Array(arr.into_iter().map(Into::into).collect()),
WireValue::Object(map) => {
Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireWidgetError {
SourceUnavailable,
ExtractionFailed,
}
impl From<&WidgetError> for WireWidgetError {
fn from(e: &WidgetError) -> Self {
match e {
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
}
}
}
impl From<WireWidgetError> for WidgetError {
fn from(w: WireWidgetError) -> Self {
match w {
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireKeyValue {
pub key: String,
@@ -81,35 +29,6 @@ pub struct WireWidgetState {
pub error: Option<WireWidgetError>,
}
impl From<&WidgetState> for WireWidgetState {
fn from(s: &WidgetState) -> Self {
WireWidgetState {
data: s
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: v.into(),
})
.collect(),
error: s.error.as_ref().map(Into::into),
}
}
}
impl From<WireWidgetState> for WidgetState {
fn from(w: WireWidgetState) -> Self {
WidgetState {
data: w
.data
.into_iter()
.map(|kv| (kv.key, kv.value.into()))
.collect(),
error: w.error.map(Into::into),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDisplayHintKind {
IconValue,
@@ -148,134 +67,18 @@ impl WireDisplayHint {
}
}
impl From<&DisplayHintKind> for WireDisplayHintKind {
fn from(k: &DisplayHintKind) -> Self {
match k {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
}
}
}
impl From<WireDisplayHintKind> for DisplayHintKind {
fn from(w: WireDisplayHintKind) -> Self {
match w {
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
}
}
}
impl From<&HAlign> for WireHAlign {
fn from(h: &HAlign) -> Self {
match h {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
}
}
}
impl From<WireHAlign> for HAlign {
fn from(w: WireHAlign) -> Self {
match w {
WireHAlign::Left => HAlign::Left,
WireHAlign::Center => HAlign::Center,
WireHAlign::Right => HAlign::Right,
}
}
}
impl From<&VAlign> for WireVAlign {
fn from(v: &VAlign) -> Self {
match v {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
}
}
}
impl From<WireVAlign> for VAlign {
fn from(w: WireVAlign) -> Self {
match w {
WireVAlign::Top => VAlign::Top,
WireVAlign::Middle => VAlign::Middle,
WireVAlign::Bottom => VAlign::Bottom,
}
}
}
impl From<&DisplayHint> for WireDisplayHint {
fn from(h: &DisplayHint) -> Self {
WireDisplayHint {
kind: (&h.kind).into(),
h_align: (&h.h_align).into(),
v_align: (&h.v_align).into(),
}
}
}
impl From<WireDisplayHint> for DisplayHint {
fn from(w: WireDisplayHint) -> Self {
DisplayHint {
kind: w.kind.into(),
h_align: w.h_align.into(),
v_align: w.v_align.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireSizing {
Fixed(u16),
Flex(u8),
}
impl From<&Sizing> for WireSizing {
fn from(s: &Sizing) -> Self {
match s {
Sizing::Fixed(px) => WireSizing::Fixed(*px),
Sizing::Flex(w) => WireSizing::Flex(*w),
}
}
}
impl From<WireSizing> for Sizing {
fn from(w: WireSizing) -> Self {
match w {
WireSizing::Fixed(px) => Sizing::Fixed(px),
WireSizing::Flex(weight) => Sizing::Flex(weight),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDirection {
Row,
Column,
}
impl From<&Direction> for WireDirection {
fn from(d: &Direction) -> Self {
match d {
Direction::Row => WireDirection::Row,
Direction::Column => WireDirection::Column,
}
}
}
impl From<WireDirection> for Direction {
fn from(w: WireDirection) -> Self {
match w {
WireDirection::Row => Direction::Row,
WireDirection::Column => Direction::Column,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireJustifyContent {
Start,
@@ -285,30 +88,6 @@ pub enum WireJustifyContent {
SpaceEvenly,
}
impl From<&JustifyContent> for WireJustifyContent {
fn from(j: &JustifyContent) -> Self {
match j {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
}
}
}
impl From<WireJustifyContent> for JustifyContent {
fn from(w: WireJustifyContent) -> Self {
match w {
WireJustifyContent::Start => JustifyContent::Start,
WireJustifyContent::Center => JustifyContent::Center,
WireJustifyContent::End => JustifyContent::End,
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireAlignItems {
Start,
@@ -317,28 +96,6 @@ pub enum WireAlignItems {
Stretch,
}
impl From<&AlignItems> for WireAlignItems {
fn from(a: &AlignItems) -> Self {
match a {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
}
}
}
impl From<WireAlignItems> for AlignItems {
fn from(w: WireAlignItems) -> Self {
match w {
WireAlignItems::Start => AlignItems::Start,
WireAlignItems::Center => AlignItems::Center,
WireAlignItems::End => AlignItems::End,
WireAlignItems::Stretch => AlignItems::Stretch,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireContainerNode {
pub direction: WireDirection,
@@ -360,49 +117,3 @@ pub enum WireLayoutNode {
Container(WireContainerNode),
Leaf(u16),
}
impl From<&LayoutNode> for WireLayoutNode {
fn from(n: &LayoutNode) -> Self {
match n {
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
direction: (&c.direction).into(),
gap: c.gap,
padding: c.padding,
justify_content: (&c.justify_content).into(),
align_items: (&c.align_items).into(),
children: c
.children
.iter()
.map(|ch| WireLayoutChild {
sizing: (&ch.sizing).into(),
node: (&ch.node).into(),
})
.collect(),
}),
}
}
}
impl From<WireLayoutNode> for LayoutNode {
fn from(w: WireLayoutNode) -> Self {
match w {
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
direction: c.direction.into(),
gap: c.gap,
padding: c.padding,
justify_content: c.justify_content.into(),
align_items: c.align_items.into(),
children: c
.children
.into_iter()
.map(|ch| LayoutChild {
sizing: ch.sizing.into(),
node: ch.node.into(),
})
.collect(),
}),
}
}
}

View File

@@ -1,86 +0,0 @@
use domain::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
};
use protocol::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
};
use std::collections::BTreeMap;
#[test]
fn value_converts_to_wire_and_back() {
let original = Value::Object(BTreeMap::from([(
"items".into(),
Value::Array(vec![
Value::String("hello".into()),
Value::Number(42.0),
Value::Bool(true),
Value::Null,
]),
)]));
let wire: WireValue = (&original).into();
let roundtripped: Value = wire.into();
assert_eq!(original, roundtripped);
}
#[test]
fn widget_state_with_error_converts_to_wire_and_back() {
let original = WidgetState {
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
error: Some(WidgetError::SourceUnavailable),
};
let wire: WireWidgetState = (&original).into();
let roundtripped: WidgetState = wire.into();
assert_eq!(original, roundtripped);
}
#[test]
fn layout_tree_converts_to_wire_and_back() {
let original = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(100),
node: LayoutNode::Container(ContainerNode {
direction: Direction::Column,
gap: 2,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
}],
}),
},
],
});
let wire: WireLayoutNode = (&original).into();
let roundtripped: LayoutNode = wire.into();
assert_eq!(original, roundtripped);
}
#[test]
fn display_hint_converts_to_wire_and_back() {
for hint in [
DisplayHint::new(DisplayHintKind::IconValue),
DisplayHint::new(DisplayHintKind::TextBlock),
DisplayHint::new(DisplayHintKind::KeyValue),
] {
let wire: WireDisplayHint = (&hint).into();
let roundtripped: DisplayHint = wire.into();
assert_eq!(hint, roundtripped);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +0,0 @@
from PIL import Image
INPUT = "/home/gabriel/Downloads/Logo.png"
OUTPUT = "/home/gabriel/Downloads/logo_splash.h"
DISPLAY_W, DISPLAY_H = 240, 320
BG_COLOR = (30, 30, 30) # dark background, change to (0,0,0) for pure black
img = Image.open(INPUT).convert("RGB")
# Scale logo to fit within display, preserving aspect ratio
img.thumbnail((DISPLAY_W, DISPLAY_H), Image.LANCZOS)
# Center on display canvas
canvas = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BG_COLOR)
x = (DISPLAY_W - img.width) // 2
y = (DISPLAY_H - img.height) // 2
canvas.paste(img, (x, y))
# Convert to RGB565
pixels = []
for r, g, b in canvas.getdata():
rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
# Swap bytes for ILI9341 (big-endian over SPI)
pixels.append(((rgb565 & 0xFF) << 8) | (rgb565 >> 8))
# Write header file
with open(OUTPUT, "w") as f:
f.write("#pragma once\n")
f.write("#include <pgmspace.h>\n\n")
f.write(f"// {DISPLAY_W}x{DISPLAY_H} RGB565, big-endian\n")
f.write(f"const uint16_t SPLASH_WIDTH = {DISPLAY_W};\n")
f.write(f"const uint16_t SPLASH_HEIGHT = {DISPLAY_H};\n\n")
f.write("const uint16_t splash_logo[] PROGMEM = {\n")
for i, p in enumerate(pixels):
if i % 12 == 0:
f.write(" ")
f.write(f"0x{p:04X},")
if i % 12 == 11:
f.write("\n")
f.write("\n};\n")
print(f"Done — {len(pixels)} pixels, {len(pixels)*2/1024:.1f} KB")
print(f"Output: {OUTPUT}")

View File

@@ -1,5 +1,13 @@
export type DisplayHint = "icon_value" | "text_block" | "key_value"
export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook"
export type DisplayHintKind = "icon_value" | "text_block" | "key_value"
export type HAlign = "left" | "center" | "right"
export type VAlign = "top" | "middle" | "bottom"
export interface DisplayHint {
kind: DisplayHintKind
h_align: HAlign
v_align: VAlign
}
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"
@@ -21,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 {

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef } from "react"
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
import { computeLayout } from "@/lib/layout-engine"
import { useWidgetPreview } from "@/api/widgets"
interface LayoutPreviewProps {
layout: LayoutNode
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
}
function formatPreviewData(
kind: string,
data: Record<string, unknown> | undefined,
): string {
if (!data) return ""
const entries = Object.entries(data)
if (entries.length === 0) return ""
switch (kind) {
case "key_value":
return entries.map(([k, v]) => `${k}: ${v}`).join("\n")
case "text_block":
return entries.map(([, v]) => String(v ?? "")).join("\n")
case "icon_value":
return entries.map(([, v]) => String(v ?? "")).join(" ")
default:
return entries.map(([, v]) => String(v ?? "")).join("\n")
}
}
function WidgetCell({ wid, widget, scale, theme }: {
wid: number
widget: Widget | undefined
scale: number
theme: ThemeConfig
}) {
const { data } = useWidgetPreview(wid, true)
const hAlign = widget?.display_hint?.h_align ?? "left"
const vAlign = widget?.display_hint?.v_align ?? "top"
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
const kind = widget?.display_hint?.kind ?? "text_block"
const previewText = formatPreviewData(kind, data as Record<string, unknown> | undefined)
const hasData = previewText.length > 0
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
width: "100%",
height: "100%",
}}
>
{hasData ? (
<span
style={{
fontSize: 8 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.3,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{previewText}
</span>
) : (
<>
<span
style={{
fontSize: 10 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.2,
opacity: 0.5,
}}
>
{widget?.name ?? `#${wid}`}
</span>
{widget && (
<span style={{ fontSize: 7 * scale, color: colorToCSS(theme.accent), textAlign, opacity: 0.5 }}>
{kind}
</span>
)}
</>
)}
</div>
)
}
export function LayoutPreview({
layout,
screenWidth,
@@ -65,35 +151,11 @@ export function LayoutPreview({
height: box.height * scale,
border: `1px solid ${colorToCSS(theme.secondary)}`,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
padding: 2 * scale,
}}
>
<span
style={{
fontSize: 10 * scale,
color: colorToCSS(theme.text),
textAlign: "center",
lineHeight: 1.2,
}}
>
{w?.name ?? `#${wid}`}
</span>
{w && (
<span
style={{
fontSize: 8 * scale,
color: colorToCSS(theme.accent),
textAlign: "center",
}}
>
{w.display_hint}
</span>
)}
<WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
</div>
)
})}

View File

@@ -1,11 +1,11 @@
import { useState } from "react"
import { useState, useEffect } from "react"
import {
useDataSources,
useCreateDataSource,
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,
@@ -14,13 +14,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -30,36 +23,74 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Plus, Pencil, Trash2, X, Eye, EyeOff } from "lucide-react"
import { Plus, Pencil, Trash2, X, Eye, EyeOff, ChevronUp } from "lucide-react"
import { toast } from "sonner"
const SOURCE_TYPES: SourceType[] = [
"weather",
"media",
"rss",
"http_json",
"webhook",
"weather", "media", "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: [] },
}
const VALID_TIMEZONES = new Set(Intl.supportedValuesOf("timeZone"))
const STRFTIME_MAP: Record<string, (d: Date) => string> = {
"%H": (d) => String(d.getHours()).padStart(2, "0"),
"%M": (d) => String(d.getMinutes()).padStart(2, "0"),
"%S": (d) => String(d.getSeconds()).padStart(2, "0"),
"%I": (d) => String(d.getHours() % 12 || 12).padStart(2, "0"),
"%p": (d) => (d.getHours() >= 12 ? "PM" : "AM"),
"%Y": (d) => String(d.getFullYear()),
"%m": (d) => String(d.getMonth() + 1).padStart(2, "0"),
"%d": (d) => String(d.getDate()).padStart(2, "0"),
}
function formatClockPreview(fmt: string, tz: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).formatToParts(new Date())
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
const h24 = Number(get("hour"))
const fakeDate = new Date(2000, 0, 1, h24, Number(get("minute")), Number(get("second")))
let result = fmt
for (const [token, fn] of Object.entries(STRFTIME_MAP)) {
if (token === "%Y") result = result.replaceAll(token, get("year"))
else if (token === "%m") result = result.replaceAll(token, get("month"))
else if (token === "%d") result = result.replaceAll(token, get("day"))
else result = result.replaceAll(token, fn(fakeDate))
}
return result
} catch {
return "invalid timezone"
}
}
function isValidSave(ds: DataSource): boolean {
if (!ds.name) return false
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.poll_interval_secs <= 0) return false
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.config.type === "external" && !ds.config.url) return false
return true
}
export function DataSourcesPage() {
@@ -68,166 +99,151 @@ export function DataSourcesPage() {
const update = useUpdateDataSource()
const del = useDeleteDataSource()
const [editing, setEditing] = useState<DataSource | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editingData, setEditingData] = useState<DataSource | null>(null)
const [newSource, setNewSource] = useState<DataSource | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
function openNew() {
const nextId =
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
setEditing({ ...EMPTY, id: nextId })
const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
setNewSource({ ...EMPTY, id: nextId })
setEditingId(null)
}
function openEdit(ds: DataSource) {
setEditing({ ...ds })
function startEdit(ds: DataSource) {
setEditingId(ds.id)
setEditingData({ ...ds })
setNewSource(null)
}
async function save() {
if (!editing) return
const isNew = !sources.some((s) => s.id === editing.id)
function cancelEdit() {
setEditingId(null)
setEditingData(null)
setNewSource(null)
}
async function saveExisting() {
if (!editingData) return
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Data source created")
} else {
await update.mutateAsync(editing)
toast.success("Data source updated")
}
setEditing(null)
await update.mutateAsync(editingData)
toast.success("Data source updated")
setEditingId(null)
setEditingData(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
async function saveNew() {
if (!newSource) return
try {
await del.mutateAsync(deleting)
await create.mutateAsync(newSource)
toast.success("Data source created")
setNewSource(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete(id: number) {
try {
await del.mutateAsync(id)
toast.success("Data source deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
setConfirmingDelete(null)
}
if (isLoading) return <div className="text-muted-foreground p-4">Loading</div>
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Data Sources
</h1>
<p className="text-muted-foreground text-sm">
Configure external data feeds
</p>
<h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
<p className="text-muted-foreground text-sm">Configure data feeds</p>
</div>
<Button onClick={openNew}>
<Button onClick={openNew} disabled={!!newSource}>
<Plus className="mr-2 h-4 w-4" />
Add Source
</Button>
</div>
{sources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No data sources configured yet.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{sources.map((ds) => (
<div className="grid gap-3">
{newSource && (
<Card className="border-primary">
<CardHeader className="py-3">
<CardTitle className="text-base">New Data Source</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DataSourceForm value={newSource} onChange={setNewSource} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveNew} disabled={!isValidSave(newSource)}>Save</Button>
</div>
</CardContent>
</Card>
)}
{sources.length === 0 && !newSource && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No data sources configured yet.</p>
</CardContent>
</Card>
)}
{sources.map((ds) => {
const isEditing = editingId === ds.id
const isDeleting = confirmingDelete === ds.id
return (
<Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
<CardTitle className="text-base">{ds.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{ds.source_type}</Badge>
<span>every {ds.poll_interval_secs}s</span>
{ds.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">
{ds.url}
</span>
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
)}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{isDeleting && (
<CardContent className="pt-0">
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
<span className="text-sm flex-1">Delete? Widgets referencing it will lose their feed.</span>
<Button size="sm" variant="destructive" onClick={() => confirmDelete(ds.id)}>Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
</div>
</CardContent>
)}
{isEditing && editingData && (
<CardContent className="space-y-4 pt-0">
<DataSourceForm value={editingData} onChange={setEditingData} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Edit / Create Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing && sources.some((s) => s.id === editing.id)
? "Edit Data Source"
: "New Data Source"}
</DialogTitle>
</DialogHeader>
{editing && (
<DataSourceForm value={editing} onChange={setEditing} />
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
(editing.source_type !== "webhook" &&
editing.poll_interval_secs <= 0) ||
(editing.source_type !== "webhook" && !editing.url)
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete data source?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this data source. Widgets referencing
it will lose their feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
})}
</div>
</div>
)
}
@@ -239,11 +255,7 @@ function isSensitiveKey(key: string) {
}
function HeaderRow({
headerKey,
headerValue,
onChangeKey,
onChangeValue,
onRemove,
headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
}: {
headerKey: string
headerValue: string
@@ -256,12 +268,7 @@ function HeaderRow({
return (
<div className="flex items-center gap-2">
<Input
value={headerKey}
onChange={(e) => onChangeKey(e.target.value)}
placeholder="key"
className="flex-1"
/>
<Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
<div className="relative flex-1">
<Input
type={sensitive && !visible ? "password" : "text"}
@@ -271,17 +278,8 @@ function HeaderRow({
className={sensitive ? "pr-9" : ""}
/>
{sensitive && (
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-full w-9"
onClick={() => setVisible((v) => !v)}
>
{visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
<Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
)}
</div>
@@ -292,105 +290,128 @@ function HeaderRow({
)
}
function DataSourceForm({
value,
onChange,
}: {
value: DataSource
onChange: (ds: DataSource) => void
}) {
function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
const [preview, setPreview] = useState("")
useEffect(() => {
setPreview(formatClockPreview(format, timezone))
const id = setInterval(() => setPreview(formatClockPreview(format, timezone)), 1000)
return () => clearInterval(id)
}, [format, timezone])
const validTz = VALID_TIMEZONES.has(timezone)
return (
<div className="space-y-1">
<p className="text-muted-foreground text-sm font-mono">{preview}</p>
{timezone && !validTz && (
<p className="text-destructive text-xs">Unknown timezone</p>
)}
</div>
)
}
function DataSourceForm({ value, onChange }: { value: DataSource; onChange: (ds: DataSource) => void }) {
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v })
const setConfig = (patch: Partial<DataSourceConfig>) =>
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 (
<div className="grid gap-4 py-2">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
<Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
</div>
<div className="grid gap-2">
<Label>Source Type</Label>
<Select
value={value.source_type}
onValueChange={(v) => set("source_type", v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.url ?? ""}
onChange={(e) => set("url", e.target.value || null)}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.api_key ?? ""}
onChange={(e) => set("api_key", e.target.value || null)}
placeholder="Optional"
/>
</div>
{isExternal && (
<>
<div className="grid gap-2">
<Label>URL</Label>
<Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
</div>
</>
)}
{isClock && (
<>
<div className="grid gap-2">
<Label>Format</Label>
<Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
</div>
<div className="grid gap-2">
<Label>Timezone</Label>
<Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
</div>
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
</>
)}
{isStaticText && (
<div className="grid gap-2">
<Label>Text</Label>
<Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
</div>
)}
<div className="grid gap-2">
<Label>Poll Interval (seconds)</Label>
<Input
type="number"
value={value.poll_interval_secs}
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
min={1}
/>
<Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
set("headers", [...value.headers, ["", ""]])
}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
{isExternal && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{value.config.headers.map(([k, v], i) => (
<HeaderRow
key={i}
headerKey={k}
headerValue={v}
onChangeKey={(newKey) => {
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) })}
/>
))}
</div>
{value.headers.map(([k, v], i) => (
<HeaderRow
key={i}
headerKey={k}
headerValue={v}
onChangeKey={(newKey) => {
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))
}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -18,16 +18,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
onAddContainer={(path, dir) =>
addChild(path, makeContainerChild(dir))
}
onRemove={() => setPendingDelete(selected)}
pendingDelete={pendingDelete}
onRequestDelete={() => setPendingDelete(selected)}
onConfirmDelete={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
onCancelDelete={() => setPendingDelete(null)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
isRoot={selected.length === 0}
widgets={widgets}
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
<LeafProps
path={selected}
widgetId={selectedNode?.widget_id ?? 0}
onRemove={() => setPendingDelete(selected)}
pendingDelete={pendingDelete}
onRequestDelete={() => setPendingDelete(selected)}
onConfirmDelete={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
onCancelDelete={() => setPendingDelete(null)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
widgets={widgets}
sizing={
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
</Card>
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDelete?.length === 0
? "Clear entire layout?"
: "Remove this node?"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.length === 0
? "This will remove the entire layout tree. You can rebuild it afterward."
: "This will remove the selected node and all its children."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
>
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{showPreview && root && theme && (
<Card>
<CardHeader className="pb-3">
@@ -493,7 +466,10 @@ function ContainerProps({
onUpdateProp,
onAddWidget,
onAddContainer,
onRemove,
pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing,
isRoot,
widgets,
@@ -503,7 +479,10 @@ function ContainerProps({
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void
pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
isRoot: boolean
widgets: { id: number; name: string }[]
@@ -622,14 +601,37 @@ function ContainerProps({
</Select>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={onRemove}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
{pendingDelete !== null &&
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
<div className="grid gap-2">
<p className="text-sm text-destructive">
{isRoot
? "Clear entire layout? You can rebuild afterward."
: "Remove this node and all its children?"}
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
>
{isRoot ? "Clear" : "Remove"}
</Button>
<Button variant="outline" size="sm" onClick={onCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<Button
variant="destructive"
size="sm"
onClick={onRequestDelete}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
)}
</div>
)
}
@@ -637,14 +639,20 @@ function ContainerProps({
function LeafProps({
path,
widgetId,
onRemove,
pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing,
widgets,
sizing,
}: {
path: Path
widgetId: number
onRemove: () => void
pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
widgets: { id: number; name: string }[]
sizing?: LayoutChild["sizing"]
@@ -657,10 +665,31 @@ function LeafProps({
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
</div>
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
<Button variant="destructive" size="sm" onClick={onRemove}>
<Trash2 className="mr-1 h-3 w-3" />
Remove
</Button>
{pendingDelete !== null &&
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
<div className="grid gap-2">
<p className="text-sm text-destructive">
Remove this widget?
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
>
Remove
</Button>
<Button variant="outline" size="sm" onClick={onCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<Button variant="destructive" size="sm" onClick={onRequestDelete}>
<Trash2 className="mr-1 h-3 w-3" />
Remove
</Button>
)}
</div>
)
}

View File

@@ -15,26 +15,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Save, Upload, Trash2 } from "lucide-react"
import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
import { toast } from "sonner"
export function PresetsPage() {
@@ -45,22 +27,22 @@ export function PresetsPage() {
const loadPreset = useLoadPreset()
const [saving, setSaving] = useState(false)
const [saveName, setSaveName] = useState("")
const [deleting, setDeleting] = useState<number | null>(null)
const [presetName, setPresetName] = useState("")
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
async function saveAsPreset() {
if (!layout || !saveName) return
if (!layout || !presetName) return
const nextId =
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
try {
await createPreset.mutateAsync({
id: nextId,
name: saveName,
name: presetName,
layout,
})
toast.success("Preset saved")
setSaving(false)
setSaveName("")
setPresetName("")
} catch (e) {
toast.error(String(e))
}
@@ -75,15 +57,14 @@ export function PresetsPage() {
}
}
async function confirmDelete() {
if (deleting == null) return
async function confirmDelete(id: number) {
try {
await deletePreset.mutateAsync(deleting)
await deletePreset.mutateAsync(id)
toast.success("Preset deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
setConfirmingDelete(null)
}
function nodeCount(node: Preset["layout"]["root"]): number {
@@ -104,12 +85,50 @@ export function PresetsPage() {
Save and restore layout configurations
</p>
</div>
<Button onClick={() => setSaving(true)} disabled={!layout}>
<Save className="mr-2 h-4 w-4" />
Save Current Layout
<Button
onClick={() => setSaving((v) => !v)}
disabled={!layout}
variant={saving ? "secondary" : "default"}
>
{saving ? (
<ChevronUp className="mr-2 h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? "Close" : "Save Current Layout"}
</Button>
</div>
{saving && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-base">Save Current Layout as Preset</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 pb-3">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g. dashboard"
onKeyDown={(e) => e.key === "Enter" && saveAsPreset()}
autoFocus
/>
<Button onClick={saveAsPreset} disabled={!presetName} size="sm">
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSaving(false)
setPresetName("")
}}
>
Cancel
</Button>
</CardContent>
</Card>
)}
{presets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
@@ -128,69 +147,47 @@ export function PresetsPage() {
</CardDescription>
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" />
Load
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
{confirmingDelete === p.id ? (
<>
<span className="text-sm text-muted-foreground self-center mr-1">
Delete?
</span>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(p.id)}
>
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmingDelete(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" />
Load
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setConfirmingDelete(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</CardHeader>
</Card>
))}
</div>
)}
<Dialog open={saving} onOpenChange={(o) => !o && setSaving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Current Layout as Preset</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Preset Name</Label>
<Input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="e.g. dashboard"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaving(false)}>
Cancel
</Button>
<Button onClick={saveAsPreset} disabled={!saveName}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete preset?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this preset.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -7,7 +7,7 @@ import {
useWidgetPreview,
} from "@/api/widgets"
import { useDataSources } from "@/api/data-sources"
import type { Widget, DisplayHint, KeyMapping } from "@/api/types"
import type { Widget, DisplayHintKind, HAlign, VAlign, KeyMapping } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
@@ -16,13 +16,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -32,27 +25,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Plus, Pencil, Trash2, X, Eye } from "lucide-react"
import { Plus, Pencil, Trash2, X, Eye, ChevronUp } from "lucide-react"
import { toast } from "sonner"
const DISPLAY_HINTS: DisplayHint[] = ["icon_value", "text_block", "key_value"]
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
const EMPTY: Widget = {
id: 0,
name: "",
display_hint: "icon_value",
display_hint: { kind: "icon_value", h_align: "left", v_align: "top" },
data_source_id: 0,
mappings: [],
max_data_size: 2048,
@@ -65,52 +47,72 @@ export function WidgetsPage() {
const update = useUpdateWidget()
const del = useDeleteWidget()
const [editing, setEditing] = useState<Widget | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [previewing, setPreviewing] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editingData, setEditingData] = useState<Widget | null>(null)
const [newWidget, setNewWidget] = useState<Widget | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
const [previewingId, setPreviewingId] = useState<number | null>(null)
function openNew() {
const nextId =
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
setEditing({
...EMPTY,
id: nextId,
data_source_id: sources[0]?.id ?? 0,
})
setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 })
setEditingId(null)
}
async function save() {
if (!editing) return
const isNew = !widgets.some((w) => w.id === editing.id)
function startEdit(w: Widget) {
setEditingId(w.id)
setEditingData({ ...w })
setNewWidget(null)
}
function cancelEdit() {
setEditingId(null)
setEditingData(null)
setNewWidget(null)
}
async function saveExisting() {
if (!editingData) return
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Widget created")
} else {
await update.mutateAsync(editing)
toast.success("Widget updated")
}
setEditing(null)
await update.mutateAsync(editingData)
toast.success("Widget updated")
setEditingId(null)
setEditingData(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
async function saveNew() {
if (!newWidget) return
try {
await del.mutateAsync(deleting)
await create.mutateAsync(newWidget)
toast.success("Widget created")
setNewWidget(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete(id: number) {
try {
await del.mutateAsync(id)
toast.success("Widget deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
setConfirmingDelete(null)
}
function togglePreview(id: number) {
setPreviewingId(previewingId === id ? null : id)
}
const sourceName = (id: number) =>
sources.find((s) => s.id === id)?.name ?? `#${id}`
if (isLoading) return <div className="text-muted-foreground p-4">Loading</div>
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
return (
<div className="space-y-6">
@@ -121,166 +123,108 @@ export function WidgetsPage() {
Display primitives bound to data sources
</p>
</div>
<Button onClick={openNew}>
<Button onClick={openNew} disabled={!!newWidget}>
<Plus className="mr-2 h-4 w-4" />
Add Widget
</Button>
</div>
{widgets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{widgets.map((w) => (
<div className="grid gap-3">
{newWidget && (
<Card className="border-primary">
<CardHeader className="py-3">
<CardTitle className="text-base">New Widget</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<WidgetForm value={newWidget} onChange={setNewWidget} sources={sources} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveNew} disabled={!newWidget.name || !newWidget.data_source_id}>Save</Button>
</div>
</CardContent>
</Card>
)}
{widgets.length === 0 && !newWidget && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent>
</Card>
)}
{widgets.map((w) => {
const isEditing = editingId === w.id
const isDeleting = confirmingDelete === w.id
const isPreviewing = previewingId === w.id
return (
<Card key={w.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
<CardTitle className="text-base">{w.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{w.display_hint}</Badge>
<Badge variant="secondary">{w.display_hint.kind}</Badge>
<span>source: {sourceName(w.data_source_id)}</span>
<span>{w.mappings.length} mapping(s)</span>
{w.mappings.length > 0 && <span>{w.mappings.length} mapping(s)</span>}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setPreviewing(w.id)}
title="Preview current data"
>
<Eye className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
{isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditing({ ...w })}
>
<Pencil className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(w.id)}
>
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{isDeleting && (
<CardContent className="pt-0">
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
<span className="text-sm flex-1">Delete this widget? Layout references will become dangling.</span>
<Button size="sm" variant="destructive" onClick={() => confirmDelete(w.id)}>Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
</div>
</CardContent>
)}
{isPreviewing && !isEditing && (
<CardContent className="pt-0">
<WidgetPreviewInline widgetId={w.id} />
</CardContent>
)}
{isEditing && editingData && (
<CardContent className="space-y-4 pt-0">
<WidgetForm value={editingData} onChange={setEditingData} sources={sources} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveExisting} disabled={!editingData.name || !editingData.data_source_id}>Save</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editing && widgets.some((w) => w.id === editing.id)
? "Edit Widget"
: "New Widget"}
</DialogTitle>
</DialogHeader>
{editing && (
<WidgetForm
value={editing}
onChange={setEditing}
sources={sources}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
!editing.data_source_id ||
editing.mappings.length === 0
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete widget?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this widget. Layout references will
become dangling.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{previewing != null && (
<WidgetPreviewDialog
widgetId={previewing}
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
onClose={() => setPreviewing(null)}
/>
)}
)
})}
</div>
</div>
)
}
function WidgetPreviewDialog({
widgetId,
widgetName,
onClose,
}: {
widgetId: number
widgetName: string
onClose: () => void
}) {
function WidgetPreviewInline({ widgetId }: { widgetId: number }) {
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
if (isLoading) return <p className="text-muted-foreground text-sm">Loading...</p>
if (isError) return <p className="text-muted-foreground text-sm">No data yet</p>
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Preview: {widgetName}</DialogTitle>
</DialogHeader>
<div className="py-2">
{isLoading && (
<p className="text-muted-foreground text-sm">Loading</p>
)}
{isError && (
<p className="text-muted-foreground text-sm">
No data yet widget hasn't been polled
</p>
)}
{data && (
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)
}
@@ -314,7 +258,7 @@ function WidgetForm({
}
return (
<div className="grid gap-4 py-2">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input
@@ -326,35 +270,57 @@ function WidgetForm({
<div className="grid gap-2">
<Label>Display Hint</Label>
<Select
value={value.display_hint}
onValueChange={(v) => set("display_hint", v as DisplayHint)}
value={value.display_hint.kind}
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DISPLAY_HINTS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
{DISPLAY_HINT_KINDS.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>H Align</Label>
<Select
value={value.display_hint.h_align}
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>V Align</Label>
<Select
value={value.display_hint.v_align}
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="middle">Middle</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label>Data Source</Label>
<Select
value={String(value.data_source_id)}
onValueChange={(v) => set("data_source_id", Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -380,26 +346,18 @@ function WidgetForm({
<div key={i} className="flex items-center gap-2">
<Input
value={m.source_path}
onChange={(e) =>
updateMapping(i, { ...m, source_path: e.target.value })
}
onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })}
placeholder="$.path.to.value"
className="flex-1"
/>
<span className="text-muted-foreground text-sm"></span>
<span className="text-muted-foreground text-sm">&rarr;</span>
<Input
value={m.target_key}
onChange={(e) =>
updateMapping(i, { ...m, target_key: e.target.value })
}
onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
placeholder="target_key"
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeMapping(i)}
>
<Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
<X className="h-3 w-3" />
</Button>
</div>