add all crates: domain, protocol, application, client, adapters, ESP32 firmware
Server: domain (entities, value objects, ports), protocol (postcard wire types), application (config service, data projection), adapters (config-memory, tcp-server), bootstrap (composition root with fake data). Client: client-domain (layout engine, render tree, HAL ports), client-application (message handling, repaint commands), adapters (tcp-client, display-terminal), client-desktop (end-to-end working). ESP32: client-esp32 firmware with ILI9341 display over SPI, WiFi networking. Display test verified on hardware — landscape orientation, text rendering works. 60 workspace tests, all passing.
This commit is contained in:
69
crates/domain/src/entities/data_source.rs
Normal file
69
crates/domain/src/entities/data_source.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::time::Duration;
|
||||
|
||||
pub type DataSourceId = u16;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DataSourceType {
|
||||
Weather,
|
||||
Media,
|
||||
Xtb,
|
||||
Rss,
|
||||
HttpJson,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataSourceConfig {
|
||||
pub url: Option<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataSource {
|
||||
pub id: DataSourceId,
|
||||
pub name: String,
|
||||
pub source_type: DataSourceType,
|
||||
pub poll_interval: Duration,
|
||||
pub config: DataSourceConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DataSourceValidationError {
|
||||
UrlRequired,
|
||||
PollIntervalNotAllowed,
|
||||
PollIntervalRequired,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
if self.poll_interval.is_zero() {
|
||||
errors.push(DataSourceValidationError::PollIntervalRequired);
|
||||
}
|
||||
if self.requires_url() && self.config.url.is_none() {
|
||||
errors.push(DataSourceValidationError::UrlRequired);
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
fn requires_url(&self) -> bool {
|
||||
matches!(
|
||||
self.source_type,
|
||||
DataSourceType::Weather
|
||||
| DataSourceType::Media
|
||||
| DataSourceType::Rss
|
||||
| DataSourceType::HttpJson
|
||||
)
|
||||
}
|
||||
}
|
||||
10
crates/domain/src/entities/layout_preset.rs
Normal file
10
crates/domain/src/entities/layout_preset.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::value_objects::Layout;
|
||||
|
||||
pub type LayoutPresetId = u16;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LayoutPreset {
|
||||
pub id: LayoutPresetId,
|
||||
pub name: String,
|
||||
pub layout: Layout,
|
||||
}
|
||||
7
crates/domain/src/entities/mod.rs
Normal file
7
crates/domain/src/entities/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod widget_config;
|
||||
mod data_source;
|
||||
mod layout_preset;
|
||||
|
||||
pub use widget_config::{WidgetConfig, WidgetId};
|
||||
pub use data_source::{DataSource, DataSourceId, DataSourceType, DataSourceConfig, DataSourceValidationError};
|
||||
pub use layout_preset::{LayoutPreset, LayoutPresetId};
|
||||
70
crates/domain/src/entities/widget_config.rs
Normal file
70
crates/domain/src/entities/widget_config.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::collections::BTreeMap;
|
||||
use crate::value_objects::{DisplayHint, KeyMapping, Value, WidgetState};
|
||||
|
||||
pub type WidgetId = u16;
|
||||
pub type DataSourceId = u16;
|
||||
|
||||
const DEFAULT_MAX_DATA_SIZE: u16 = 2048;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WidgetConfig {
|
||||
pub id: WidgetId,
|
||||
pub name: String,
|
||||
pub display_hint: DisplayHint,
|
||||
pub data_source_id: DataSourceId,
|
||||
pub mappings: Vec<KeyMapping>,
|
||||
pub max_data_size: u16,
|
||||
}
|
||||
|
||||
impl WidgetConfig {
|
||||
pub fn new(
|
||||
id: WidgetId,
|
||||
name: String,
|
||||
display_hint: DisplayHint,
|
||||
data_source_id: DataSourceId,
|
||||
mappings: Vec<KeyMapping>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
display_hint,
|
||||
data_source_id,
|
||||
mappings,
|
||||
max_data_size: DEFAULT_MAX_DATA_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract(&self, raw: &Value) -> WidgetState {
|
||||
let budget = self.max_data_size as usize;
|
||||
let mut used = 0usize;
|
||||
let mut data = BTreeMap::new();
|
||||
|
||||
for mapping in &self.mappings {
|
||||
if let Some((key, value)) = mapping.extract(raw) {
|
||||
let key_cost = key.len();
|
||||
let remaining = budget.saturating_sub(used + key_cost);
|
||||
let value = Self::truncate_value(value, remaining);
|
||||
used += key_cost + value.estimated_size();
|
||||
data.insert(key, value);
|
||||
if used >= budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WidgetState { data, error: None }
|
||||
}
|
||||
|
||||
fn truncate_value(value: Value, max_bytes: usize) -> Value {
|
||||
match value {
|
||||
Value::String(s) if s.len() > max_bytes => {
|
||||
let truncated: String = s.char_indices()
|
||||
.take_while(|(i, _)| *i < max_bytes)
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
Value::String(truncated)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
16
crates/domain/src/events/mod.rs
Normal file
16
crates/domain/src/events/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
|
||||
use crate::value_objects::Layout;
|
||||
|
||||
#[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 },
|
||||
LayoutPresetSaved { id: LayoutPresetId },
|
||||
LayoutPresetLoaded { id: LayoutPresetId },
|
||||
LayoutPresetDeleted { id: LayoutPresetId },
|
||||
}
|
||||
19
crates/domain/src/lib.rs
Normal file
19
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
#![allow(async_fn_in_trait)]
|
||||
|
||||
pub mod entities;
|
||||
pub mod value_objects;
|
||||
pub mod events;
|
||||
pub mod ports;
|
||||
|
||||
pub use entities::{
|
||||
WidgetConfig, WidgetId,
|
||||
DataSource, DataSourceId, DataSourceType, DataSourceConfig, DataSourceValidationError,
|
||||
LayoutPreset, LayoutPresetId,
|
||||
};
|
||||
pub use value_objects::{
|
||||
Value, KeyMapping,
|
||||
WidgetState, WidgetError, DisplayHint,
|
||||
Layout, LayoutNode, LayoutChild, ContainerNode, Direction, Sizing, LayoutValidationError,
|
||||
};
|
||||
pub use events::DomainEvent;
|
||||
pub use ports::{ConfigRepository, DataSourcePort, BroadcastPort, EventPublisher};
|
||||
17
crates/domain/src/ports/broadcast.rs
Normal file
17
crates/domain/src/ports/broadcast.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::entities::WidgetId;
|
||||
use crate::value_objects::{Layout, WidgetState};
|
||||
|
||||
pub trait BroadcastPort {
|
||||
type Error;
|
||||
|
||||
async fn push_screen_update(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
widgets: &[(WidgetId, WidgetState)],
|
||||
) -> Result<(), Self::Error>;
|
||||
|
||||
async fn push_data_update(
|
||||
&self,
|
||||
updates: &[(WidgetId, WidgetState)],
|
||||
) -> Result<(), Self::Error>;
|
||||
}
|
||||
26
crates/domain/src/ports/config_repository.rs
Normal file
26
crates/domain/src/ports/config_repository.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::entities::{
|
||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||
};
|
||||
use crate::value_objects::Layout;
|
||||
|
||||
pub trait ConfigRepository {
|
||||
type Error;
|
||||
|
||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error>;
|
||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error>;
|
||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error>;
|
||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error>;
|
||||
|
||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error>;
|
||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error>;
|
||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error>;
|
||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error>;
|
||||
|
||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error>;
|
||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error>;
|
||||
|
||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error>;
|
||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error>;
|
||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error>;
|
||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error>;
|
||||
}
|
||||
8
crates/domain/src/ports/data_source_port.rs
Normal file
8
crates/domain/src/ports/data_source_port.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::entities::DataSource;
|
||||
use crate::value_objects::Value;
|
||||
|
||||
pub trait DataSourcePort {
|
||||
type Error;
|
||||
|
||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error>;
|
||||
}
|
||||
7
crates/domain/src/ports/event.rs
Normal file
7
crates/domain/src/ports/event.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use crate::events::DomainEvent;
|
||||
|
||||
pub trait EventPublisher {
|
||||
type Error;
|
||||
|
||||
async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error>;
|
||||
}
|
||||
9
crates/domain/src/ports/mod.rs
Normal file
9
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod config_repository;
|
||||
mod data_source_port;
|
||||
mod broadcast;
|
||||
mod event;
|
||||
|
||||
pub use config_repository::ConfigRepository;
|
||||
pub use data_source_port::DataSourcePort;
|
||||
pub use broadcast::BroadcastPort;
|
||||
pub use event::EventPublisher;
|
||||
14
crates/domain/src/value_objects/key_mapping.rs
Normal file
14
crates/domain/src/value_objects/key_mapping.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use super::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct KeyMapping {
|
||||
pub source_path: String,
|
||||
pub target_key: String,
|
||||
}
|
||||
|
||||
impl KeyMapping {
|
||||
pub fn extract(&self, raw: &Value) -> Option<(String, Value)> {
|
||||
let value = raw.get_path(&self.source_path)?;
|
||||
Some((self.target_key.clone(), value.clone()))
|
||||
}
|
||||
}
|
||||
92
crates/domain/src/value_objects/layout.rs
Normal file
92
crates/domain/src/value_objects/layout.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::collections::BTreeSet;
|
||||
use crate::entities::WidgetId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Sizing {
|
||||
Fixed(u16),
|
||||
Flex(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Direction {
|
||||
Row,
|
||||
Column,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ContainerNode {
|
||||
pub direction: Direction,
|
||||
pub gap: u8,
|
||||
pub padding: u8,
|
||||
pub children: Vec<LayoutChild>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LayoutChild {
|
||||
pub sizing: Sizing,
|
||||
pub node: LayoutNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LayoutNode {
|
||||
Container(ContainerNode),
|
||||
Leaf(WidgetId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Layout {
|
||||
pub root: LayoutNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LayoutValidationError {
|
||||
UnknownWidget(WidgetId),
|
||||
EmptyContainer,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn validate(&self, known_widgets: &BTreeSet<WidgetId>) -> Vec<LayoutValidationError> {
|
||||
let mut errors = Vec::new();
|
||||
Self::validate_node(&self.root, known_widgets, &mut errors);
|
||||
errors
|
||||
}
|
||||
|
||||
fn validate_node(
|
||||
node: &LayoutNode,
|
||||
known: &BTreeSet<WidgetId>,
|
||||
errors: &mut Vec<LayoutValidationError>,
|
||||
) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => {
|
||||
if !known.contains(id) {
|
||||
errors.push(LayoutValidationError::UnknownWidget(*id));
|
||||
}
|
||||
}
|
||||
LayoutNode::Container(c) => {
|
||||
if c.children.is_empty() {
|
||||
errors.push(LayoutValidationError::EmptyContainer);
|
||||
}
|
||||
for child in &c.children {
|
||||
Self::validate_node(&child.node, known, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget_ids(&self) -> BTreeSet<WidgetId> {
|
||||
let mut ids = BTreeSet::new();
|
||||
Self::collect_ids(&self.root, &mut ids);
|
||||
ids
|
||||
}
|
||||
|
||||
fn collect_ids(node: &LayoutNode, ids: &mut BTreeSet<WidgetId>) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => { ids.insert(*id); }
|
||||
LayoutNode::Container(c) => {
|
||||
for child in &c.children {
|
||||
Self::collect_ids(&child.node, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/domain/src/value_objects/mod.rs
Normal file
11
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod value;
|
||||
mod key_mapping;
|
||||
mod widget_state;
|
||||
mod layout;
|
||||
|
||||
pub use value::Value;
|
||||
pub use key_mapping::KeyMapping;
|
||||
pub use widget_state::{WidgetState, WidgetError, DisplayHint};
|
||||
pub use layout::{
|
||||
Layout, LayoutNode, LayoutChild, ContainerNode, Direction, Sizing, LayoutValidationError,
|
||||
};
|
||||
58
crates/domain/src/value_objects/value.rs
Normal file
58
crates/domain/src/value_objects/value.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Array(Vec<Value>),
|
||||
Object(BTreeMap<String, Value>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn estimated_size(&self) -> usize {
|
||||
match self {
|
||||
Value::Null | Value::Bool(_) => 1,
|
||||
Value::Number(_) => 8,
|
||||
Value::String(s) => s.len(),
|
||||
Value::Array(arr) => arr.iter().map(|v| v.estimated_size()).sum(),
|
||||
Value::Object(map) => map
|
||||
.iter()
|
||||
.map(|(k, v)| k.len() + v.estimated_size())
|
||||
.sum(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_path(&self, path: &str) -> Option<&Value> {
|
||||
let path = path.strip_prefix("$").unwrap_or(path);
|
||||
let mut current = self;
|
||||
|
||||
for raw_segment in path.split('.').filter(|s| !s.is_empty()) {
|
||||
if let Some(bracket_pos) = raw_segment.find('[') {
|
||||
let key = &raw_segment[..bracket_pos];
|
||||
let index_str = raw_segment[bracket_pos + 1..].strip_suffix(']')?;
|
||||
let index: usize = index_str.parse().ok()?;
|
||||
|
||||
if !key.is_empty() {
|
||||
match current {
|
||||
Value::Object(map) => current = map.get(key)?,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
match current {
|
||||
Value::Array(arr) => current = arr.get(index)?,
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
match current {
|
||||
Value::Object(map) => current = map.get(raw_segment)?,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(current)
|
||||
}
|
||||
}
|
||||
21
crates/domain/src/value_objects/widget_state.rs
Normal file
21
crates/domain/src/value_objects/widget_state.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::collections::BTreeMap;
|
||||
use super::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WidgetState {
|
||||
pub data: BTreeMap<String, Value>,
|
||||
pub error: Option<WidgetError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WidgetError {
|
||||
SourceUnavailable,
|
||||
ExtractionFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DisplayHint {
|
||||
IconValue,
|
||||
TextBlock,
|
||||
KeyValue,
|
||||
}
|
||||
Reference in New Issue
Block a user