Die Host-Seite ist jetzt auf eine gemeinsame software-first API ausgerichtet. In [control.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/control.rs>) steckt jetzt das stabile gemeinsame Modell für Snapshots, Commands, Pattern-Katalog, Presets, Gruppen, Parameter, Preview und Übergänge. Darauf sitzen die neue Szenen-/Pattern-Schicht in [scene.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/scene.rs>) und der simulationsbasierte Host-Service in [simulation.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>). Der neue Core kann jetzt softwareseitig schon: - Pattern-Katalog mit `solid_color`, `gradient`, `chase`, `pulse`, `noise`, `walking_pixel` - Preset-Recall, Gruppen-Targeting, Parameteränderungen und Übergänge - simulierte Preview-Daten für alle 18 Outputs - denselben API-Zugriff für CLI, Engineering-GUI und später Web-UI / grandMA-Adapter Zusätzlich gibt es im Host-CLI jetzt `snapshot`, also eine direkte JSON-Sicht auf den gemeinsamen Host-State über [main.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/main.rs>). **Oberflächen** Die technische lokale GUI bleibt bestehen und hängt jetzt auf der neuen gemeinsamen API. In [app.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_ui/src/app.rs>) zeigt sie weiter Mapping/Status/Testmuster, ergänzt um Engine-/Szene-/Übergangsstatus. Sie bleibt bewusst Engineering-orientiert und ist nicht zur kreativen Hauptoberfläche aufgeblasen worden. Die Beispielkonfiguration in [project.example.toml](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/config/project.example.toml>) ist jetzt auch als Software-Spielwiese brauchbarer: mehr Gruppen, mehr kreative Presets und bessere Basis für Look-Entwicklung ohne echte Node-Aktivierung. Die neue API-Ausrichtung ist in [host_api.md](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>) und [architecture.md](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>) dokumentiert. **Verifikation** `cargo check` und `cargo test -q` laufen erfolgreich. Zusätzlich läuft `cargo run -p infinity_host -- snapshot --config config/project.example.toml` und liefert den gemeinsamen Host-Snapshot mit Katalog, aktiver Szene, Preview, Node- und Panelstatus. Der nächste sinnvolle Schritt ist jetzt ein echter API-Adapter fuer die kommende Web-UI, also HTTP/WebSocket auf genau diesem Host-Core statt einer frontend-spezifischen Parallelarchitektur.
318 lines
11 KiB
Rust
318 lines
11 KiB
Rust
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_salt("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);
|
|
|
|
ui.group(|ui| {
|
|
ui.label(RichText::new("Engine").strong());
|
|
ui.label(format!("Pattern: {}", snapshot.active_scene.pattern_id));
|
|
ui.label(format!(
|
|
"Preset: {}",
|
|
snapshot
|
|
.active_scene
|
|
.preset_id
|
|
.as_deref()
|
|
.unwrap_or("custom")
|
|
));
|
|
ui.label(format!(
|
|
"Group: {}",
|
|
snapshot
|
|
.active_scene
|
|
.target_group
|
|
.as_deref()
|
|
.unwrap_or("all_panels")
|
|
));
|
|
ui.label(format!(
|
|
"Frames: {} at {} Hz",
|
|
snapshot.engine.frame_index, snapshot.engine.frame_hz
|
|
));
|
|
if let Some(transition) = &snapshot.engine.active_transition {
|
|
ui.label(format!(
|
|
"Transition: {:?} {:.0}%",
|
|
transition.style,
|
|
transition.progress * 100.0
|
|
));
|
|
} else {
|
|
ui.label("Transition: idle");
|
|
}
|
|
});
|
|
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"
|
|
}
|
|
}
|