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:
2026-04-17 01:45:30 +02:00
parent 1f6543bd7a
commit dde35551be
11 changed files with 816 additions and 11 deletions

View 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",
}
}
}

View File

@@ -0,0 +1,8 @@
pub mod control;
pub mod mock;
pub mod runtime;
pub use control::*;
pub use mock::*;
pub use runtime::*;

View File

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

View 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");
}
}