Der minimale GUI-Vertical-Slice ist jetzt als eigenes Crate umgesetzt. Die Desktop-App liegt in crates/infinity_host_ui/src/main.rs und crates/infinity_host_ui/src/app.rs: lokales Fenster per eframe, globale Controls für Blackout, Master Brightness und Pattern, eine 6-Node-Übersicht, die 18er Mapping-Tabelle mit Node, top/middle/bottom, physischem Output/Kanal, 106 LEDs, direction, color_order, enable_flag, plus Walk 106-Testtrigger pro Panel und einen Status-/Event-Bereich für online/offline, letzter Kontakt und Fehlerstatus.
Damit die UI sauber getrennt bleibt, habe ich infinity_host zu einer wiederverwendbaren Core-Schicht ausgebaut: crates/infinity_host/src/lib.rs, control.rs und mock.rs definieren Snapshot-/Command-Typen und einen Mock-Simulationsdienst mit Hintergrundthread. Die UI pollt nur Snapshots über HostUiPort; sie treibt keine Realtime-Logik. Den Workspace und die Build-Hinweise habe ich in Cargo.toml, README.md und docs/build_and_deploy.md ergänzt. Verifikation war hier nur eingeschränkt möglich: cargo und rustc sind in dieser Umgebung nicht installiert, daher konnte ich die App nicht kompilieren oder starten. Der nächste sinnvolle Schritt wäre erst nach deinem Go, den vorhandenen HostUiPort statt des Mock-Backends an echten Transportstatus anzubinden.
This commit is contained in:
104
crates/infinity_host/src/control.rs
Normal file
104
crates/infinity_host/src/control.rs
Normal file
@@ -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<String>,
|
||||
pub nodes: Vec<NodeSnapshot>,
|
||||
pub panels: Vec<PanelSnapshot>,
|
||||
pub recent_events: Vec<StatusEvent>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub connection: NodeConnectionState,
|
||||
pub last_contact_ms: u64,
|
||||
pub error_status: Option<String>,
|
||||
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<u64>,
|
||||
pub error_status: Option<String>,
|
||||
}
|
||||
|
||||
#[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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
crates/infinity_host/src/lib.rs
Normal file
8
crates/infinity_host/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod control;
|
||||
pub mod mock;
|
||||
pub mod runtime;
|
||||
|
||||
pub use control::*;
|
||||
pub use mock::*;
|
||||
pub use runtime::*;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
351
crates/infinity_host/src/mock.rs
Normal file
351
crates/infinity_host/src/mock.rs
Normal file
@@ -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<Mutex<MockHostState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MockHostState {
|
||||
started_at: Instant,
|
||||
tick_count: u64,
|
||||
snapshot: HostSnapshot,
|
||||
}
|
||||
|
||||
impl MockHostService {
|
||||
pub fn spawn(project: ProjectConfig) -> Arc<dyn HostUiPort> {
|
||||
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<Self>) {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
13
crates/infinity_host_ui/Cargo.toml
Normal file
13
crates/infinity_host_ui/Cargo.toml
Normal file
@@ -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" }
|
||||
|
||||
282
crates/infinity_host_ui/src/app.rs
Normal file
282
crates/infinity_host_ui/src/app.rs
Normal file
@@ -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<dyn HostUiPort>,
|
||||
snapshot: HostSnapshot,
|
||||
}
|
||||
|
||||
impl HostUiApp {
|
||||
pub fn new(service: Arc<dyn HostUiPort>) -> 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<dyn HostUiPort>) {
|
||||
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<dyn HostUiPort>,
|
||||
) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
46
crates/infinity_host_ui/src/main.rs
Normal file
46
crates/infinity_host_ui/src/main.rs
Normal file
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user