diff --git a/Cargo.toml b/Cargo.toml index e13231a..dfde743 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/infinity_config", "crates/infinity_protocol", "crates/infinity_host", + "crates/infinity_host_ui", ] resolver = "2" @@ -14,8 +15,8 @@ authors = ["Jan", "OpenAI Codex"] [workspace.dependencies] clap = { version = "4.5", features = ["derive"] } +eframe = "0.29" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" toml = "0.8" - diff --git a/README.md b/README.md index 51c8ae4..e7b353a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ The repository is intentionally structured around hard separation of concerns: - `crates/infinity_config`: versioned project configuration and validation - `crates/infinity_protocol`: shared control and realtime protocol model -- `crates/infinity_host`: host-side CLI and runtime skeleton +- `crates/infinity_host`: host-side core library, CLI, mock UI service, and runtime skeleton +- `crates/infinity_host_ui`: native Rust desktop GUI vertical slice - `firmware/esp32_node`: ESP-IDF firmware skeleton with explicit driver abstraction - `docs/`: architecture, protocol, validation, deployment, testing, and acceptance artifacts - `config/`: example configuration files @@ -23,7 +24,8 @@ The current baseline is intentionally strict about unresolved hardware facts. `U 1. Install a current Rust toolchain. 2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md). 3. Start from [config/project.example.toml](config/project.example.toml). -4. Use the host CLI to validate the project config before attempting activation. +4. Start the desktop GUI with `cargo run -p infinity_host_ui`. +5. Use the host CLI to validate the project config before attempting activation. ## Docs @@ -34,4 +36,3 @@ The current baseline is intentionally strict about unresolved hardware facts. `U - [Testing](docs/testing.md) - [Acceptance Template](docs/acceptance_template.md) - [Legacy XML Reference](docs/legacy_xml_reference.md) - diff --git a/crates/infinity_host/src/control.rs b/crates/infinity_host/src/control.rs new file mode 100644 index 0000000..dc94be8 --- /dev/null +++ b/crates/infinity_host/src/control.rs @@ -0,0 +1,104 @@ +use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState}; + +#[derive(Debug, Clone, PartialEq)] +pub struct HostSnapshot { + pub backend_label: String, + pub generated_at_millis: u64, + pub global: GlobalControlSnapshot, + pub available_patterns: Vec, + pub nodes: Vec, + pub panels: Vec, + pub recent_events: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GlobalControlSnapshot { + pub blackout: bool, + pub master_brightness: f32, + pub selected_pattern: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeSnapshot { + pub node_id: String, + pub display_name: String, + pub reserved_ip: Option, + pub connection: NodeConnectionState, + pub last_contact_ms: u64, + pub error_status: Option, + pub panel_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PanelSnapshot { + pub target: PanelTarget, + pub physical_output_name: String, + pub driver_reference: String, + pub led_count: u16, + pub direction: LedDirection, + pub color_order: ColorOrder, + pub enabled: bool, + pub validation_state: ValidationState, + pub connection: NodeConnectionState, + pub last_test_trigger_ms: Option, + pub error_status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusEvent { + pub at_millis: u64, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeConnectionState { + Online, + Degraded, + Offline, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PanelTarget { + pub node_id: String, + pub panel_position: PanelPosition, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum HostCommand { + SetBlackout(bool), + SetMasterBrightness(f32), + SelectPattern(String), + TriggerPanelTest { + target: PanelTarget, + pattern: TestPatternKind, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestPatternKind { + WalkingPixel106, +} + +pub trait HostUiPort: Send + Sync { + fn snapshot(&self) -> HostSnapshot; + fn send_command(&self, command: HostCommand); +} + +impl NodeConnectionState { + pub fn label(self) -> &'static str { + match self { + Self::Online => "online", + Self::Degraded => "degraded", + Self::Offline => "offline", + } + } +} + +impl TestPatternKind { + pub fn label(self) -> &'static str { + match self { + Self::WalkingPixel106 => "walking_pixel_106", + } + } +} + diff --git a/crates/infinity_host/src/lib.rs b/crates/infinity_host/src/lib.rs new file mode 100644 index 0000000..f9ee9e4 --- /dev/null +++ b/crates/infinity_host/src/lib.rs @@ -0,0 +1,8 @@ +pub mod control; +pub mod mock; +pub mod runtime; + +pub use control::*; +pub use mock::*; +pub use runtime::*; + diff --git a/crates/infinity_host/src/main.rs b/crates/infinity_host/src/main.rs index 8255e42..aa5950e 100644 --- a/crates/infinity_host/src/main.rs +++ b/crates/infinity_host/src/main.rs @@ -1,8 +1,6 @@ -mod runtime; - use clap::{Parser, Subcommand, ValueEnum}; use infinity_config::{load_project_from_path, ValidationMode, ValidationSeverity}; -use runtime::RealtimeEngine; +use infinity_host::RealtimeEngine; use std::{path::PathBuf, process::ExitCode}; #[derive(Debug, Parser)] @@ -127,4 +125,3 @@ fn print_open_validation_points() { println!("{line}"); } } - diff --git a/crates/infinity_host/src/mock.rs b/crates/infinity_host/src/mock.rs new file mode 100644 index 0000000..ca41b4d --- /dev/null +++ b/crates/infinity_host/src/mock.rs @@ -0,0 +1,351 @@ +use crate::control::{ + GlobalControlSnapshot, HostCommand, HostSnapshot, HostUiPort, NodeConnectionState, + NodeSnapshot, PanelSnapshot, PanelTarget, StatusEvent, TestPatternKind, +}; +use infinity_config::{PanelPosition, ProjectConfig}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +const MAX_RECENT_EVENTS: usize = 10; + +#[derive(Debug)] +pub struct MockHostService { + inner: Arc>, +} + +#[derive(Debug)] +struct MockHostState { + started_at: Instant, + tick_count: u64, + snapshot: HostSnapshot, +} + +impl MockHostService { + pub fn spawn(project: ProjectConfig) -> Arc { + let service = Arc::new(Self { + inner: Arc::new(Mutex::new(MockHostState::new(project))), + }); + Self::spawn_simulation_loop(Arc::clone(&service)); + service + } + + fn spawn_simulation_loop(service: Arc) { + thread::spawn(move || loop { + thread::sleep(Duration::from_millis(250)); + if let Ok(mut state) = service.inner.lock() { + state.simulate_tick(); + } + }); + } +} + +impl HostUiPort for MockHostService { + fn snapshot(&self) -> HostSnapshot { + self.inner + .lock() + .map(|state| state.snapshot.clone()) + .unwrap_or_else(|_| HostSnapshot { + backend_label: "mock-simulation".to_string(), + generated_at_millis: 0, + global: GlobalControlSnapshot { + blackout: true, + master_brightness: 0.0, + selected_pattern: "unavailable".to_string(), + }, + available_patterns: vec!["unavailable".to_string()], + nodes: Vec::new(), + panels: Vec::new(), + recent_events: vec![StatusEvent { + at_millis: 0, + message: "snapshot unavailable because the simulation lock was poisoned" + .to_string(), + }], + }) + } + + fn send_command(&self, command: HostCommand) { + if let Ok(mut state) = self.inner.lock() { + state.apply_command(command); + } + } +} + +impl MockHostState { + fn new(project: ProjectConfig) -> Self { + let patterns = pattern_list_from_project(&project); + let selected_pattern = patterns + .first() + .cloned() + .unwrap_or_else(|| "solid_color".to_string()); + + let mut nodes = Vec::new(); + let mut panels = Vec::new(); + for node in &project.topology.nodes { + nodes.push(NodeSnapshot { + node_id: node.node_id.clone(), + display_name: node.display_name.clone(), + reserved_ip: node.network.reserved_ip.clone(), + connection: NodeConnectionState::Online, + last_contact_ms: 14, + error_status: None, + panel_count: node.outputs.len(), + }); + + for output in &node.outputs { + panels.push(PanelSnapshot { + target: PanelTarget { + node_id: node.node_id.clone(), + panel_position: output.panel_position.clone(), + }, + physical_output_name: output.physical_output_name.clone(), + driver_reference: output.driver_channel.reference.clone(), + led_count: output.led_count, + direction: output.direction.clone(), + color_order: output.color_order.clone(), + enabled: output.enabled, + validation_state: output.validation_state.clone(), + connection: NodeConnectionState::Online, + last_test_trigger_ms: None, + error_status: None, + }); + } + } + + let mut state = Self { + started_at: Instant::now(), + tick_count: 0, + snapshot: HostSnapshot { + backend_label: "mock-simulation".to_string(), + generated_at_millis: 0, + global: GlobalControlSnapshot { + blackout: false, + master_brightness: 0.20, + selected_pattern, + }, + available_patterns: patterns, + nodes, + panels, + recent_events: Vec::new(), + }, + }; + state.push_event("mock backend started".to_string()); + state.simulate_tick(); + state + } + + fn simulate_tick(&mut self) { + self.tick_count += 1; + self.snapshot.generated_at_millis = self.elapsed_millis(); + + let previous_states: BTreeMap<_, _> = self + .snapshot + .nodes + .iter() + .map(|node| (node.node_id.clone(), node.connection)) + .collect(); + let mut transition_messages = Vec::new(); + + for (index, node) in self.snapshot.nodes.iter_mut().enumerate() { + let connection = simulated_connection_state(index, self.tick_count); + node.connection = connection; + node.last_contact_ms = simulated_last_contact_ms(index, self.tick_count, connection); + node.error_status = simulated_error_status(connection); + + if previous_states + .get(&node.node_id) + .copied() + .unwrap_or(NodeConnectionState::Offline) + != connection + { + transition_messages.push(format!( + "{} is now {}", + node.display_name, + connection.label() + )); + } + } + + for message in transition_messages { + self.push_event(message); + } + + let node_states: BTreeMap<_, _> = self + .snapshot + .nodes + .iter() + .map(|node| (node.node_id.clone(), (node.connection, node.error_status.clone()))) + .collect(); + + for panel in &mut self.snapshot.panels { + if let Some((connection, node_error)) = node_states.get(&panel.target.node_id) { + panel.connection = *connection; + panel.error_status = match (node_error, panel.enabled) { + (_, false) => Some("output disabled".to_string()), + (Some(error), _) => Some(error.clone()), + (None, true) => None, + }; + } + } + } + + fn apply_command(&mut self, command: HostCommand) { + match command { + HostCommand::SetBlackout(enabled) => { + self.snapshot.global.blackout = enabled; + self.push_event(if enabled { + "global blackout enabled".to_string() + } else { + "global blackout cleared".to_string() + }); + } + HostCommand::SetMasterBrightness(value) => { + self.snapshot.global.master_brightness = value.clamp(0.0, 1.0); + self.push_event(format!( + "master brightness set to {:.0}%", + self.snapshot.global.master_brightness * 100.0 + )); + } + HostCommand::SelectPattern(pattern) => { + if self.snapshot.available_patterns.iter().any(|entry| entry == &pattern) { + self.snapshot.global.selected_pattern = pattern.clone(); + self.push_event(format!("pattern selected: {pattern}")); + } else { + self.push_event(format!("ignored unknown pattern request: {pattern}")); + } + } + HostCommand::TriggerPanelTest { target, pattern } => { + let now = self.elapsed_millis(); + let event_message = if let Some(panel) = self + .snapshot + .panels + .iter_mut() + .find(|panel| panel.target == target) + { + panel.last_test_trigger_ms = Some(now); + format!( + "test '{}' triggered for {}:{}", + pattern.label(), + panel.target.node_id, + panel_position_label(&panel.target.panel_position) + ) + } else { + format!( + "test '{}' requested for unknown panel {}:{}", + pattern.label(), + target.node_id, + panel_position_label(&target.panel_position) + ) + }; + + self.push_event(event_message); + } + } + } + + fn push_event(&mut self, message: String) { + self.snapshot.recent_events.insert( + 0, + StatusEvent { + at_millis: self.elapsed_millis(), + message, + }, + ); + self.snapshot.recent_events.truncate(MAX_RECENT_EVENTS); + } + + fn elapsed_millis(&self) -> u64 { + self.started_at.elapsed().as_millis() as u64 + } +} + +fn pattern_list_from_project(project: &ProjectConfig) -> Vec { + let mut patterns = BTreeSet::new(); + for preset in &project.presets { + patterns.insert(preset.scene.effect.clone()); + } + for fallback in ["solid_color", "gradient", "chase", "noise", "walking_pixel"] { + patterns.insert(fallback.to_string()); + } + patterns.into_iter().collect() +} + +fn simulated_connection_state(index: usize, tick_count: u64) -> NodeConnectionState { + match index { + 4 => { + if tick_count % 24 < 8 { + NodeConnectionState::Degraded + } else { + NodeConnectionState::Online + } + } + 5 => { + if tick_count % 32 < 7 { + NodeConnectionState::Offline + } else { + NodeConnectionState::Online + } + } + _ => NodeConnectionState::Online, + } +} + +fn simulated_last_contact_ms( + index: usize, + tick_count: u64, + connection: NodeConnectionState, +) -> u64 { + match connection { + NodeConnectionState::Online => 10 + (index as u64 * 4) + (tick_count % 6), + NodeConnectionState::Degraded => 180 + (tick_count % 90), + NodeConnectionState::Offline => 2_500 + (tick_count % 700), + } +} + +fn simulated_error_status(connection: NodeConnectionState) -> Option { + match connection { + NodeConnectionState::Online => None, + NodeConnectionState::Degraded => Some("heartbeat jitter above target".to_string()), + NodeConnectionState::Offline => Some("awaiting reconnect".to_string()), + } +} + +fn panel_position_label(position: &PanelPosition) -> &'static str { + match position { + PanelPosition::Top => "top", + PanelPosition::Middle => "middle", + PanelPosition::Bottom => "bottom", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use infinity_config::ProjectConfig; + + fn sample_project() -> ProjectConfig { + ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml")) + .expect("embedded project config must parse") + } + + #[test] + fn mock_service_starts_with_six_nodes_and_eighteen_panels() { + let service = MockHostService::spawn(sample_project()); + let snapshot = service.snapshot(); + assert_eq!(snapshot.nodes.len(), 6); + assert_eq!(snapshot.panels.len(), 18); + } + + #[test] + fn commands_update_global_state() { + let service = MockHostService::spawn(sample_project()); + service.send_command(HostCommand::SetBlackout(true)); + service.send_command(HostCommand::SelectPattern("walking_pixel".to_string())); + let snapshot = service.snapshot(); + assert!(snapshot.global.blackout); + assert_eq!(snapshot.global.selected_pattern, "walking_pixel"); + } +} diff --git a/crates/infinity_host_ui/Cargo.toml b/crates/infinity_host_ui/Cargo.toml new file mode 100644 index 0000000..7f35ec3 --- /dev/null +++ b/crates/infinity_host_ui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "infinity_host_ui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +clap.workspace = true +eframe.workspace = true +infinity_config = { path = "../infinity_config" } +infinity_host = { path = "../infinity_host" } + diff --git a/crates/infinity_host_ui/src/app.rs b/crates/infinity_host_ui/src/app.rs new file mode 100644 index 0000000..5073a57 --- /dev/null +++ b/crates/infinity_host_ui/src/app.rs @@ -0,0 +1,282 @@ +use eframe::egui::{self, Color32, RichText}; +use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState}; +use infinity_host::{ + HostCommand, HostSnapshot, HostUiPort, NodeConnectionState, PanelTarget, TestPatternKind, +}; +use std::{sync::Arc, time::Duration}; + +pub struct HostUiApp { + service: Arc, + snapshot: HostSnapshot, +} + +impl HostUiApp { + pub fn new(service: Arc) -> Self { + let snapshot = service.snapshot(); + Self { service, snapshot } + } +} + +impl eframe::App for HostUiApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.snapshot = self.service.snapshot(); + ctx.request_repaint_after(Duration::from_millis(100)); + + egui::TopBottomPanel::top("global_controls").show(ctx, |ui| { + draw_global_controls(ui, &self.snapshot, &self.service); + }); + + egui::SidePanel::right("status_panel") + .default_width(320.0) + .resizable(true) + .show(ctx, |ui| { + draw_status_panel(ui, &self.snapshot); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + draw_node_overview(ui, &self.snapshot); + ui.add_space(12.0); + draw_panel_mapping(ui, &self.snapshot, &self.service); + }); + } +} + +fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc) { + ui.horizontal_wrapped(|ui| { + ui.heading("Infinity Vis Host UI"); + ui.label( + RichText::new(format!("backend: {}", snapshot.backend_label)) + .color(Color32::from_rgb(110, 169, 255)), + ); + ui.separator(); + ui.label(format!("nodes: {}", snapshot.nodes.len())); + ui.label(format!("outputs: {}", snapshot.panels.len())); + ui.label(format!("schema time: {} ms", snapshot.generated_at_millis)); + }); + + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + let blackout = snapshot.global.blackout; + let blackout_button = egui::Button::new(if blackout { + RichText::new("Blackout ACTIVE").strong().color(Color32::WHITE) + } else { + RichText::new("Blackout").strong() + }) + .fill(if blackout { + Color32::from_rgb(190, 32, 32) + } else { + Color32::from_rgb(70, 24, 24) + }); + + if ui.add(blackout_button).clicked() { + service.send_command(HostCommand::SetBlackout(!blackout)); + } + + let mut brightness = snapshot.global.master_brightness; + if ui + .add( + egui::Slider::new(&mut brightness, 0.0..=1.0) + .text("Master Brightness"), + ) + .changed() + { + service.send_command(HostCommand::SetMasterBrightness(brightness)); + } + + let selected_pattern = snapshot.global.selected_pattern.clone(); + egui::ComboBox::from_id_source("pattern_selector") + .width(180.0) + .selected_text(selected_pattern.clone()) + .show_ui(ui, |ui| { + for pattern in &snapshot.available_patterns { + if ui + .selectable_label(selected_pattern == *pattern, pattern) + .clicked() + { + service.send_command(HostCommand::SelectPattern(pattern.clone())); + } + } + }); + }); +} + +fn draw_node_overview(ui: &mut egui::Ui, snapshot: &HostSnapshot) { + ui.heading("Node Overview"); + ui.label("Simulation snapshots are shown here without making the UI thread the timing master."); + ui.add_space(6.0); + + ui.columns(3, |columns| { + for (index, node) in snapshot.nodes.iter().enumerate() { + let column = &mut columns[index % 3]; + column.group(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&node.display_name).strong()); + ui.label(connection_badge(node.connection)); + }); + ui.label(format!("Node ID: {}", node.node_id)); + ui.label(format!( + "IP: {}", + node.reserved_ip.as_deref().unwrap_or("unassigned") + )); + ui.label(format!("Panels: {}", node.panel_count)); + ui.label(format!("Last contact: {} ms", node.last_contact_ms)); + if let Some(error) = &node.error_status { + ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140))); + } else { + ui.label(RichText::new("No active errors").color(Color32::from_rgb(120, 204, 142))); + } + }); + } + }); +} + +fn draw_panel_mapping( + ui: &mut egui::Ui, + snapshot: &HostSnapshot, + service: &Arc, +) { + ui.separator(); + ui.heading("Panel Mapping"); + ui.label("Each row is a real output slot in the fixed 6 x 3 hardware topology."); + ui.add_space(6.0); + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + egui::Grid::new("panel_mapping_grid") + .num_columns(11) + .striped(true) + .min_col_width(72.0) + .show(ui, |ui| { + header_cell(ui, "Node"); + header_cell(ui, "Panel"); + header_cell(ui, "Output"); + header_cell(ui, "GPIO / Channel"); + header_cell(ui, "LEDs"); + header_cell(ui, "Direction"); + header_cell(ui, "Color"); + header_cell(ui, "Enabled"); + header_cell(ui, "Validation"); + header_cell(ui, "Status"); + header_cell(ui, "Test"); + ui.end_row(); + + for panel in &snapshot.panels { + ui.label(&panel.target.node_id); + ui.label(panel_position_label(&panel.target.panel_position)); + ui.label(&panel.physical_output_name); + ui.label(&panel.driver_reference); + ui.label(panel.led_count.to_string()); + ui.label(direction_label(&panel.direction)); + ui.label(color_order_label(&panel.color_order)); + ui.label(bool_flag(panel.enabled)); + ui.label(validation_label(&panel.validation_state)); + ui.vertical(|ui| { + ui.label(connection_badge(panel.connection)); + if let Some(last_test_ms) = panel.last_test_trigger_ms { + ui.label(format!("last test: {} ms", snapshot.generated_at_millis.saturating_sub(last_test_ms))); + } + if let Some(error) = &panel.error_status { + ui.label( + RichText::new(error).color(Color32::from_rgb(255, 140, 140)), + ); + } + }); + if ui.button("Walk 106").clicked() { + service.send_command(HostCommand::TriggerPanelTest { + target: PanelTarget { + node_id: panel.target.node_id.clone(), + panel_position: panel.target.panel_position.clone(), + }, + pattern: TestPatternKind::WalkingPixel106, + }); + } + ui.end_row(); + } + }); + }); +} + +fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) { + ui.heading("Status"); + ui.label("Node connectivity, last contact, and recent control activity."); + ui.add_space(8.0); + + for node in &snapshot.nodes { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&node.display_name).strong()); + ui.label(connection_badge(node.connection)); + }); + ui.label(format!("Last contact: {} ms", node.last_contact_ms)); + ui.label(format!( + "Error status: {}", + node.error_status.as_deref().unwrap_or("none") + )); + }); + ui.add_space(6.0); + } + + ui.separator(); + ui.heading("Recent Events"); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for event in &snapshot.recent_events { + ui.label(format!("[{} ms] {}", event.at_millis, event.message)); + } + }); +} + +fn header_cell(ui: &mut egui::Ui, label: &str) { + ui.label(RichText::new(label).strong()); +} + +fn connection_badge(state: NodeConnectionState) -> RichText { + let (label, color) = match state { + NodeConnectionState::Online => ("online", Color32::from_rgb(78, 194, 120)), + NodeConnectionState::Degraded => ("degraded", Color32::from_rgb(242, 182, 59)), + NodeConnectionState::Offline => ("offline", Color32::from_rgb(220, 76, 76)), + }; + RichText::new(label).strong().color(color) +} + +fn panel_position_label(position: &PanelPosition) -> &'static str { + match position { + PanelPosition::Top => "top", + PanelPosition::Middle => "middle", + PanelPosition::Bottom => "bottom", + } +} + +fn direction_label(direction: &LedDirection) -> &'static str { + match direction { + LedDirection::Forward => "forward", + LedDirection::Reverse => "reverse", + } +} + +fn color_order_label(color_order: &ColorOrder) -> &'static str { + match color_order { + ColorOrder::Rgb => "RGB", + ColorOrder::Rbg => "RBG", + ColorOrder::Grb => "GRB", + ColorOrder::Gbr => "GBR", + ColorOrder::Brg => "BRG", + ColorOrder::Bgr => "BGR", + } +} + +fn validation_label(state: &ValidationState) -> &'static str { + match state { + ValidationState::PendingHardwareValidation => "pending_validation", + ValidationState::Validated => "validated", + ValidationState::Retired => "retired", + } +} + +fn bool_flag(value: bool) -> &'static str { + if value { + "true" + } else { + "false" + } +} diff --git a/crates/infinity_host_ui/src/main.rs b/crates/infinity_host_ui/src/main.rs new file mode 100644 index 0000000..f94641d --- /dev/null +++ b/crates/infinity_host_ui/src/main.rs @@ -0,0 +1,46 @@ +mod app; + +use app::HostUiApp; +use clap::Parser; +use eframe::egui; +use infinity_config::{load_project_from_path, ProjectConfig}; +use infinity_host::MockHostService; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(author, version, about = "Infinity Vis native host UI")] +struct Cli { + #[arg(long)] + config: Option, +} + +fn main() -> eframe::Result<()> { + let cli = Cli::parse(); + let project = load_project(cli.config.as_deref()); + let service = MockHostService::spawn(project); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("Infinity Vis Host UI") + .with_inner_size([1500.0, 940.0]) + .with_min_inner_size([1200.0, 760.0]), + ..Default::default() + }; + + eframe::run_native( + "Infinity Vis Host UI", + options, + Box::new(move |_creation_context| Ok(Box::new(HostUiApp::new(service.clone())))), + ) +} + +fn load_project(path: Option<&std::path::Path>) -> ProjectConfig { + if let Some(path) = path { + if let Ok(project) = load_project_from_path(path) { + return project; + } + } + + ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml")) + .expect("embedded example config must parse") +} diff --git a/docs/architecture.md b/docs/architecture.md index 744ff97..142d5bc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,6 +11,7 @@ Build a live-capable LED control platform that keeps realtime output determinist - Presets and topology editing - Monitoring and diagnostics - Never the timing master for LED output + - First vertical slice is implemented as `crates/infinity_host_ui` 2. Realtime engine - Owns the monotonic clock - Computes scene state, transitions, and dirty regions @@ -78,8 +79,7 @@ The codebase deliberately blocks activation when these remain unresolved: ## Planned Next Steps -1. Add the actual UI adapter on top of `infinity_host` +1. Expand the new UI slice from mock service to real host transport adapters 2. Implement UDP transport with separate control and realtime sockets 3. Connect firmware driver backends after hardware validation 4. Add deterministic effect registry shared between host planning and firmware capability negotiation - diff --git a/docs/build_and_deploy.md b/docs/build_and_deploy.md index c74f1d9..1b22157 100644 --- a/docs/build_and_deploy.md +++ b/docs/build_and_deploy.md @@ -11,10 +11,13 @@ Suggested commands: ```powershell cargo test +cargo run -p infinity_host_ui cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue ``` +The native UI currently runs against the host-core mock service so the operator workflow can be exercised before transport and firmware integration are complete. + Before any live activation, run: ```powershell @@ -37,4 +40,3 @@ Suggested layout: - flash with `idf.py -p flash monitor` The firmware skeleton is intentionally conservative. It will not silently select a backend for `UART 6`, `UART 5`, or `UART 4`. -