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:
7
crates/adapters/config-memory/Cargo.toml
Normal file
7
crates/adapters/config-memory/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "config-memory"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
119
crates/adapters/config-memory/src/lib.rs
Normal file
119
crates/adapters/config-memory/src/lib.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use domain::{
|
||||
ConfigRepository,
|
||||
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId,
|
||||
WidgetConfig, WidgetId,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MemoryConfigError {
|
||||
LockPoisoned,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MemoryConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MemoryConfigError::LockPoisoned => write!(f, "lock poisoned"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MemoryConfigStore {
|
||||
widgets: RwLock<HashMap<WidgetId, WidgetConfig>>,
|
||||
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
||||
layout: RwLock<Option<Layout>>,
|
||||
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||
}
|
||||
|
||||
impl MemoryConfigStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
widgets: RwLock::new(HashMap::new()),
|
||||
data_sources: RwLock::new(HashMap::new()),
|
||||
layout: RwLock::new(None),
|
||||
presets: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigRepository for MemoryConfigStore {
|
||||
type Error = MemoryConfigError;
|
||||
|
||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
||||
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.insert(config.id, config.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.insert(source.id, source.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||
let guard = self.layout.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.clone())
|
||||
}
|
||||
|
||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||
let mut guard = self.layout.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
*guard = Some(layout.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.insert(preset.id, preset.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
7
crates/adapters/display-terminal/Cargo.toml
Normal file
7
crates/adapters/display-terminal/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "display-terminal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
client-domain.workspace = true
|
||||
38
crates/adapters/display-terminal/src/lib.rs
Normal file
38
crates/adapters/display-terminal/src/lib.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use client_domain::{BoundingBox, DisplayPort};
|
||||
|
||||
pub struct TerminalDisplay;
|
||||
|
||||
impl TerminalDisplay {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl DisplayPort for TerminalDisplay {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||
println!("[CLEAR] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||
println!("[TEXT] ({x}, {y}) in {}x{}: \"{text}\"", bounds.width, bounds.height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> {
|
||||
println!("[ICON] ({x}, {y}): {icon}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||
println!("[BG] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<(), Self::Error> {
|
||||
println!("[FLUSH]");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
8
crates/adapters/tcp-client/Cargo.toml
Normal file
8
crates/adapters/tcp-client/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "tcp-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
client-domain.workspace = true
|
||||
protocol.workspace = true
|
||||
82
crates/adapters/tcp-client/src/lib.rs
Normal file
82
crates/adapters/tcp-client/src/lib.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
use client_domain::NetworkPort;
|
||||
use protocol::MAX_FRAME_SIZE;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TcpClientError {
|
||||
Io(std::io::Error),
|
||||
NotConnected,
|
||||
FrameTooLarge(usize),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TcpClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TcpClientError::Io(e) => write!(f, "io: {e}"),
|
||||
TcpClientError::NotConnected => write!(f, "not connected"),
|
||||
TcpClientError::FrameTooLarge(n) => write!(f, "frame too large: {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StdTcpClient {
|
||||
stream: Option<TcpStream>,
|
||||
}
|
||||
|
||||
impl StdTcpClient {
|
||||
pub fn new() -> Self {
|
||||
Self { stream: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkPort for StdTcpClient {
|
||||
type Error = TcpClientError;
|
||||
|
||||
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
|
||||
let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?;
|
||||
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
|
||||
stream.set_read_timeout(Some(Duration::from_millis(10))).map_err(TcpClientError::Io)?;
|
||||
self.stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) -> Result<(), Self::Error> {
|
||||
self.stream = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(&mut self, data: &[u8]) -> Result<(), Self::Error> {
|
||||
let stream = self.stream.as_mut().ok_or(TcpClientError::NotConnected)?;
|
||||
stream.write_all(data).map_err(TcpClientError::Io)
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
let stream = self.stream.as_mut().ok_or(TcpClientError::NotConnected)?;
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
match stream.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => return Ok(None),
|
||||
Err(e) => return Err(TcpClientError::Io(e)),
|
||||
}
|
||||
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
return Err(TcpClientError::FrameTooLarge(len));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.set_nonblocking(false).map_err(TcpClientError::Io)?;
|
||||
stream.read_exact(&mut payload).map_err(TcpClientError::Io)?;
|
||||
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.stream.is_some()
|
||||
}
|
||||
}
|
||||
10
crates/adapters/tcp-server/Cargo.toml
Normal file
10
crates/adapters/tcp-server/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "tcp-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
protocol.workspace = true
|
||||
tokio.workspace = true
|
||||
postcard.workspace = true
|
||||
150
crates/adapters/tcp-server/src/lib.rs
Normal file
150
crates/adapters/tcp-server/src/lib.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use domain::{
|
||||
BroadcastPort, EventPublisher, DomainEvent,
|
||||
Layout, WidgetId, WidgetState,
|
||||
};
|
||||
use protocol::{
|
||||
ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode,
|
||||
encode,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TcpServerError {
|
||||
Io(std::io::Error),
|
||||
Encode(postcard::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TcpServerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TcpServerError::Io(e) => write!(f, "io: {e}"),
|
||||
TcpServerError::Encode(e) => write!(f, "encode: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TcpBroadcaster {
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl TcpBroadcaster {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
fn send_frame(&self, frame: Vec<u8>) -> Result<(), TcpServerError> {
|
||||
let _ = self.tx.send(frame);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BroadcastPort for TcpBroadcaster {
|
||||
type Error = TcpServerError;
|
||||
|
||||
async fn push_screen_update(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
widgets: &[(WidgetId, WidgetState)],
|
||||
) -> Result<(), Self::Error> {
|
||||
let wire_layout: WireLayoutNode = (&layout.root).into();
|
||||
let wire_widgets: Vec<WidgetDescriptor> = widgets.iter().map(|(id, state)| {
|
||||
WidgetDescriptor {
|
||||
id: *id,
|
||||
display_hint: WireDisplayHint::IconValue,
|
||||
state: state.into(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let msg = ServerMessage::ScreenUpdate {
|
||||
layout: wire_layout,
|
||||
widgets: wire_widgets,
|
||||
};
|
||||
|
||||
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
|
||||
self.send_frame(frame)
|
||||
}
|
||||
|
||||
async fn push_data_update(
|
||||
&self,
|
||||
updates: &[(WidgetId, WidgetState)],
|
||||
) -> Result<(), Self::Error> {
|
||||
let wire_widgets: Vec<WidgetDescriptor> = updates.iter().map(|(id, state)| {
|
||||
WidgetDescriptor {
|
||||
id: *id,
|
||||
display_hint: WireDisplayHint::IconValue,
|
||||
state: state.into(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let msg = ServerMessage::DataUpdate {
|
||||
widgets: wire_widgets,
|
||||
};
|
||||
|
||||
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
|
||||
self.send_frame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TcpEventBus {
|
||||
tx: broadcast::Sender<DomainEvent>,
|
||||
}
|
||||
|
||||
impl TcpEventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventPublisher for TcpEventBus {
|
||||
type Error = TcpServerError;
|
||||
|
||||
async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> {
|
||||
let _ = self.tx.send(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_tcp_server(
|
||||
addr: &str,
|
||||
broadcaster: Arc<TcpBroadcaster>,
|
||||
) -> Result<(), TcpServerError> {
|
||||
let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?;
|
||||
println!("TCP server listening on {addr}");
|
||||
|
||||
loop {
|
||||
let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?;
|
||||
println!("Client connected: {peer}");
|
||||
|
||||
let mut rx = broadcaster.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => {
|
||||
if socket.write_all(&frame).await.is_err() {
|
||||
println!("Client disconnected: {peer}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
println!("Client {peer} lagged by {n} messages");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
10
crates/application/Cargo.toml
Normal file
10
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
118
crates/application/src/config_service.rs
Normal file
118
crates/application/src/config_service.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::fmt;
|
||||
use domain::{
|
||||
ConfigRepository, EventPublisher, DomainEvent,
|
||||
WidgetConfig, WidgetId,
|
||||
DataSource, DataSourceId, DataSourceValidationError,
|
||||
Layout, LayoutPreset, LayoutPresetId,
|
||||
};
|
||||
|
||||
pub struct ConfigService<'a, C, E> {
|
||||
config: &'a C,
|
||||
events: &'a E,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError<C: fmt::Debug, E: fmt::Debug> {
|
||||
Repository(C),
|
||||
Event(E),
|
||||
Validation(Vec<DataSourceValidationError>),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl<C: fmt::Debug, E: fmt::Debug> fmt::Display for ConfigError<C, E> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ConfigError::Repository(e) => write!(f, "repository error: {:?}", e),
|
||||
ConfigError::Event(e) => write!(f, "event error: {:?}", e),
|
||||
ConfigError::Validation(errors) => write!(f, "validation errors: {:?}", errors),
|
||||
ConfigError::NotFound => write!(f, "not found"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C, E> ConfigService<'a, C, E>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: fmt::Debug,
|
||||
E: EventPublisher,
|
||||
E::Error: fmt::Debug,
|
||||
{
|
||||
pub fn new(config: &'a C, events: &'a E) -> Self {
|
||||
Self { config, events }
|
||||
}
|
||||
|
||||
pub async fn create_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::WidgetCreated { id: widget.id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::WidgetUpdated { id: widget.id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_widget(&self, id: WidgetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.delete_widget(id).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::WidgetDeleted { id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_data_source(&self, source: DataSource) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
let errors = source.validate();
|
||||
if !errors.is_empty() {
|
||||
return Err(ConfigError::Validation(errors));
|
||||
}
|
||||
self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::DataSourceAdded { id: source.id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_data_source(&self, source: DataSource) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
let errors = source.validate();
|
||||
if !errors.is_empty() {
|
||||
return Err(ConfigError::Validation(errors));
|
||||
}
|
||||
self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::DataSourceUpdated { id: source.id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_data_source(&self, id: DataSourceId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.delete_data_source(id).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::DataSourceRemoved { id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_layout(&self, layout: Layout) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.save_layout(&layout).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::LayoutChanged { layout }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_preset(&self, preset: LayoutPreset) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.save_preset(&preset).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::LayoutPresetSaved { id: preset.id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
let preset = self.config.get_preset(id).await
|
||||
.map_err(ConfigError::Repository)?
|
||||
.ok_or(ConfigError::NotFound)?;
|
||||
|
||||
self.events.publish(DomainEvent::LayoutPresetLoaded { id }).await.map_err(ConfigError::Event)?;
|
||||
|
||||
self.config.save_layout(&preset.layout).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::LayoutChanged { layout: preset.layout }).await.map_err(ConfigError::Event)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||
self.config.delete_preset(id).await.map_err(ConfigError::Repository)?;
|
||||
self.events.publish(DomainEvent::LayoutPresetDeleted { id }).await.map_err(ConfigError::Event)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
42
crates/application/src/data_projection.rs
Normal file
42
crates/application/src/data_projection.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::collections::HashMap;
|
||||
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState};
|
||||
|
||||
pub struct DataProjection {
|
||||
current: HashMap<WidgetId, WidgetState>,
|
||||
}
|
||||
|
||||
impl DataProjection {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_poll_result(
|
||||
&mut self,
|
||||
data_source_id: DataSourceId,
|
||||
raw: &Value,
|
||||
widget_configs: &[WidgetConfig],
|
||||
) -> Vec<(WidgetId, WidgetState)> {
|
||||
let mut changed = Vec::new();
|
||||
|
||||
for config in widget_configs {
|
||||
if config.data_source_id != data_source_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_state = config.extract(raw);
|
||||
|
||||
let is_changed = self.current
|
||||
.get(&config.id)
|
||||
.map_or(true, |prev| *prev != new_state);
|
||||
|
||||
if is_changed {
|
||||
self.current.insert(config.id, new_state.clone());
|
||||
changed.push((config.id, new_state));
|
||||
}
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
}
|
||||
5
crates/application/src/lib.rs
Normal file
5
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod config_service;
|
||||
mod data_projection;
|
||||
|
||||
pub use config_service::ConfigService;
|
||||
pub use data_projection::DataProjection;
|
||||
151
crates/application/tests/config_service_tests.rs
Normal file
151
crates/application/tests/config_service_tests.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
mod support;
|
||||
|
||||
use std::time::Duration;
|
||||
use domain::{
|
||||
ConfigRepository, DisplayHint, DomainEvent, KeyMapping, WidgetConfig,
|
||||
DataSource, DataSourceConfig, DataSourceType,
|
||||
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||
LayoutPreset,
|
||||
};
|
||||
use application::ConfigService;
|
||||
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_widget_persists_and_emits_event() {
|
||||
let repo = InMemoryConfigRepository::new();
|
||||
let events = InMemoryEventPublisher::new();
|
||||
let service = ConfigService::new(&repo, &events);
|
||||
|
||||
let config = WidgetConfig::new(
|
||||
1,
|
||||
"weather".into(),
|
||||
DisplayHint::IconValue,
|
||||
1,
|
||||
vec![
|
||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
||||
],
|
||||
);
|
||||
|
||||
service.create_widget(config).await.unwrap();
|
||||
|
||||
let stored = repo.get_widget(1).await.unwrap();
|
||||
assert!(stored.is_some());
|
||||
|
||||
let emitted = events.emitted();
|
||||
assert_eq!(emitted.len(), 1);
|
||||
assert!(matches!(emitted[0], DomainEvent::WidgetCreated { id: 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_data_source_rejects_invalid() {
|
||||
let repo = InMemoryConfigRepository::new();
|
||||
let events = InMemoryEventPublisher::new();
|
||||
let service = ConfigService::new(&repo, &events);
|
||||
|
||||
let source = DataSource {
|
||||
id: 1,
|
||||
name: "bad".into(),
|
||||
source_type: DataSourceType::HttpJson,
|
||||
poll_interval: Duration::from_secs(60),
|
||||
config: DataSourceConfig {
|
||||
url: None,
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = service.create_data_source(source).await;
|
||||
assert!(result.is_err());
|
||||
assert!(events.emitted().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_data_source_persists_valid_and_emits_event() {
|
||||
let repo = InMemoryConfigRepository::new();
|
||||
let events = InMemoryEventPublisher::new();
|
||||
let service = ConfigService::new(&repo, &events);
|
||||
|
||||
let source = DataSource {
|
||||
id: 1,
|
||||
name: "weather".into(),
|
||||
source_type: DataSourceType::Weather,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
config: DataSourceConfig {
|
||||
url: Some("https://api.weather.com".into()),
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
},
|
||||
};
|
||||
|
||||
service.create_data_source(source).await.unwrap();
|
||||
|
||||
let stored = repo.get_data_source(1).await.unwrap();
|
||||
assert!(stored.is_some());
|
||||
|
||||
let emitted = events.emitted();
|
||||
assert_eq!(emitted.len(), 1);
|
||||
assert!(matches!(emitted[0], DomainEvent::DataSourceAdded { id: 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_layout_persists_and_emits_event() {
|
||||
let repo = InMemoryConfigRepository::new();
|
||||
let events = InMemoryEventPublisher::new();
|
||||
let service = ConfigService::new(&repo, &events);
|
||||
|
||||
let layout = Layout {
|
||||
root: LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 4,
|
||||
padding: 2,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
service.update_layout(layout.clone()).await.unwrap();
|
||||
|
||||
let stored = repo.get_layout().await.unwrap();
|
||||
assert_eq!(stored, Some(layout));
|
||||
|
||||
assert_eq!(events.emitted().len(), 1);
|
||||
assert!(matches!(events.emitted()[0], DomainEvent::LayoutChanged { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_preset_replaces_active_layout() {
|
||||
let repo = InMemoryConfigRepository::new();
|
||||
let events = InMemoryEventPublisher::new();
|
||||
let service = ConfigService::new(&repo, &events);
|
||||
|
||||
let preset_layout = Layout {
|
||||
root: LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(5) },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
let preset = LayoutPreset {
|
||||
id: 1,
|
||||
name: "vertical".into(),
|
||||
layout: preset_layout.clone(),
|
||||
};
|
||||
|
||||
repo.save_preset(&preset).await.unwrap();
|
||||
|
||||
service.load_preset(1).await.unwrap();
|
||||
|
||||
let stored = repo.get_layout().await.unwrap();
|
||||
assert_eq!(stored, Some(preset_layout));
|
||||
|
||||
let emitted = events.emitted();
|
||||
assert_eq!(emitted.len(), 2);
|
||||
assert!(matches!(emitted[0], DomainEvent::LayoutPresetLoaded { id: 1 }));
|
||||
assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. }));
|
||||
}
|
||||
82
crates/application/tests/data_projection_tests.rs
Normal file
82
crates/application/tests/data_projection_tests.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::collections::BTreeMap;
|
||||
use domain::{
|
||||
DisplayHint, KeyMapping, Value, WidgetConfig, WidgetId, WidgetState,
|
||||
};
|
||||
use application::DataProjection;
|
||||
|
||||
fn weather_widget() -> WidgetConfig {
|
||||
WidgetConfig::new(
|
||||
1,
|
||||
"weather".into(),
|
||||
DisplayHint::IconValue,
|
||||
10,
|
||||
vec![
|
||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
||||
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn weather_response(temp: f64) -> Value {
|
||||
Value::Object(BTreeMap::from([
|
||||
("temp".into(), Value::Number(temp)),
|
||||
("icon".into(), Value::String("sunny".into())),
|
||||
]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_poll_result_detects_new_widget_state() {
|
||||
let mut projection = DataProjection::new();
|
||||
let widgets = vec![weather_widget()];
|
||||
|
||||
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
||||
|
||||
assert_eq!(changed.len(), 1);
|
||||
assert_eq!(changed[0].0, 1);
|
||||
assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(5.4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_poll_result_returns_empty_when_nothing_changed() {
|
||||
let mut projection = DataProjection::new();
|
||||
let widgets = vec![weather_widget()];
|
||||
|
||||
projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
||||
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
||||
|
||||
assert!(changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_poll_result_detects_changed_value() {
|
||||
let mut projection = DataProjection::new();
|
||||
let widgets = vec![weather_widget()];
|
||||
|
||||
projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
||||
let changed = projection.apply_poll_result(10, &weather_response(6.1), &widgets);
|
||||
|
||||
assert_eq!(changed.len(), 1);
|
||||
assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(6.1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_poll_result_only_updates_widgets_bound_to_source() {
|
||||
let mut projection = DataProjection::new();
|
||||
let widgets = vec![
|
||||
weather_widget(),
|
||||
WidgetConfig::new(
|
||||
2,
|
||||
"portfolio".into(),
|
||||
DisplayHint::KeyValue,
|
||||
20,
|
||||
vec![
|
||||
KeyMapping { source_path: "$.value".into(), target_key: "amount".into() },
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
||||
|
||||
assert_eq!(changed.len(), 1);
|
||||
assert_eq!(changed[0].0, 1);
|
||||
}
|
||||
126
crates/application/tests/support/mod.rs
Normal file
126
crates/application/tests/support/mod.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use domain::{
|
||||
ConfigRepository, EventPublisher,
|
||||
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId,
|
||||
WidgetConfig, WidgetId, DomainEvent,
|
||||
};
|
||||
|
||||
pub struct InMemoryConfigRepository {
|
||||
pub widgets: RefCell<HashMap<WidgetId, WidgetConfig>>,
|
||||
pub data_sources: RefCell<HashMap<DataSourceId, DataSource>>,
|
||||
pub layout: RefCell<Option<Layout>>,
|
||||
pub presets: RefCell<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||
}
|
||||
|
||||
impl InMemoryConfigRepository {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
widgets: RefCell::new(HashMap::new()),
|
||||
data_sources: RefCell::new(HashMap::new()),
|
||||
layout: RefCell::new(None),
|
||||
presets: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Never;
|
||||
|
||||
impl std::fmt::Display for Never {
|
||||
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigRepository for InMemoryConfigRepository {
|
||||
type Error = Never;
|
||||
|
||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
||||
Ok(self.widgets.borrow().get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||
Ok(self.widgets.borrow().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||
self.widgets.borrow_mut().insert(config.id, config.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||
self.widgets.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||
Ok(self.data_sources.borrow().get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||
Ok(self.data_sources.borrow().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||
self.data_sources.borrow_mut().insert(source.id, source.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||
self.data_sources.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||
Ok(self.layout.borrow().clone())
|
||||
}
|
||||
|
||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||
*self.layout.borrow_mut() = Some(layout.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||
Ok(self.presets.borrow().get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||
Ok(self.presets.borrow().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||
self.presets.borrow_mut().insert(preset.id, preset.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||
self.presets.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InMemoryEventPublisher {
|
||||
pub events: RefCell<Vec<DomainEvent>>,
|
||||
}
|
||||
|
||||
impl InMemoryEventPublisher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emitted(&self) -> Vec<DomainEvent> {
|
||||
self.events.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventPublisher for InMemoryEventPublisher {
|
||||
type Error = Never;
|
||||
|
||||
async fn publish(&self, event: DomainEvent) -> Result<(), Self::Error> {
|
||||
self.events.borrow_mut().push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
12
crates/bootstrap/Cargo.toml
Normal file
12
crates/bootstrap/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "bootstrap"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
protocol.workspace = true
|
||||
application.workspace = true
|
||||
config-memory.workspace = true
|
||||
tcp-server.workspace = true
|
||||
tokio.workspace = true
|
||||
94
crates/bootstrap/src/main.rs
Normal file
94
crates/bootstrap/src/main.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use domain::{
|
||||
ConfigRepository, BroadcastPort,
|
||||
WidgetConfig, DisplayHint, KeyMapping,
|
||||
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||
Value, WidgetState,
|
||||
};
|
||||
use application::{ConfigService, DataProjection};
|
||||
use config_memory::MemoryConfigStore;
|
||||
use tcp_server::{TcpBroadcaster, TcpEventBus, run_tcp_server};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config_store = Arc::new(MemoryConfigStore::new());
|
||||
let event_bus = Arc::new(TcpEventBus::new(64));
|
||||
let broadcaster = Arc::new(TcpBroadcaster::new(64));
|
||||
|
||||
let service = ConfigService::new(config_store.as_ref(), event_bus.as_ref());
|
||||
|
||||
service.create_widget(WidgetConfig::new(
|
||||
1, "weather".into(), DisplayHint::IconValue, 1,
|
||||
vec![
|
||||
KeyMapping { source_path: "$.temperature".into(), target_key: "value".into() },
|
||||
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
|
||||
],
|
||||
)).await.unwrap();
|
||||
|
||||
service.create_widget(WidgetConfig::new(
|
||||
2, "portfolio".into(), DisplayHint::KeyValue, 2,
|
||||
vec![
|
||||
KeyMapping { source_path: "$.amount".into(), target_key: "value".into() },
|
||||
],
|
||||
)).await.unwrap();
|
||||
|
||||
let layout = Layout {
|
||||
root: LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 4,
|
||||
padding: 2,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
}),
|
||||
};
|
||||
service.update_layout(layout).await.unwrap();
|
||||
|
||||
let bc = broadcaster.clone();
|
||||
tokio::spawn(async move {
|
||||
run_tcp_server("0.0.0.0:2699", bc).await.unwrap();
|
||||
});
|
||||
|
||||
println!("Server running on :2699");
|
||||
println!("Sending fake data every 3 seconds...");
|
||||
|
||||
let mut projection = DataProjection::new();
|
||||
let mut counter = 0u32;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
counter += 1;
|
||||
|
||||
let widgets = config_store.list_widgets().await.unwrap();
|
||||
let layout = config_store.get_layout().await.unwrap();
|
||||
|
||||
let weather_data = Value::Object(std::collections::BTreeMap::from([
|
||||
("temperature".into(), Value::String(format!("{}.{}°C", 5 + counter % 10, counter % 10))),
|
||||
("icon".into(), Value::String("sunny".into())),
|
||||
]));
|
||||
|
||||
let portfolio_data = Value::Object(std::collections::BTreeMap::from([
|
||||
("amount".into(), Value::String(format!("{}.{} PLN", 100 + counter, counter % 100))),
|
||||
]));
|
||||
|
||||
let changed_weather = projection.apply_poll_result(1, &weather_data, &widgets);
|
||||
let changed_portfolio = projection.apply_poll_result(2, &portfolio_data, &widgets);
|
||||
|
||||
let mut all_changed: Vec<(u16, WidgetState)> = Vec::new();
|
||||
all_changed.extend(changed_weather);
|
||||
all_changed.extend(changed_portfolio);
|
||||
|
||||
if !all_changed.is_empty() {
|
||||
if counter == 1 {
|
||||
if let Some(l) = &layout {
|
||||
broadcaster.push_screen_update(l, &all_changed).await.unwrap();
|
||||
}
|
||||
} else {
|
||||
broadcaster.push_data_update(&all_changed).await.unwrap();
|
||||
}
|
||||
println!("Pushed {} widget updates (tick {counter})", all_changed.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/client-application/Cargo.toml
Normal file
11
crates/client-application/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "client-application"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
client-domain.workspace = true
|
||||
protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
110
crates/client-application/src/client_app.rs
Normal file
110
crates/client-application/src/client_app.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::collections::HashMap;
|
||||
use domain::LayoutNode;
|
||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||
use protocol::{
|
||||
ServerMessage, WidgetDescriptor, WireDisplayHint, WireWidgetState, WireLayoutNode,
|
||||
};
|
||||
|
||||
pub struct ClientApp {
|
||||
screen: BoundingBox,
|
||||
render_tree: Option<RenderTree>,
|
||||
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RepaintCommand {
|
||||
pub widget_id: u16,
|
||||
pub bounds: BoundingBox,
|
||||
pub display_hint: WireDisplayHint,
|
||||
pub state: WireWidgetState,
|
||||
}
|
||||
|
||||
impl ClientApp {
|
||||
pub fn new(screen: BoundingBox) -> Self {
|
||||
Self {
|
||||
screen,
|
||||
render_tree: None,
|
||||
widget_states: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_message(&mut self, msg: ServerMessage) -> Vec<RepaintCommand> {
|
||||
match msg {
|
||||
ServerMessage::ScreenUpdate { layout, widgets } => {
|
||||
self.handle_screen_update(layout, widgets)
|
||||
}
|
||||
ServerMessage::DataUpdate { widgets } => {
|
||||
self.handle_data_update(widgets)
|
||||
}
|
||||
ServerMessage::Heartbeat => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_screen_update(
|
||||
&mut self,
|
||||
wire_layout: WireLayoutNode,
|
||||
widgets: Vec<WidgetDescriptor>,
|
||||
) -> Vec<RepaintCommand> {
|
||||
let layout: LayoutNode = wire_layout.into();
|
||||
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()));
|
||||
}
|
||||
|
||||
let repaints = self.build_repaints_for_all(&new_tree);
|
||||
self.render_tree = Some(new_tree);
|
||||
repaints
|
||||
}
|
||||
|
||||
fn handle_data_update(
|
||||
&mut self,
|
||||
widgets: Vec<WidgetDescriptor>,
|
||||
) -> Vec<RepaintCommand> {
|
||||
let tree = match &self.render_tree {
|
||||
Some(t) => t,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut repaints = Vec::new();
|
||||
|
||||
for w in widgets {
|
||||
let changed = self.widget_states
|
||||
.get(&w.id)
|
||||
.map_or(true, |(_, prev_state)| *prev_state != w.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(),
|
||||
});
|
||||
}
|
||||
self.widget_states.insert(w.id, (w.display_hint, w.state));
|
||||
}
|
||||
}
|
||||
|
||||
repaints
|
||||
}
|
||||
|
||||
fn build_repaints_for_all(&self, tree: &RenderTree) -> Vec<RepaintCommand> {
|
||||
let mut repaints = Vec::new();
|
||||
|
||||
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.sort_by_key(|r| r.widget_id);
|
||||
repaints
|
||||
}
|
||||
}
|
||||
3
crates/client-application/src/lib.rs
Normal file
3
crates/client-application/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod client_app;
|
||||
|
||||
pub use client_app::{ClientApp, RepaintCommand};
|
||||
154
crates/client-application/tests/client_app_tests.rs
Normal file
154
crates/client-application/tests/client_app_tests.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use client_application::{ClientApp, RepaintCommand};
|
||||
use client_domain::BoundingBox;
|
||||
use protocol::{
|
||||
ServerMessage, WidgetDescriptor,
|
||||
WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild,
|
||||
WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue,
|
||||
};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
}
|
||||
|
||||
fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor {
|
||||
WidgetDescriptor {
|
||||
id,
|
||||
display_hint: WireDisplayHint::IconValue,
|
||||
state: WireWidgetState {
|
||||
data: vec![
|
||||
WireKeyValue { key: "temperature".into(), value: WireValue::String(temp.into()) },
|
||||
],
|
||||
error: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn two_widget_layout() -> WireLayoutNode {
|
||||
WireLayoutNode::Container(WireContainerNode {
|
||||
direction: WireDirection::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) },
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_update_repaints_all_widgets() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
let msg = ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
};
|
||||
|
||||
let repaints = app.handle_message(msg);
|
||||
|
||||
assert_eq!(repaints.len(), 2);
|
||||
assert_eq!(repaints[0].widget_id, 1);
|
||||
assert_eq!(repaints[0].bounds, BoundingBox::new(0, 0, 120, 320));
|
||||
assert_eq!(repaints[1].widget_id, 2);
|
||||
assert_eq!(repaints[1].bounds, BoundingBox::new(120, 0, 120, 320));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_only_repaints_changed_widgets() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::DataUpdate {
|
||||
widgets: vec![weather_descriptor(1, "6.1°C")],
|
||||
});
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_with_unchanged_data_produces_no_repaints() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::DataUpdate {
|
||||
widgets: vec![weather_descriptor(1, "5.4°C")],
|
||||
});
|
||||
|
||||
assert!(repaints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_screen_update_repaints_all_widgets_with_new_layout() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
let column_layout = WireLayoutNode::Container(WireContainerNode {
|
||||
direction: WireDirection::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) },
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) },
|
||||
],
|
||||
});
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: column_layout,
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(repaints.len(), 2);
|
||||
assert_eq!(repaints[0].bounds, BoundingBox::new(0, 0, 240, 160));
|
||||
assert_eq!(repaints[1].bounds, BoundingBox::new(0, 160, 240, 160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_before_screen_update_produces_no_repaints() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::DataUpdate {
|
||||
widgets: vec![weather_descriptor(1, "5.4°C")],
|
||||
});
|
||||
|
||||
assert!(repaints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_produces_no_repaints() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::Heartbeat);
|
||||
assert!(repaints.is_empty());
|
||||
}
|
||||
12
crates/client-desktop/Cargo.toml
Normal file
12
crates/client-desktop/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "client-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
protocol.workspace = true
|
||||
client-domain.workspace = true
|
||||
client-application.workspace = true
|
||||
tcp-client.workspace = true
|
||||
display-terminal.workspace = true
|
||||
85
crates/client-desktop/src/main.rs
Normal file
85
crates/client-desktop/src/main.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::thread;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use client_domain::{BoundingBox, DisplayPort, NetworkPort};
|
||||
use client_application::ClientApp;
|
||||
use tcp_client::StdTcpClient;
|
||||
use display_terminal::TerminalDisplay;
|
||||
use protocol::decode_server_message;
|
||||
|
||||
fn main() {
|
||||
let screen = BoundingBox::screen(240, 320);
|
||||
let mut app = ClientApp::new(screen);
|
||||
let mut display = TerminalDisplay::new();
|
||||
|
||||
println!("=== K-Frame Desktop Client ===");
|
||||
println!("Screen: {}x{}", screen.width, screen.height);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
println!("[RENDER] Render loop started");
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(msg) => {
|
||||
let repaints = app.handle_message(msg);
|
||||
if !repaints.is_empty() {
|
||||
println!("\n--- Repaint ({} widgets) ---", repaints.len());
|
||||
for cmd in &repaints {
|
||||
display.clear_region(cmd.bounds).unwrap();
|
||||
display.fill_background(cmd.bounds).unwrap();
|
||||
|
||||
for kv in &cmd.state.data {
|
||||
if let protocol::WireValue::String(s) = &kv.value {
|
||||
display.draw_text(
|
||||
&format!("{}: {s}", kv.key),
|
||||
cmd.bounds.x, cmd.bounds.y,
|
||||
cmd.bounds,
|
||||
).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
display.flush().unwrap();
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/client-domain/Cargo.toml
Normal file
9
crates/client-domain/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "client-domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
17
crates/client-domain/src/bounding_box.rs
Normal file
17
crates/client-domain/src/bounding_box.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BoundingBox {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
impl BoundingBox {
|
||||
pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
|
||||
Self { x, y, width, height }
|
||||
}
|
||||
|
||||
pub fn screen(width: u16, height: u16) -> Self {
|
||||
Self { x: 0, y: 0, width, height }
|
||||
}
|
||||
}
|
||||
96
crates/client-domain/src/layout_engine.rs
Normal file
96
crates/client-domain/src/layout_engine.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use std::collections::HashMap;
|
||||
use domain::{LayoutNode, ContainerNode, Direction, Sizing};
|
||||
use crate::{BoundingBox, RenderTree};
|
||||
|
||||
pub struct LayoutEngine;
|
||||
|
||||
impl LayoutEngine {
|
||||
pub fn compute(layout: &LayoutNode, bounds: BoundingBox) -> RenderTree {
|
||||
let mut widget_bounds = HashMap::new();
|
||||
Self::compute_node(layout, bounds, &mut widget_bounds);
|
||||
RenderTree { widget_bounds }
|
||||
}
|
||||
|
||||
fn compute_node(
|
||||
node: &LayoutNode,
|
||||
bounds: BoundingBox,
|
||||
out: &mut HashMap<u16, BoundingBox>,
|
||||
) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => {
|
||||
out.insert(*id, bounds);
|
||||
}
|
||||
LayoutNode::Container(container) => {
|
||||
Self::compute_container(container, bounds, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_container(
|
||||
container: &ContainerNode,
|
||||
bounds: BoundingBox,
|
||||
out: &mut HashMap<u16, BoundingBox>,
|
||||
) {
|
||||
let inner = BoundingBox::new(
|
||||
bounds.x + container.padding as u16,
|
||||
bounds.y + container.padding as u16,
|
||||
bounds.width.saturating_sub(container.padding as u16 * 2),
|
||||
bounds.height.saturating_sub(container.padding as u16 * 2),
|
||||
);
|
||||
|
||||
let children = &container.children;
|
||||
if children.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_row = container.direction == Direction::Row;
|
||||
let total_axis = if is_row { inner.width } else { inner.height };
|
||||
let total_gap = container.gap as u16 * (children.len() as u16).saturating_sub(1);
|
||||
let available = total_axis.saturating_sub(total_gap);
|
||||
|
||||
let fixed_total: u16 = children.iter().map(|c| match c.sizing {
|
||||
Sizing::Fixed(px) => px,
|
||||
Sizing::Flex(_) => 0,
|
||||
}).sum();
|
||||
|
||||
let flex_space = available.saturating_sub(fixed_total);
|
||||
let flex_total: u16 = children.iter().map(|c| match c.sizing {
|
||||
Sizing::Flex(w) => w as u16,
|
||||
Sizing::Fixed(_) => 0,
|
||||
}).sum();
|
||||
|
||||
let mut offset = 0u16;
|
||||
|
||||
for child in children {
|
||||
let child_size = match child.sizing {
|
||||
Sizing::Fixed(px) => px,
|
||||
Sizing::Flex(w) => {
|
||||
if flex_total > 0 {
|
||||
(flex_space as u32 * w as u32 / flex_total as u32) as u16
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let child_bounds = if is_row {
|
||||
BoundingBox::new(
|
||||
inner.x + offset,
|
||||
inner.y,
|
||||
child_size,
|
||||
inner.height,
|
||||
)
|
||||
} else {
|
||||
BoundingBox::new(
|
||||
inner.x,
|
||||
inner.y + offset,
|
||||
inner.width,
|
||||
child_size,
|
||||
)
|
||||
};
|
||||
|
||||
Self::compute_node(&child.node, child_bounds, out);
|
||||
offset += child_size + container.gap as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/client-domain/src/lib.rs
Normal file
9
crates/client-domain/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod bounding_box;
|
||||
mod layout_engine;
|
||||
mod render_tree;
|
||||
pub mod ports;
|
||||
|
||||
pub use bounding_box::BoundingBox;
|
||||
pub use layout_engine::LayoutEngine;
|
||||
pub use render_tree::RenderTree;
|
||||
pub use ports::{DisplayPort, NetworkPort, StoragePort, ClientConfig};
|
||||
11
crates/client-domain/src/ports/display.rs
Normal file
11
crates/client-domain/src/ports/display.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::BoundingBox;
|
||||
|
||||
pub trait DisplayPort {
|
||||
type Error;
|
||||
|
||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>;
|
||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||
fn flush(&mut self) -> Result<(), Self::Error>;
|
||||
}
|
||||
7
crates/client-domain/src/ports/mod.rs
Normal file
7
crates/client-domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod display;
|
||||
mod network;
|
||||
mod storage;
|
||||
|
||||
pub use display::DisplayPort;
|
||||
pub use network::NetworkPort;
|
||||
pub use storage::{StoragePort, ClientConfig};
|
||||
9
crates/client-domain/src/ports/network.rs
Normal file
9
crates/client-domain/src/ports/network.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub trait NetworkPort {
|
||||
type Error;
|
||||
|
||||
fn connect(&mut self, addr: &str) -> Result<(), Self::Error>;
|
||||
fn disconnect(&mut self) -> Result<(), Self::Error>;
|
||||
fn send(&mut self, data: &[u8]) -> Result<(), Self::Error>;
|
||||
fn receive(&mut self) -> Result<Option<Vec<u8>>, Self::Error>;
|
||||
fn is_connected(&self) -> bool;
|
||||
}
|
||||
11
crates/client-domain/src/ports/storage.rs
Normal file
11
crates/client-domain/src/ports/storage.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
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>;
|
||||
}
|
||||
29
crates/client-domain/src/render_tree.rs
Normal file
29
crates/client-domain/src/render_tree.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::collections::HashMap;
|
||||
use domain::WidgetId;
|
||||
use crate::BoundingBox;
|
||||
|
||||
pub struct RenderTree {
|
||||
pub widget_bounds: HashMap<WidgetId, BoundingBox>,
|
||||
}
|
||||
|
||||
impl RenderTree {
|
||||
pub fn get_widget_bounds(&self, id: WidgetId) -> Option<&BoundingBox> {
|
||||
self.widget_bounds.get(&id)
|
||||
}
|
||||
|
||||
pub fn diff(&self, other: &RenderTree) -> Vec<WidgetId> {
|
||||
let mut changed = Vec::new();
|
||||
for (id, bounds) in &self.widget_bounds {
|
||||
match other.widget_bounds.get(id) {
|
||||
Some(prev) if prev == bounds => {}
|
||||
_ => changed.push(*id),
|
||||
}
|
||||
}
|
||||
for id in other.widget_bounds.keys() {
|
||||
if !self.widget_bounds.contains_key(id) {
|
||||
changed.push(*id);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
151
crates/client-domain/tests/layout_engine_tests.rs
Normal file
151
crates/client-domain/tests/layout_engine_tests.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use domain::{
|
||||
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||
};
|
||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
}
|
||||
|
||||
fn leaf(id: u16) -> LayoutChild {
|
||||
LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: LayoutNode::Leaf(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_fixed(id: u16, size: u16) -> LayoutChild {
|
||||
LayoutChild {
|
||||
sizing: Sizing::Fixed(size),
|
||||
node: LayoutNode::Leaf(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(children: Vec<LayoutChild>) -> LayoutNode {
|
||||
LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn column(children: Vec<LayoutChild>) -> LayoutNode {
|
||||
LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_with_gap(gap: u8, children: Vec<LayoutChild>) -> LayoutNode {
|
||||
LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap,
|
||||
padding: 0,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_with_padding(padding: u8, children: Vec<LayoutChild>) -> LayoutNode {
|
||||
LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_leaf_fills_screen() {
|
||||
let layout = LayoutNode::Leaf(1);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(1),
|
||||
Some(&BoundingBox::new(0, 0, 240, 320))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_splits_width_among_equal_flex_children() {
|
||||
let layout = row(vec![leaf(1), leaf(2), leaf(3)]);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 80, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(80, 0, 80, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(160, 0, 80, 320)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_splits_height_among_equal_flex_children() {
|
||||
let layout = column(vec![leaf(1), leaf(2)]);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 240, 160)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(0, 160, 240, 160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_and_flex_children_coexist() {
|
||||
let layout = row(vec![leaf_fixed(1, 40), leaf(2)]);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(40, 0, 200, 320)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_is_subtracted_before_distributing_space() {
|
||||
// 240px wide, 2 children, gap=10 → 230px available, 115px each
|
||||
let layout = row_with_gap(10, vec![leaf(1), leaf(2)]);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 115, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(125, 0, 115, 320)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_insets_available_area() {
|
||||
// padding=10 → inner area starts at (10,10), size (220,300)
|
||||
let layout = row_with_padding(10, vec![leaf(1)]);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(10, 10, 220, 300)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_containers_compute_correctly() {
|
||||
// Row with [leaf(1), column([leaf(2), leaf(3)])]
|
||||
let inner_col = LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: column(vec![leaf(2), leaf(3)]),
|
||||
};
|
||||
let layout = row(vec![leaf(1), inner_col]);
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 120, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 120, 160)));
|
||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(120, 160, 120, 160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_flex_distributes_proportionally() {
|
||||
// weights [1, 2, 1] → 25%, 50%, 25% of 240 = 60, 120, 60
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Flex(2), node: LayoutNode::Leaf(2) },
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(3) },
|
||||
],
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 60, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(60, 0, 120, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(180, 0, 60, 320)));
|
||||
}
|
||||
85
crates/client-domain/tests/render_tree_tests.rs
Normal file
85
crates/client-domain/tests/render_tree_tests.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use domain::{
|
||||
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||
};
|
||||
use client_domain::{BoundingBox, LayoutEngine};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_detects_moved_widget_after_layout_change() {
|
||||
let layout_a = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
});
|
||||
|
||||
let layout_b = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
});
|
||||
|
||||
let tree_a = LayoutEngine::compute(&layout_a, screen());
|
||||
let tree_b = LayoutEngine::compute(&layout_b, screen());
|
||||
|
||||
let mut changed = tree_b.diff(&tree_a);
|
||||
changed.sort();
|
||||
assert_eq!(changed, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_returns_empty_for_identical_layouts() {
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
});
|
||||
|
||||
let tree_a = LayoutEngine::compute(&layout, screen());
|
||||
let tree_b = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert!(tree_b.diff(&tree_a).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_detects_added_and_removed_widgets() {
|
||||
let layout_a = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
],
|
||||
});
|
||||
|
||||
let layout_b = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
});
|
||||
|
||||
let tree_a = LayoutEngine::compute(&layout_a, screen());
|
||||
let tree_b = LayoutEngine::compute(&layout_b, screen());
|
||||
|
||||
let mut changed = tree_b.diff(&tree_a);
|
||||
changed.sort();
|
||||
// widget 1 was removed (in old but not new), widget 2 was added (in new but not old)
|
||||
assert_eq!(changed, vec![1, 2]);
|
||||
}
|
||||
13
crates/client-esp32/.cargo/config.toml
Normal file
13
crates/client-esp32/.cargo/config.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[build]
|
||||
target = "xtensa-esp32-espidf"
|
||||
|
||||
[target.xtensa-esp32-espidf]
|
||||
linker = "ldproxy"
|
||||
runner = "espflash flash --monitor"
|
||||
|
||||
[unstable]
|
||||
build-std = ["std", "panic_abort"]
|
||||
|
||||
[env]
|
||||
MCU = "esp32"
|
||||
ESP_IDF_VERSION = "v5.4"
|
||||
2
crates/client-esp32/.gitignore
vendored
Normal file
2
crates/client-esp32/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
.embuild/
|
||||
1820
crates/client-esp32/Cargo.lock
generated
Normal file
1820
crates/client-esp32/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
crates/client-esp32/Cargo.toml
Normal file
30
crates/client-esp32/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "client-esp32"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { path = "../domain" }
|
||||
protocol = { path = "../protocol" }
|
||||
client-domain = { path = "../client-domain" }
|
||||
client-application = { path = "../client-application" }
|
||||
|
||||
esp-idf-hal = "0.46"
|
||||
esp-idf-svc = { version = "0.52", features = ["experimental"] }
|
||||
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"
|
||||
|
||||
[build-dependencies]
|
||||
embuild = "0.33"
|
||||
3
crates/client-esp32/build.rs
Normal file
3
crates/client-esp32/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
embuild::espidf::sysenv::output();
|
||||
}
|
||||
2
crates/client-esp32/rust-toolchain.toml
Normal file
2
crates/client-esp32/rust-toolchain.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "esp"
|
||||
16
crates/client-esp32/sdkconfig.defaults
Normal file
16
crates/client-esp32/sdkconfig.defaults
Normal file
@@ -0,0 +1,16 @@
|
||||
# K-Frame ESP32 firmware config
|
||||
|
||||
# WiFi
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
|
||||
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
|
||||
|
||||
# Task stack sizes
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=8192
|
||||
|
||||
# SPI
|
||||
CONFIG_SPI_MASTER_IN_IRAM=y
|
||||
|
||||
# Use single large app partition (no OTA)
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||
124
crates/client-esp32/src/adapters/display.rs
Normal file
124
crates/client-esp32/src/adapters/display.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use client_domain::{BoundingBox, DisplayPort};
|
||||
use embedded_graphics::{
|
||||
mono_font::{ascii::FONT_6X10, ascii::FONT_10X20, MonoTextStyle},
|
||||
pixelcolor::Rgb565,
|
||||
prelude::*,
|
||||
primitives::{PrimitiveStyle, Rectangle},
|
||||
text::Text,
|
||||
};
|
||||
use embedded_text::{TextBox, style::TextBoxStyleBuilder};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DisplayError {
|
||||
Draw(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DisplayError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DisplayError::Draw(e) => write!(f, "draw: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Esp32DisplayAdapter {
|
||||
inner: Box<dyn ErasedDisplay>,
|
||||
}
|
||||
|
||||
trait ErasedDisplay {
|
||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>;
|
||||
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), DisplayError>;
|
||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError>;
|
||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>;
|
||||
fn flush(&mut self) -> Result<(), DisplayError>;
|
||||
}
|
||||
|
||||
impl<D> ErasedDisplay for D
|
||||
where
|
||||
D: DrawTarget<Color = Rgb565>,
|
||||
D::Error: std::fmt::Debug,
|
||||
{
|
||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> {
|
||||
Rectangle::new(
|
||||
Point::new(bounds.x as i32, bounds.y as i32),
|
||||
Size::new(bounds.width as u32, bounds.height as u32),
|
||||
)
|
||||
.into_styled(PrimitiveStyle::with_fill(Rgb565::BLACK))
|
||||
.draw(self)
|
||||
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_text(&mut self, text: &str, _x: u16, _y: u16, bounds: BoundingBox) -> Result<(), DisplayError> {
|
||||
let style = MonoTextStyle::new(&FONT_6X10, Rgb565::WHITE);
|
||||
let textbox_style = TextBoxStyleBuilder::new().build();
|
||||
let rect = Rectangle::new(
|
||||
Point::new(bounds.x as i32, bounds.y as i32),
|
||||
Size::new(bounds.width as u32, bounds.height as u32),
|
||||
);
|
||||
TextBox::with_textbox_style(text, rect, style, textbox_style)
|
||||
.draw(self)
|
||||
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError> {
|
||||
let style = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE);
|
||||
let icon_char = match icon {
|
||||
"sunny" | "clear" => "*",
|
||||
"cloud_rain" | "rain" => "~",
|
||||
"cloud" | "cloudy" => "=",
|
||||
"dollar" | "money" => "$",
|
||||
"music" | "note" => "#",
|
||||
_ => "?",
|
||||
};
|
||||
Text::new(icon_char, Point::new(x as i32, y as i32 + 20), style)
|
||||
.draw(self)
|
||||
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> {
|
||||
self.clear_region(bounds)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<(), DisplayError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Esp32DisplayAdapter {
|
||||
pub fn new<D>(display: D) -> Self
|
||||
where
|
||||
D: DrawTarget<Color = Rgb565> + 'static,
|
||||
D::Error: std::fmt::Debug,
|
||||
{
|
||||
Self {
|
||||
inner: Box::new(display),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DisplayPort for Esp32DisplayAdapter {
|
||||
type Error = DisplayError;
|
||||
|
||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||
self.inner.clear_region(bounds)
|
||||
}
|
||||
|
||||
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||
self.inner.draw_text(text, x, y, bounds)
|
||||
}
|
||||
|
||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> {
|
||||
self.inner.draw_icon(icon, x, y)
|
||||
}
|
||||
|
||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||
self.inner.fill_background(bounds)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<(), Self::Error> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
2
crates/client-esp32/src/adapters/mod.rs
Normal file
2
crates/client-esp32/src/adapters/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod display;
|
||||
pub mod network;
|
||||
85
crates/client-esp32/src/adapters/network.rs
Normal file
85
crates/client-esp32/src/adapters/network.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use client_domain::NetworkPort;
|
||||
use protocol::MAX_FRAME_SIZE;
|
||||
use crate::config::NET_READ_TIMEOUT;
|
||||
use log::info;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NetworkError {
|
||||
Io(std::io::Error),
|
||||
NotConnected,
|
||||
FrameTooLarge(usize),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NetworkError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
NetworkError::Io(e) => write!(f, "io: {e}"),
|
||||
NetworkError::NotConnected => write!(f, "not connected"),
|
||||
NetworkError::FrameTooLarge(n) => write!(f, "frame too large: {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Esp32Network {
|
||||
stream: Option<TcpStream>,
|
||||
}
|
||||
|
||||
impl Esp32Network {
|
||||
pub fn new() -> Self {
|
||||
Self { stream: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkPort for Esp32Network {
|
||||
type Error = NetworkError;
|
||||
|
||||
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
|
||||
info!("TCP connecting to {addr}...");
|
||||
let stream = TcpStream::connect(addr).map_err(NetworkError::Io)?;
|
||||
stream.set_nonblocking(true).map_err(NetworkError::Io)?;
|
||||
stream.set_read_timeout(Some(NET_READ_TIMEOUT)).map_err(NetworkError::Io)?;
|
||||
self.stream = Some(stream);
|
||||
info!("TCP connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) -> Result<(), Self::Error> {
|
||||
self.stream = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(&mut self, data: &[u8]) -> Result<(), Self::Error> {
|
||||
let stream = self.stream.as_mut().ok_or(NetworkError::NotConnected)?;
|
||||
stream.write_all(data).map_err(NetworkError::Io)
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
let stream = self.stream.as_mut().ok_or(NetworkError::NotConnected)?;
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
match stream.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => return Ok(None),
|
||||
Err(e) => return Err(NetworkError::Io(e)),
|
||||
}
|
||||
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
return Err(NetworkError::FrameTooLarge(len));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.set_nonblocking(false).map_err(NetworkError::Io)?;
|
||||
stream.read_exact(&mut payload).map_err(NetworkError::Io)?;
|
||||
stream.set_nonblocking(true).map_err(NetworkError::Io)?;
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.stream.is_some()
|
||||
}
|
||||
}
|
||||
22
crates/client-esp32/src/config.rs
Normal file
22
crates/client-esp32/src/config.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::time::Duration;
|
||||
use esp_idf_hal::units::Hertz;
|
||||
use client_domain::BoundingBox;
|
||||
|
||||
pub const SCREEN_WIDTH: u16 = 320;
|
||||
pub const SCREEN_HEIGHT: u16 = 240;
|
||||
pub const SCREEN: BoundingBox = BoundingBox {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
};
|
||||
|
||||
pub const SPI_BAUDRATE: Hertz = Hertz(26_000_000);
|
||||
pub const SPI_BUFFER_SIZE: usize = 512;
|
||||
|
||||
pub const NET_THREAD_STACK_SIZE: usize = 8192;
|
||||
pub const NET_READ_TIMEOUT: Duration = Duration::from_millis(10);
|
||||
pub const NET_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
pub const NET_RECONNECT_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
pub const RENDER_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
58
crates/client-esp32/src/hal/display.rs
Normal file
58
crates/client-esp32/src/hal/display.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use esp_idf_hal::delay::{Delay, Ets};
|
||||
use esp_idf_hal::gpio::{AnyIOPin, AnyOutputPin, PinDriver};
|
||||
use esp_idf_hal::spi::{SpiDeviceDriver, SpiDriver, SpiDriverConfig, SPI2, config::Config as SpiConfig};
|
||||
use mipidsi::{Builder, models::ILI9341Rgb565, options::{Orientation, Rotation}, interface::SpiInterface};
|
||||
use embedded_graphics::pixelcolor::Rgb565;
|
||||
use embedded_graphics::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::config::{SCREEN_WIDTH, SCREEN_HEIGHT, SPI_BAUDRATE, SPI_BUFFER_SIZE};
|
||||
use crate::adapters::display::Esp32DisplayAdapter;
|
||||
|
||||
pub struct DisplayHardware<'d> {
|
||||
pub spi: SPI2<'d>,
|
||||
pub sclk: AnyOutputPin<'d>,
|
||||
pub mosi: AnyOutputPin<'d>,
|
||||
pub cs: AnyOutputPin<'d>,
|
||||
pub dc: AnyOutputPin<'d>,
|
||||
pub rst: AnyOutputPin<'d>,
|
||||
}
|
||||
|
||||
pub fn init(hw: DisplayHardware<'static>) -> Esp32DisplayAdapter {
|
||||
let spi_driver = SpiDriver::new(
|
||||
hw.spi, hw.sclk, hw.mosi,
|
||||
None::<AnyIOPin>,
|
||||
&SpiDriverConfig::new(),
|
||||
).expect("SPI driver init failed");
|
||||
|
||||
let spi_config = SpiConfig::new().baudrate(SPI_BAUDRATE);
|
||||
let spi_device = SpiDeviceDriver::new(spi_driver, Some(hw.cs), &spi_config)
|
||||
.expect("SPI device init failed");
|
||||
|
||||
let dc_pin = PinDriver::output(hw.dc).expect("DC pin failed");
|
||||
let mut rst_pin = PinDriver::output(hw.rst).expect("RST pin failed");
|
||||
|
||||
info!("Hardware reset...");
|
||||
rst_pin.set_high().unwrap();
|
||||
Ets::delay_ms(10);
|
||||
rst_pin.set_low().unwrap();
|
||||
Ets::delay_ms(10);
|
||||
rst_pin.set_high().unwrap();
|
||||
Ets::delay_ms(120);
|
||||
|
||||
// Keep RST pin high — dropping PinDriver may release the GPIO
|
||||
let rst_pin: &'static mut _ = Box::leak(Box::new(rst_pin));
|
||||
|
||||
let buf: &'static mut [u8; SPI_BUFFER_SIZE] = Box::leak(Box::new([0u8; SPI_BUFFER_SIZE]));
|
||||
let di = SpiInterface::new(spi_device, dc_pin, buf);
|
||||
|
||||
info!("Initializing ILI9341...");
|
||||
let mut raw_display = Builder::new(ILI9341Rgb565, di)
|
||||
.display_size(240, 320)
|
||||
.orientation(Orientation { rotation: Rotation::Deg90, mirrored: true })
|
||||
.init(&mut Delay::new_default())
|
||||
.expect("Display init failed");
|
||||
|
||||
raw_display.clear(Rgb565::BLACK).unwrap();
|
||||
Esp32DisplayAdapter::new(raw_display)
|
||||
}
|
||||
2
crates/client-esp32/src/hal/mod.rs
Normal file
2
crates/client-esp32/src/hal/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod display;
|
||||
pub mod wifi;
|
||||
38
crates/client-esp32/src/hal/wifi.rs
Normal file
38
crates/client-esp32/src/hal/wifi.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use esp_idf_hal::modem::Modem;
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use esp_idf_svc::wifi::{
|
||||
AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi,
|
||||
};
|
||||
use log::info;
|
||||
|
||||
pub fn init<'d>(
|
||||
modem: Modem<'d>,
|
||||
sysloop: EspSystemEventLoop,
|
||||
nvs: EspDefaultNvsPartition,
|
||||
ssid: &str,
|
||||
password: &str,
|
||||
) -> Result<BlockingWifi<EspWifi<'d>>, String> {
|
||||
let esp_wifi = EspWifi::new(modem, sysloop.clone(), Some(nvs))
|
||||
.map_err(|e| format!("wifi new: {e:?}"))?;
|
||||
|
||||
let mut wifi = BlockingWifi::wrap(esp_wifi, sysloop)
|
||||
.map_err(|e| format!("wifi wrap: {e:?}"))?;
|
||||
|
||||
let config = Configuration::Client(ClientConfiguration {
|
||||
ssid: ssid.try_into().unwrap(),
|
||||
password: password.try_into().unwrap(),
|
||||
auth_method: AuthMethod::WPA2Personal,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
wifi.set_configuration(&config).map_err(|e| format!("wifi config: {e:?}"))?;
|
||||
wifi.start().map_err(|e| format!("wifi start: {e:?}"))?;
|
||||
|
||||
info!("WiFi started, connecting...");
|
||||
wifi.connect().map_err(|e| format!("wifi connect: {e:?}"))?;
|
||||
wifi.wait_netif_up().map_err(|e| format!("wifi netif: {e:?}"))?;
|
||||
|
||||
info!("WiFi connected");
|
||||
Ok(wifi)
|
||||
}
|
||||
45
crates/client-esp32/src/main.rs
Normal file
45
crates/client-esp32/src/main.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
mod adapters;
|
||||
mod config;
|
||||
mod hal;
|
||||
mod tasks;
|
||||
|
||||
use client_domain::{BoundingBox, DisplayPort};
|
||||
use esp_idf_hal::peripherals::Peripherals;
|
||||
use log::info;
|
||||
|
||||
fn main() {
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
info!("=== K-Frame ESP32 ===");
|
||||
|
||||
let peripherals = Peripherals::take().unwrap();
|
||||
|
||||
let mut display = hal::display::init(hal::display::DisplayHardware {
|
||||
spi: peripherals.spi2,
|
||||
sclk: peripherals.pins.gpio18.into(),
|
||||
mosi: peripherals.pins.gpio23.into(),
|
||||
cs: peripherals.pins.gpio26.into(),
|
||||
dc: peripherals.pins.gpio21.into(),
|
||||
rst: peripherals.pins.gpio22.into(),
|
||||
});
|
||||
info!("Display initialized");
|
||||
|
||||
display.fill_background(config::SCREEN).unwrap();
|
||||
display.draw_text(
|
||||
"K-Frame",
|
||||
10, 10,
|
||||
BoundingBox::new(10, 10, 220, 40),
|
||||
).unwrap();
|
||||
display.draw_text(
|
||||
"Display test OK",
|
||||
10, 60,
|
||||
BoundingBox::new(10, 60, 220, 40),
|
||||
).unwrap();
|
||||
display.flush().unwrap();
|
||||
|
||||
info!("Display test complete — looping forever");
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
2
crates/client-esp32/src/tasks/mod.rs
Normal file
2
crates/client-esp32/src/tasks/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod network;
|
||||
pub mod render;
|
||||
50
crates/client-esp32/src/tasks/network.rs
Normal file
50
crates/client-esp32/src/tasks/network.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use client_domain::NetworkPort;
|
||||
use protocol::{ServerMessage, decode_server_message};
|
||||
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>) {
|
||||
thread::Builder::new()
|
||||
.stack_size(NET_THREAD_STACK_SIZE)
|
||||
.name("net".into())
|
||||
.spawn(move || run(server_addr, tx))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
crates/client-esp32/src/tasks/render.rs
Normal file
46
crates/client-esp32/src/tasks/render.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::sync::mpsc;
|
||||
use client_domain::{BoundingBox, DisplayPort};
|
||||
use client_application::ClientApp;
|
||||
use protocol::ServerMessage;
|
||||
use crate::config::RENDER_POLL_INTERVAL;
|
||||
use crate::adapters::display::Esp32DisplayAdapter;
|
||||
use log::*;
|
||||
|
||||
pub fn run(
|
||||
screen: BoundingBox,
|
||||
mut display: Esp32DisplayAdapter,
|
||||
rx: mpsc::Receiver<ServerMessage>,
|
||||
) {
|
||||
let mut app = ClientApp::new(screen);
|
||||
info!("Render loop started");
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(RENDER_POLL_INTERVAL) {
|
||||
Ok(msg) => {
|
||||
let repaints = app.handle_message(msg);
|
||||
for cmd in &repaints {
|
||||
display.clear_region(cmd.bounds).unwrap();
|
||||
|
||||
for kv in &cmd.state.data {
|
||||
if let protocol::WireValue::String(s) = &kv.value {
|
||||
display.draw_text(
|
||||
&format!("{}: {s}", kv.key),
|
||||
cmd.bounds.x,
|
||||
cmd.bounds.y,
|
||||
cmd.bounds,
|
||||
).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !repaints.is_empty() {
|
||||
display.flush().unwrap();
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
error!("Network thread died");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
crates/domain/Cargo.toml
Normal file
8
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
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,
|
||||
}
|
||||
51
crates/domain/tests/data_source_tests.rs
Normal file
51
crates/domain/tests/data_source_tests.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::time::Duration;
|
||||
use domain::{DataSource, DataSourceConfig, DataSourceType, DataSourceValidationError};
|
||||
|
||||
fn make_source(source_type: DataSourceType, url: Option<&str>, poll: Duration) -> DataSource {
|
||||
DataSource {
|
||||
id: 1,
|
||||
name: "test".into(),
|
||||
source_type,
|
||||
poll_interval: poll,
|
||||
config: DataSourceConfig {
|
||||
url: url.map(Into::into),
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_json_requires_url() {
|
||||
let source = make_source(DataSourceType::HttpJson, None, Duration::from_secs(60));
|
||||
let errors = source.validate();
|
||||
assert!(errors.contains(&DataSourceValidationError::UrlRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_does_not_allow_poll_interval() {
|
||||
let source = make_source(DataSourceType::Webhook, None, Duration::from_secs(60));
|
||||
let errors = source.validate();
|
||||
assert!(errors.contains(&DataSourceValidationError::PollIntervalNotAllowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_with_zero_interval_is_valid() {
|
||||
let source = make_source(DataSourceType::Webhook, None, Duration::ZERO);
|
||||
let errors = source.validate();
|
||||
assert!(errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_based_source_requires_nonzero_interval() {
|
||||
let source = make_source(DataSourceType::Weather, Some("https://api.weather.com"), Duration::ZERO);
|
||||
let errors = source.validate();
|
||||
assert!(errors.contains(&DataSourceValidationError::PollIntervalRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_poll_source_has_no_errors() {
|
||||
let source = make_source(DataSourceType::Rss, Some("https://feed.example.com"), Duration::from_secs(300));
|
||||
let errors = source.validate();
|
||||
assert!(errors.is_empty());
|
||||
}
|
||||
33
crates/domain/tests/key_mapping_tests.rs
Normal file
33
crates/domain/tests/key_mapping_tests.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::collections::BTreeMap;
|
||||
use domain::{KeyMapping, Value};
|
||||
|
||||
#[test]
|
||||
fn extracts_value_at_path_and_renames_key() {
|
||||
let mapping = KeyMapping {
|
||||
source_path: "$.main.temp".into(),
|
||||
target_key: "temperature".into(),
|
||||
};
|
||||
|
||||
let raw = Value::Object(BTreeMap::from([
|
||||
("main".into(), Value::Object(BTreeMap::from([
|
||||
("temp".into(), Value::Number(5.4)),
|
||||
]))),
|
||||
]));
|
||||
|
||||
let result = mapping.extract(&raw);
|
||||
assert_eq!(result, Some(("temperature".into(), Value::Number(5.4))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_path_does_not_match() {
|
||||
let mapping = KeyMapping {
|
||||
source_path: "$.missing.path".into(),
|
||||
target_key: "value".into(),
|
||||
};
|
||||
|
||||
let raw = Value::Object(BTreeMap::from([
|
||||
("other".into(), Value::Number(1.0)),
|
||||
]));
|
||||
|
||||
assert_eq!(mapping.extract(&raw), None);
|
||||
}
|
||||
66
crates/domain/tests/layout_tests.rs
Normal file
66
crates/domain/tests/layout_tests.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::collections::BTreeSet;
|
||||
use domain::{
|
||||
ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing,
|
||||
WidgetId,
|
||||
};
|
||||
|
||||
fn leaf(id: WidgetId) -> LayoutChild {
|
||||
LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: LayoutNode::Leaf(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(children: Vec<LayoutChild>) -> LayoutNode {
|
||||
LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_returns_empty_when_all_widgets_exist() {
|
||||
let layout = Layout { root: row(vec![leaf(1), leaf(2)]) };
|
||||
let known = BTreeSet::from([1, 2]);
|
||||
assert!(layout.validate(&known).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_reports_unknown_widget_ids() {
|
||||
let layout = Layout { root: row(vec![leaf(1), leaf(99)]) };
|
||||
let known = BTreeSet::from([1]);
|
||||
let errors = layout.validate(&known);
|
||||
assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(99)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_reports_empty_container() {
|
||||
let layout = Layout { root: row(vec![]) };
|
||||
let known = BTreeSet::new();
|
||||
let errors = layout.validate(&known);
|
||||
assert_eq!(errors, vec![LayoutValidationError::EmptyContainer]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_checks_nested_containers() {
|
||||
let inner = LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: row(vec![leaf(1), leaf(42)]),
|
||||
};
|
||||
let layout = Layout { root: row(vec![inner, leaf(2)]) };
|
||||
let known = BTreeSet::from([1, 2]);
|
||||
let errors = layout.validate(&known);
|
||||
assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(42)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_ids_collects_all_leaf_ids() {
|
||||
let inner = LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: row(vec![leaf(3)]),
|
||||
};
|
||||
let layout = Layout { root: row(vec![leaf(1), inner, leaf(2)]) };
|
||||
assert_eq!(layout.widget_ids(), BTreeSet::from([1, 2, 3]));
|
||||
}
|
||||
77
crates/domain/tests/value_tests.rs
Normal file
77
crates/domain/tests/value_tests.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::collections::BTreeMap;
|
||||
use domain::Value;
|
||||
|
||||
#[test]
|
||||
fn estimated_size_of_string_is_its_byte_length() {
|
||||
let v = Value::String("hello".into());
|
||||
assert_eq!(v.estimated_size(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_size_of_number_is_8_bytes() {
|
||||
assert_eq!(Value::Number(3.14).estimated_size(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_size_of_null_and_bool_is_1() {
|
||||
assert_eq!(Value::Null.estimated_size(), 1);
|
||||
assert_eq!(Value::Bool(true).estimated_size(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_size_of_nested_structure_sums_recursively() {
|
||||
let v = Value::Object(BTreeMap::from([
|
||||
("key".into(), Value::String("value".into())),
|
||||
("num".into(), Value::Number(1.0)),
|
||||
]));
|
||||
assert_eq!(v.estimated_size(), 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_size_of_array_sums_elements() {
|
||||
let v = Value::Array(vec![
|
||||
Value::String("abc".into()),
|
||||
Value::Number(1.0),
|
||||
]);
|
||||
assert_eq!(v.estimated_size(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_path_returns_none_for_missing_key() {
|
||||
let data = Value::Object(BTreeMap::from([
|
||||
("main".into(), Value::Number(1.0)),
|
||||
]));
|
||||
assert_eq!(data.get_path("$.missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_path_returns_none_when_traversing_non_object() {
|
||||
let data = Value::Object(BTreeMap::from([
|
||||
("temp".into(), Value::Number(5.4)),
|
||||
]));
|
||||
assert_eq!(data.get_path("$.temp.nested"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_path_accesses_array_by_index() {
|
||||
let data = Value::Object(BTreeMap::from([
|
||||
("items".into(), Value::Array(vec![
|
||||
Value::String("first".into()),
|
||||
Value::String("second".into()),
|
||||
])),
|
||||
]));
|
||||
assert_eq!(
|
||||
data.get_path("$.items[1]"),
|
||||
Some(&Value::String("second".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_path_traverses_nested_object() {
|
||||
let data = Value::Object(BTreeMap::from([
|
||||
("main".into(), Value::Object(BTreeMap::from([
|
||||
("temp".into(), Value::Number(5.4)),
|
||||
]))),
|
||||
]));
|
||||
assert_eq!(data.get_path("$.main.temp"), Some(&Value::Number(5.4)));
|
||||
}
|
||||
110
crates/domain/tests/widget_tests.rs
Normal file
110
crates/domain/tests/widget_tests.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::collections::BTreeMap;
|
||||
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig};
|
||||
|
||||
#[test]
|
||||
fn extract_applies_all_mappings_to_produce_widget_state() {
|
||||
let config = WidgetConfig {
|
||||
id: 1,
|
||||
name: "weather".into(),
|
||||
display_hint: DisplayHint::IconValue,
|
||||
data_source_id: 1,
|
||||
mappings: vec![
|
||||
KeyMapping { source_path: "$.main.temp".into(), target_key: "temperature".into() },
|
||||
KeyMapping { source_path: "$.weather[0].icon".into(), target_key: "icon".into() },
|
||||
],
|
||||
max_data_size: 2048,
|
||||
};
|
||||
|
||||
let raw = Value::Object(BTreeMap::from([
|
||||
("main".into(), Value::Object(BTreeMap::from([
|
||||
("temp".into(), Value::Number(5.4)),
|
||||
]))),
|
||||
("weather".into(), Value::Array(vec![
|
||||
Value::Object(BTreeMap::from([
|
||||
("icon".into(), Value::String("cloud_rain".into())),
|
||||
])),
|
||||
])),
|
||||
]));
|
||||
|
||||
let state = config.extract(&raw);
|
||||
|
||||
assert_eq!(state.data.get("temperature"), Some(&Value::Number(5.4)));
|
||||
assert_eq!(state.data.get("icon"), Some(&Value::String("cloud_rain".into())));
|
||||
assert_eq!(state.error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_truncates_string_values_exceeding_max_data_size() {
|
||||
let long_text = "a".repeat(3000);
|
||||
let config = WidgetConfig {
|
||||
id: 1,
|
||||
name: "news".into(),
|
||||
display_hint: DisplayHint::TextBlock,
|
||||
data_source_id: 1,
|
||||
mappings: vec![
|
||||
KeyMapping { source_path: "$.text".into(), target_key: "body".into() },
|
||||
],
|
||||
max_data_size: 100,
|
||||
};
|
||||
|
||||
let raw = Value::Object(BTreeMap::from([
|
||||
("text".into(), Value::String(long_text)),
|
||||
]));
|
||||
|
||||
let state = config.extract(&raw);
|
||||
match state.data.get("body") {
|
||||
Some(Value::String(s)) => assert!(s.len() <= 100),
|
||||
other => panic!("expected truncated string, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_respects_max_data_size_across_total_state() {
|
||||
let config = WidgetConfig {
|
||||
id: 1,
|
||||
name: "big".into(),
|
||||
display_hint: DisplayHint::TextBlock,
|
||||
data_source_id: 1,
|
||||
mappings: vec![
|
||||
KeyMapping { source_path: "$.a".into(), target_key: "a".into() },
|
||||
KeyMapping { source_path: "$.b".into(), target_key: "b".into() },
|
||||
KeyMapping { source_path: "$.c".into(), target_key: "c".into() },
|
||||
],
|
||||
max_data_size: 50,
|
||||
};
|
||||
|
||||
let raw = Value::Object(BTreeMap::from([
|
||||
("a".into(), Value::String("x".repeat(20))),
|
||||
("b".into(), Value::String("y".repeat(20))),
|
||||
("c".into(), Value::String("z".repeat(20))),
|
||||
]));
|
||||
|
||||
let state = config.extract(&raw);
|
||||
let total: usize = state.data.values().map(|v| v.estimated_size()).sum();
|
||||
assert!(total <= 50, "total size {total} exceeds max 50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_skips_mappings_that_dont_match() {
|
||||
let config = WidgetConfig {
|
||||
id: 1,
|
||||
name: "weather".into(),
|
||||
display_hint: DisplayHint::IconValue,
|
||||
data_source_id: 1,
|
||||
mappings: vec![
|
||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
||||
KeyMapping { source_path: "$.missing".into(), target_key: "gone".into() },
|
||||
],
|
||||
max_data_size: 2048,
|
||||
};
|
||||
|
||||
let raw = Value::Object(BTreeMap::from([
|
||||
("temp".into(), Value::Number(5.4)),
|
||||
]));
|
||||
|
||||
let state = config.extract(&raw);
|
||||
|
||||
assert_eq!(state.data.len(), 1);
|
||||
assert_eq!(state.data.get("temperature"), Some(&Value::Number(5.4)));
|
||||
assert_eq!(state.data.get("gone"), None);
|
||||
}
|
||||
11
crates/protocol/Cargo.toml
Normal file
11
crates/protocol/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
54
crates/protocol/src/frame.rs
Normal file
54
crates/protocol/src/frame.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use super::wire::{WireLayoutNode, WireWidgetState, WireDisplayHint};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidgetDescriptor {
|
||||
pub id: u16,
|
||||
pub display_hint: WireDisplayHint,
|
||||
pub state: WireWidgetState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ServerMessage {
|
||||
ScreenUpdate {
|
||||
layout: WireLayoutNode,
|
||||
widgets: Vec<WidgetDescriptor>,
|
||||
},
|
||||
DataUpdate {
|
||||
widgets: Vec<WidgetDescriptor>,
|
||||
},
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
pub const MAX_FRAME_SIZE: usize = 32 * 1024;
|
||||
|
||||
pub fn encode(msg: &ServerMessage) -> Result<Vec<u8>, postcard::Error> {
|
||||
let payload = postcard::to_allocvec(msg)?;
|
||||
let len = (payload.len() as u32).to_be_bytes();
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.extend_from_slice(&len);
|
||||
frame.extend_from_slice(&payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
pub fn decode_server_message(payload: &[u8]) -> Result<ServerMessage, postcard::Error> {
|
||||
postcard::from_bytes(payload)
|
||||
}
|
||||
|
||||
pub fn encode_client(msg: &ClientMessage) -> Result<Vec<u8>, postcard::Error> {
|
||||
let payload = postcard::to_allocvec(msg)?;
|
||||
let len = (payload.len() as u32).to_be_bytes();
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.extend_from_slice(&len);
|
||||
frame.extend_from_slice(&payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
pub fn decode_client_message(payload: &[u8]) -> Result<ClientMessage, postcard::Error> {
|
||||
postcard::from_bytes(payload)
|
||||
}
|
||||
13
crates/protocol/src/lib.rs
Normal file
13
crates/protocol/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod wire;
|
||||
mod frame;
|
||||
|
||||
pub use wire::{
|
||||
WireValue, WireWidgetState, WireWidgetError, WireDisplayHint,
|
||||
WireLayoutNode, WireContainerNode, WireLayoutChild, WireDirection, WireSizing,
|
||||
WireKeyValue,
|
||||
};
|
||||
pub use frame::{
|
||||
ServerMessage, ClientMessage, WidgetDescriptor,
|
||||
encode, decode_server_message, encode_client, decode_client_message,
|
||||
MAX_FRAME_SIZE,
|
||||
};
|
||||
232
crates/protocol/src/wire.rs
Normal file
232
crates/protocol/src/wire.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use std::collections::BTreeMap;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use domain::value_objects::{
|
||||
ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value,
|
||||
WidgetError, WidgetState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WireValue {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Array(Vec<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,
|
||||
pub value: WireValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WireWidgetState {
|
||||
pub data: Vec<WireKeyValue>,
|
||||
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 WireDisplayHint {
|
||||
IconValue,
|
||||
TextBlock,
|
||||
KeyValue,
|
||||
}
|
||||
|
||||
impl From<&DisplayHint> for WireDisplayHint {
|
||||
fn from(h: &DisplayHint) -> Self {
|
||||
match h {
|
||||
DisplayHint::IconValue => WireDisplayHint::IconValue,
|
||||
DisplayHint::TextBlock => WireDisplayHint::TextBlock,
|
||||
DisplayHint::KeyValue => WireDisplayHint::KeyValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WireDisplayHint> for DisplayHint {
|
||||
fn from(w: WireDisplayHint) -> Self {
|
||||
match w {
|
||||
WireDisplayHint::IconValue => DisplayHint::IconValue,
|
||||
WireDisplayHint::TextBlock => DisplayHint::TextBlock,
|
||||
WireDisplayHint::KeyValue => DisplayHint::KeyValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 struct WireContainerNode {
|
||||
pub direction: WireDirection,
|
||||
pub gap: u8,
|
||||
pub padding: u8,
|
||||
pub children: Vec<WireLayoutChild>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WireLayoutChild {
|
||||
pub sizing: WireSizing,
|
||||
pub node: WireLayoutNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
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,
|
||||
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,
|
||||
children: c.children.into_iter().map(|ch| LayoutChild {
|
||||
sizing: ch.sizing.into(),
|
||||
node: ch.node.into(),
|
||||
}).collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
82
crates/protocol/tests/conversion_tests.rs
Normal file
82
crates/protocol/tests/conversion_tests.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::collections::BTreeMap;
|
||||
use domain::{
|
||||
Value, WidgetState, WidgetError, DisplayHint,
|
||||
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||
};
|
||||
use protocol::{
|
||||
WireValue, WireWidgetState, WireWidgetError, WireDisplayHint,
|
||||
WireLayoutNode, WireContainerNode, WireLayoutChild, WireDirection, WireSizing,
|
||||
WireKeyValue,
|
||||
};
|
||||
|
||||
#[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,
|
||||
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,
|
||||
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::IconValue, DisplayHint::TextBlock, DisplayHint::KeyValue] {
|
||||
let wire: WireDisplayHint = (&hint).into();
|
||||
let roundtripped: DisplayHint = wire.into();
|
||||
assert_eq!(hint, roundtripped);
|
||||
}
|
||||
}
|
||||
94
crates/protocol/tests/round_trip_tests.rs
Normal file
94
crates/protocol/tests/round_trip_tests.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use protocol::{
|
||||
ServerMessage, ClientMessage, WidgetDescriptor,
|
||||
WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild,
|
||||
WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue,
|
||||
encode, decode_server_message, encode_client, decode_client_message,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn screen_update_round_trips() {
|
||||
let msg = ServerMessage::ScreenUpdate {
|
||||
layout: WireLayoutNode::Container(WireContainerNode {
|
||||
direction: WireDirection::Row,
|
||||
gap: 4,
|
||||
padding: 2,
|
||||
children: vec![
|
||||
WireLayoutChild {
|
||||
sizing: WireSizing::Flex(1),
|
||||
node: WireLayoutNode::Leaf(1),
|
||||
},
|
||||
WireLayoutChild {
|
||||
sizing: WireSizing::Fixed(80),
|
||||
node: WireLayoutNode::Leaf(2),
|
||||
},
|
||||
],
|
||||
}),
|
||||
widgets: vec![
|
||||
WidgetDescriptor {
|
||||
id: 1,
|
||||
display_hint: WireDisplayHint::IconValue,
|
||||
state: WireWidgetState {
|
||||
data: vec![
|
||||
WireKeyValue { key: "temperature".into(), value: WireValue::String("5.4°C".into()) },
|
||||
WireKeyValue { key: "icon".into(), value: WireValue::String("cloud_rain".into()) },
|
||||
],
|
||||
error: None,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let frame = encode(&msg).unwrap();
|
||||
let payload = &frame[4..];
|
||||
let decoded = decode_server_message(payload).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_round_trips() {
|
||||
let msg = ServerMessage::DataUpdate {
|
||||
widgets: vec![
|
||||
WidgetDescriptor {
|
||||
id: 3,
|
||||
display_hint: WireDisplayHint::TextBlock,
|
||||
state: WireWidgetState {
|
||||
data: vec![
|
||||
WireKeyValue { key: "body".into(), value: WireValue::String("Breaking news...".into()) },
|
||||
],
|
||||
error: None,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let frame = encode(&msg).unwrap();
|
||||
let payload = &frame[4..];
|
||||
let decoded = decode_server_message(payload).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_heartbeat_round_trips() {
|
||||
let msg = ServerMessage::Heartbeat;
|
||||
let frame = encode(&msg).unwrap();
|
||||
let payload = &frame[4..];
|
||||
let decoded = decode_server_message(payload).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_heartbeat_round_trips() {
|
||||
let msg = ClientMessage::Heartbeat;
|
||||
let frame = encode_client(&msg).unwrap();
|
||||
let payload = &frame[4..];
|
||||
let decoded = decode_client_message(payload).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_has_correct_length_prefix() {
|
||||
let msg = ServerMessage::Heartbeat;
|
||||
let frame = encode(&msg).unwrap();
|
||||
let len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
|
||||
assert_eq!(len, frame.len() - 4);
|
||||
}
|
||||
Reference in New Issue
Block a user