Stabilize control surface and external bridge v1
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
|
||||
use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ValidationState};
|
||||
use infinity_host::{
|
||||
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
|
||||
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
|
||||
TestPatternKind,
|
||||
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, OutputBackendMode,
|
||||
PreviewSource, SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
|
||||
TechnicalSnapshot, TestPatternKind,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -80,9 +80,40 @@ pub struct ApiErrorBody {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiDiscoveryScanRequest {
|
||||
pub subnet: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiDiscoveryScanResponse {
|
||||
pub api_version: &'static str,
|
||||
pub subnet: String,
|
||||
pub scanned_hosts: usize,
|
||||
pub reachable_hosts: usize,
|
||||
pub results: Vec<ApiDiscoveryResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiDiscoveryResult {
|
||||
pub ip: String,
|
||||
pub reachable: bool,
|
||||
pub detected_type: ApiDiscoveredNodeType,
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApiDiscoveredNodeType {
|
||||
Wled,
|
||||
Unknown,
|
||||
NativeNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiStateSnapshot {
|
||||
pub system: ApiSystemInfo,
|
||||
pub technical: ApiTechnicalState,
|
||||
pub global: ApiGlobalState,
|
||||
pub engine: ApiEngineState,
|
||||
pub active_scene: ApiActiveScene,
|
||||
@@ -134,6 +165,21 @@ pub struct ApiSystemInfo {
|
||||
pub topology_label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiTechnicalState {
|
||||
pub backend_mode: ApiOutputBackendMode,
|
||||
pub output_enabled: bool,
|
||||
pub output_fps: u16,
|
||||
pub live_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApiOutputBackendMode {
|
||||
PreviewOnly,
|
||||
DdpWled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiGlobalState {
|
||||
pub blackout: bool,
|
||||
@@ -258,6 +304,7 @@ pub struct ApiPanelStatus {
|
||||
pub node_id: String,
|
||||
pub panel_position: ApiPanelPosition,
|
||||
pub physical_output_name: String,
|
||||
pub driver_kind: ApiDriverKind,
|
||||
pub driver_reference: String,
|
||||
pub led_count: u16,
|
||||
pub direction: ApiLedDirection,
|
||||
@@ -337,6 +384,18 @@ pub enum ApiColorOrder {
|
||||
Bgr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApiDriverKind {
|
||||
PendingValidation,
|
||||
Gpio,
|
||||
RmtChannel,
|
||||
I2sLane,
|
||||
UartPort,
|
||||
SpiBus,
|
||||
ExternalDriver,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApiValidationState {
|
||||
@@ -356,6 +415,30 @@ pub enum ApiParameterValue {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case", tag = "type", content = "payload")]
|
||||
pub enum ApiCommand {
|
||||
SetOutputBackendMode {
|
||||
mode: ApiOutputBackendMode,
|
||||
},
|
||||
SetLiveOutputEnabled {
|
||||
enabled: bool,
|
||||
},
|
||||
SetOutputFps {
|
||||
output_fps: u16,
|
||||
},
|
||||
SetNodeReservedIp {
|
||||
node_id: String,
|
||||
reserved_ip: Option<String>,
|
||||
},
|
||||
UpdatePanelMapping {
|
||||
node_id: String,
|
||||
panel_position: ApiPanelPosition,
|
||||
physical_output_name: String,
|
||||
driver_kind: ApiDriverKind,
|
||||
driver_reference: String,
|
||||
led_count: u16,
|
||||
direction: ApiLedDirection,
|
||||
color_order: ApiColorOrder,
|
||||
enabled: bool,
|
||||
},
|
||||
SetBlackout {
|
||||
enabled: bool,
|
||||
},
|
||||
@@ -390,6 +473,9 @@ pub enum ApiCommand {
|
||||
preset_id: String,
|
||||
overwrite: bool,
|
||||
},
|
||||
DeletePreset {
|
||||
preset_id: String,
|
||||
},
|
||||
SaveCreativeSnapshot {
|
||||
snapshot_id: String,
|
||||
label: Option<String>,
|
||||
@@ -546,6 +632,7 @@ impl ApiStateSnapshot {
|
||||
schema_version: snapshot.system.schema_version,
|
||||
topology_label: snapshot.system.topology_label.clone(),
|
||||
},
|
||||
technical: map_technical_snapshot(&snapshot.technical),
|
||||
global: ApiGlobalState {
|
||||
blackout: snapshot.global.blackout,
|
||||
master_brightness: snapshot.global.master_brightness,
|
||||
@@ -615,6 +702,7 @@ impl ApiStateSnapshot {
|
||||
node_id: panel.target.node_id.clone(),
|
||||
panel_position: map_panel_position(&panel.target.panel_position),
|
||||
physical_output_name: panel.physical_output_name.clone(),
|
||||
driver_kind: map_driver_kind(panel.driver_kind.clone()),
|
||||
driver_reference: panel.driver_reference.clone(),
|
||||
led_count: panel.led_count,
|
||||
direction: map_led_direction(panel.direction.clone()),
|
||||
@@ -654,6 +742,43 @@ impl ApiPreviewSnapshot {
|
||||
impl ApiCommandRequest {
|
||||
pub fn into_host_command(self) -> Result<HostCommand, String> {
|
||||
match self.command {
|
||||
ApiCommand::SetOutputBackendMode { mode } => Ok(HostCommand::SetOutputBackendMode(
|
||||
map_output_backend_mode(mode),
|
||||
)),
|
||||
ApiCommand::SetLiveOutputEnabled { enabled } => {
|
||||
Ok(HostCommand::SetLiveOutputEnabled(enabled))
|
||||
}
|
||||
ApiCommand::SetOutputFps { output_fps } => Ok(HostCommand::SetOutputFps(output_fps)),
|
||||
ApiCommand::SetNodeReservedIp {
|
||||
node_id,
|
||||
reserved_ip,
|
||||
} => Ok(HostCommand::SetNodeReservedIp {
|
||||
node_id,
|
||||
reserved_ip,
|
||||
}),
|
||||
ApiCommand::UpdatePanelMapping {
|
||||
node_id,
|
||||
panel_position,
|
||||
physical_output_name,
|
||||
driver_kind,
|
||||
driver_reference,
|
||||
led_count,
|
||||
direction,
|
||||
color_order,
|
||||
enabled,
|
||||
} => Ok(HostCommand::UpdatePanelMapping {
|
||||
target: infinity_host::PanelTarget {
|
||||
node_id,
|
||||
panel_position: map_command_panel_position(panel_position),
|
||||
},
|
||||
physical_output_name,
|
||||
driver_kind: map_command_driver_kind(driver_kind),
|
||||
driver_reference,
|
||||
led_count,
|
||||
direction: map_command_led_direction(direction),
|
||||
color_order: map_command_color_order(color_order),
|
||||
enabled,
|
||||
}),
|
||||
ApiCommand::SetBlackout { enabled } => Ok(HostCommand::SetBlackout(enabled)),
|
||||
ApiCommand::SetMasterBrightness { value } => {
|
||||
Ok(HostCommand::SetMasterBrightness(value))
|
||||
@@ -691,6 +816,7 @@ impl ApiCommandRequest {
|
||||
preset_id,
|
||||
overwrite,
|
||||
}),
|
||||
ApiCommand::DeletePreset { preset_id } => Ok(HostCommand::DeletePreset { preset_id }),
|
||||
ApiCommand::SaveCreativeSnapshot {
|
||||
snapshot_id,
|
||||
label,
|
||||
@@ -770,6 +896,71 @@ fn map_color_order(color_order: ColorOrder) -> ApiColorOrder {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_command_color_order(color_order: ApiColorOrder) -> ColorOrder {
|
||||
match color_order {
|
||||
ApiColorOrder::Rgb => ColorOrder::Rgb,
|
||||
ApiColorOrder::Rbg => ColorOrder::Rbg,
|
||||
ApiColorOrder::Grb => ColorOrder::Grb,
|
||||
ApiColorOrder::Gbr => ColorOrder::Gbr,
|
||||
ApiColorOrder::Brg => ColorOrder::Brg,
|
||||
ApiColorOrder::Bgr => ColorOrder::Bgr,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_driver_kind(kind: DriverKind) -> ApiDriverKind {
|
||||
match kind {
|
||||
DriverKind::PendingValidation => ApiDriverKind::PendingValidation,
|
||||
DriverKind::Gpio => ApiDriverKind::Gpio,
|
||||
DriverKind::RmtChannel => ApiDriverKind::RmtChannel,
|
||||
DriverKind::I2sLane => ApiDriverKind::I2sLane,
|
||||
DriverKind::UartPort => ApiDriverKind::UartPort,
|
||||
DriverKind::SpiBus => ApiDriverKind::SpiBus,
|
||||
DriverKind::ExternalDriver => ApiDriverKind::ExternalDriver,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_command_driver_kind(kind: ApiDriverKind) -> DriverKind {
|
||||
match kind {
|
||||
ApiDriverKind::PendingValidation => DriverKind::PendingValidation,
|
||||
ApiDriverKind::Gpio => DriverKind::Gpio,
|
||||
ApiDriverKind::RmtChannel => DriverKind::RmtChannel,
|
||||
ApiDriverKind::I2sLane => DriverKind::I2sLane,
|
||||
ApiDriverKind::UartPort => DriverKind::UartPort,
|
||||
ApiDriverKind::SpiBus => DriverKind::SpiBus,
|
||||
ApiDriverKind::ExternalDriver => DriverKind::ExternalDriver,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_backend_mode(mode: ApiOutputBackendMode) -> OutputBackendMode {
|
||||
match mode {
|
||||
ApiOutputBackendMode::PreviewOnly => OutputBackendMode::PreviewOnly,
|
||||
ApiOutputBackendMode::DdpWled => OutputBackendMode::DdpWled,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_backend_mode_from_snapshot(mode: OutputBackendMode) -> ApiOutputBackendMode {
|
||||
match mode {
|
||||
OutputBackendMode::PreviewOnly => ApiOutputBackendMode::PreviewOnly,
|
||||
OutputBackendMode::DdpWled => ApiOutputBackendMode::DdpWled,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_technical_snapshot(snapshot: &TechnicalSnapshot) -> ApiTechnicalState {
|
||||
ApiTechnicalState {
|
||||
backend_mode: map_output_backend_mode_from_snapshot(snapshot.backend_mode),
|
||||
output_enabled: snapshot.output_enabled,
|
||||
output_fps: snapshot.output_fps,
|
||||
live_status: snapshot.live_status.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_command_led_direction(direction: ApiLedDirection) -> LedDirection {
|
||||
match direction {
|
||||
ApiLedDirection::Forward => LedDirection::Forward,
|
||||
ApiLedDirection::Reverse => LedDirection::Reverse,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_validation_state(state: ValidationState) -> ApiValidationState {
|
||||
match state {
|
||||
ValidationState::PendingHardwareValidation => ApiValidationState::PendingHardwareValidation,
|
||||
@@ -845,6 +1036,11 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue
|
||||
impl ApiCommand {
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SetOutputBackendMode { .. } => "set_output_backend_mode",
|
||||
Self::SetLiveOutputEnabled { .. } => "set_live_output_enabled",
|
||||
Self::SetOutputFps { .. } => "set_output_fps",
|
||||
Self::SetNodeReservedIp { .. } => "set_node_reserved_ip",
|
||||
Self::UpdatePanelMapping { .. } => "update_panel_mapping",
|
||||
Self::SetBlackout { .. } => "set_blackout",
|
||||
Self::SetMasterBrightness { .. } => "set_master_brightness",
|
||||
Self::SelectPattern { .. } => "select_pattern",
|
||||
@@ -855,6 +1051,7 @@ impl ApiCommand {
|
||||
Self::SetTransitionStyle { .. } => "set_transition_style",
|
||||
Self::TriggerPanelTest { .. } => "trigger_panel_test",
|
||||
Self::SavePreset { .. } => "save_preset",
|
||||
Self::DeletePreset { .. } => "delete_preset",
|
||||
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
|
||||
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
|
||||
Self::UpsertGroup { .. } => "upsert_group",
|
||||
@@ -863,6 +1060,40 @@ impl ApiCommand {
|
||||
|
||||
pub fn summary(&self) -> String {
|
||||
match self {
|
||||
Self::SetOutputBackendMode { mode } => {
|
||||
format!(
|
||||
"output backend mode set to {}",
|
||||
match mode {
|
||||
ApiOutputBackendMode::PreviewOnly => "preview_only",
|
||||
ApiOutputBackendMode::DdpWled => "ddp_wled",
|
||||
}
|
||||
)
|
||||
}
|
||||
Self::SetLiveOutputEnabled { enabled } => {
|
||||
if *enabled {
|
||||
"live output enabled".to_string()
|
||||
} else {
|
||||
"live output disabled".to_string()
|
||||
}
|
||||
}
|
||||
Self::SetOutputFps { output_fps } => format!("output fps set to {output_fps}"),
|
||||
Self::SetNodeReservedIp {
|
||||
node_id,
|
||||
reserved_ip,
|
||||
} => format!(
|
||||
"node target updated: {} -> {}",
|
||||
node_id,
|
||||
reserved_ip.as_deref().unwrap_or("unassigned")
|
||||
),
|
||||
Self::UpdatePanelMapping {
|
||||
node_id,
|
||||
panel_position,
|
||||
..
|
||||
} => format!(
|
||||
"panel mapping updated: {}:{}",
|
||||
node_id,
|
||||
panel_position.label()
|
||||
),
|
||||
Self::SetBlackout { enabled } => {
|
||||
if *enabled {
|
||||
"blackout enabled".to_string()
|
||||
@@ -909,6 +1140,7 @@ impl ApiCommand {
|
||||
format!("preset saved: {preset_id}")
|
||||
}
|
||||
}
|
||||
Self::DeletePreset { preset_id } => format!("preset deleted: {preset_id}"),
|
||||
Self::SaveCreativeSnapshot {
|
||||
snapshot_id,
|
||||
overwrite,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
use crate::dto::{
|
||||
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
|
||||
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiDiscoveredNodeType,
|
||||
ApiDiscoveryResult, ApiDiscoveryScanRequest, ApiDiscoveryScanResponse, ApiErrorResponse,
|
||||
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
|
||||
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
|
||||
};
|
||||
use crate::websocket::{websocket_accept_value, write_text_frame};
|
||||
use infinity_host::HostApiPort;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
mpsc, Arc, Mutex,
|
||||
};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct HostApiServer {
|
||||
local_addr: SocketAddr,
|
||||
@@ -141,6 +143,12 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => respond_error(&mut stream, error.status, error.code, error.message),
|
||||
},
|
||||
("POST", "/api/v1/discovery/scan") => {
|
||||
match handle_discovery_scan_post(&mut stream, request) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => respond_error(&mut stream, error.status, error.code, error.message),
|
||||
}
|
||||
}
|
||||
("GET", "/") => respond_text(
|
||||
&mut stream,
|
||||
200,
|
||||
@@ -153,12 +161,30 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
|
||||
"text/html; charset=utf-8",
|
||||
include_str!("../../../web/v1/index.html"),
|
||||
),
|
||||
("GET", "/technical") => respond_text(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
include_str!("../../../web/v1/technical.html"),
|
||||
),
|
||||
("GET", "/technical.html") => respond_text(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
include_str!("../../../web/v1/technical.html"),
|
||||
),
|
||||
("GET", "/app.js") => respond_text(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript; charset=utf-8",
|
||||
include_str!("../../../web/v1/app.js"),
|
||||
),
|
||||
("GET", "/technical.js") => respond_text(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript; charset=utf-8",
|
||||
include_str!("../../../web/v1/technical.js"),
|
||||
),
|
||||
("GET", "/styles.css") => respond_text(
|
||||
&mut stream,
|
||||
200,
|
||||
@@ -307,6 +333,275 @@ fn handle_websocket(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_discovery_scan_post(
|
||||
stream: &mut TcpStream,
|
||||
request: HttpRequest,
|
||||
) -> Result<(), ApiRequestError> {
|
||||
let parsed =
|
||||
serde_json::from_slice::<ApiDiscoveryScanRequest>(&request.body).map_err(|error| {
|
||||
ApiRequestError {
|
||||
status: 400,
|
||||
code: "invalid_request_json".to_string(),
|
||||
message: format!("discovery request body could not be parsed: {error}"),
|
||||
}
|
||||
})?;
|
||||
let targets = parse_subnet_targets(&parsed.subnet).map_err(|message| ApiRequestError {
|
||||
status: 400,
|
||||
code: "invalid_subnet_cidr".to_string(),
|
||||
message,
|
||||
})?;
|
||||
|
||||
let started_at = Instant::now();
|
||||
let mut results = scan_subnet_targets(&targets);
|
||||
results.sort_by_key(|result| {
|
||||
result
|
||||
.ip
|
||||
.parse::<Ipv4Addr>()
|
||||
.map(u32::from)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let reachable_hosts = results.iter().filter(|result| result.reachable).count();
|
||||
|
||||
respond_json(
|
||||
stream,
|
||||
200,
|
||||
&ApiDiscoveryScanResponse {
|
||||
api_version: API_VERSION,
|
||||
subnet: parsed.subnet.trim().to_string(),
|
||||
scanned_hosts: targets.len(),
|
||||
reachable_hosts,
|
||||
results,
|
||||
},
|
||||
)
|
||||
.map_err(|error| ApiRequestError {
|
||||
status: 500,
|
||||
code: "response_write_failed".to_string(),
|
||||
message: format!(
|
||||
"discovery response could not be written after {} ms: {error}",
|
||||
started_at.elapsed().as_millis()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_subnet_targets(raw_subnet: &str) -> Result<Vec<Ipv4Addr>, String> {
|
||||
const MAX_SCAN_HOSTS: u64 = 1024;
|
||||
|
||||
let subnet = raw_subnet.trim();
|
||||
let (address, prefix) = subnet
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("subnet '{subnet}' must be in CIDR form, e.g. 192.168.40.0/24"))?;
|
||||
let ip = address
|
||||
.trim()
|
||||
.parse::<Ipv4Addr>()
|
||||
.map_err(|_| format!("subnet '{subnet}' contains an invalid IPv4 address"))?;
|
||||
let prefix = prefix
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.map_err(|_| format!("subnet '{subnet}' contains an invalid CIDR prefix"))?;
|
||||
if prefix > 32 {
|
||||
return Err(format!(
|
||||
"subnet '{subnet}' has prefix {prefix}, expected 0..=32"
|
||||
));
|
||||
}
|
||||
|
||||
let host_span = 1u64 << (32u8.saturating_sub(prefix));
|
||||
if host_span > MAX_SCAN_HOSTS {
|
||||
return Err(format!(
|
||||
"subnet '{subnet}' spans {host_span} addresses, limit is {MAX_SCAN_HOSTS}"
|
||||
));
|
||||
}
|
||||
|
||||
let ip_u32 = u32::from(ip);
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - u32::from(prefix))
|
||||
};
|
||||
let network = ip_u32 & mask;
|
||||
let broadcast = network | !mask;
|
||||
let (start, end) = if prefix >= 31 {
|
||||
(network, broadcast)
|
||||
} else {
|
||||
(network.saturating_add(1), broadcast.saturating_sub(1))
|
||||
};
|
||||
|
||||
if start > end {
|
||||
return Err(format!("subnet '{subnet}' has no scanable host addresses"));
|
||||
}
|
||||
|
||||
Ok((start..=end).map(Ipv4Addr::from).collect())
|
||||
}
|
||||
|
||||
fn scan_subnet_targets(targets: &[Ipv4Addr]) -> Vec<ApiDiscoveryResult> {
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let worker_count = usize::min(32, targets.len().max(1));
|
||||
let (job_sender, job_receiver) = mpsc::channel::<Ipv4Addr>();
|
||||
let job_receiver = Arc::new(Mutex::new(job_receiver));
|
||||
let (result_sender, result_receiver) = mpsc::channel::<ApiDiscoveryResult>();
|
||||
let mut handles = Vec::with_capacity(worker_count);
|
||||
|
||||
for _ in 0..worker_count {
|
||||
let receiver = Arc::clone(&job_receiver);
|
||||
let sender = result_sender.clone();
|
||||
handles.push(thread::spawn(move || loop {
|
||||
let next_job = {
|
||||
let guard = receiver.lock();
|
||||
match guard {
|
||||
Ok(receiver) => receiver.recv().ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
let Some(ip) = next_job else {
|
||||
break;
|
||||
};
|
||||
let _ = sender.send(probe_ip(ip));
|
||||
}));
|
||||
}
|
||||
drop(result_sender);
|
||||
|
||||
for ip in targets {
|
||||
let _ = job_sender.send(*ip);
|
||||
}
|
||||
drop(job_sender);
|
||||
|
||||
let mut results = Vec::with_capacity(targets.len());
|
||||
for _ in 0..targets.len() {
|
||||
if let Ok(result) = result_receiver.recv() {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
let _ = handle.join();
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn probe_ip(ip: Ipv4Addr) -> ApiDiscoveryResult {
|
||||
let mut reachable = false;
|
||||
let mut detected_type = ApiDiscoveredNodeType::Unknown;
|
||||
let mut hostname = None;
|
||||
|
||||
if let Some(info_probe) = probe_http_endpoint(ip, 80, "/json/info") {
|
||||
reachable = true;
|
||||
detected_type = detect_node_type(&info_probe.body, detected_type);
|
||||
hostname = extract_probe_hostname(&info_probe);
|
||||
} else if can_connect(ip, 80) {
|
||||
reachable = true;
|
||||
}
|
||||
|
||||
if !reachable && can_connect(ip, 81) {
|
||||
reachable = true;
|
||||
}
|
||||
|
||||
if detected_type == ApiDiscoveredNodeType::Unknown {
|
||||
if let Some(node_probe) = probe_http_endpoint(ip, 80, "/api/v1/node/info") {
|
||||
reachable = true;
|
||||
detected_type = detect_node_type(&node_probe.body, detected_type);
|
||||
if hostname.is_none() {
|
||||
hostname = extract_probe_hostname(&node_probe);
|
||||
}
|
||||
} else if let Some(state_probe) = probe_http_endpoint(ip, 80, "/api/v1/state") {
|
||||
reachable = true;
|
||||
detected_type = detect_node_type(&state_probe.body, detected_type);
|
||||
if hostname.is_none() {
|
||||
hostname = extract_probe_hostname(&state_probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiDiscoveryResult {
|
||||
ip: ip.to_string(),
|
||||
reachable,
|
||||
detected_type,
|
||||
hostname,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_connect(ip: Ipv4Addr, port: u16) -> bool {
|
||||
let address = SocketAddr::new(IpAddr::V4(ip), port);
|
||||
TcpStream::connect_timeout(&address, Duration::from_millis(120)).is_ok()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpProbe {
|
||||
headers: HashMap<String, String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn probe_http_endpoint(ip: Ipv4Addr, port: u16, path: &str) -> Option<HttpProbe> {
|
||||
let address = SocketAddr::new(IpAddr::V4(ip), port);
|
||||
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(120)).ok()?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(180)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(120)));
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {ip}\r\nConnection: close\r\nAccept: application/json\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).ok()?;
|
||||
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).ok()?;
|
||||
let header_end = find_header_end(&raw)?;
|
||||
let header_text = String::from_utf8_lossy(&raw[..header_end]);
|
||||
let headers = header_text
|
||||
.lines()
|
||||
.skip(1)
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let body = String::from_utf8_lossy(raw.get(header_end + 4..).unwrap_or_default()).to_string();
|
||||
Some(HttpProbe { headers, body })
|
||||
}
|
||||
|
||||
fn detect_node_type(body: &str, fallback: ApiDiscoveredNodeType) -> ApiDiscoveredNodeType {
|
||||
let lowered = body.to_ascii_lowercase();
|
||||
if lowered.contains("\"wled\"")
|
||||
|| lowered.contains("\"brand\":\"wled\"")
|
||||
|| lowered.contains("\"product\":\"wled\"")
|
||||
{
|
||||
return ApiDiscoveredNodeType::Wled;
|
||||
}
|
||||
if lowered.contains("\"native_node\"")
|
||||
|| lowered.contains("\"node_kind\":\"native\"")
|
||||
|| lowered.contains("\"infinity_node\"")
|
||||
{
|
||||
return ApiDiscoveredNodeType::NativeNode;
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
fn extract_probe_hostname(probe: &HttpProbe) -> Option<String> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(&probe.body) {
|
||||
let name = json
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
json.get("info")
|
||||
.and_then(|value| value.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.or_else(|| json.get("mdns").and_then(Value::as_str));
|
||||
if let Some(name) = name {
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probe.headers.get("server").and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send_stream_message(
|
||||
stream: &mut TcpStream,
|
||||
sequence: u64,
|
||||
|
||||
@@ -37,7 +37,9 @@ struct HttpResponse {
|
||||
fn root_and_web_assets_target_the_versioned_api_contract() {
|
||||
let server = start_server();
|
||||
let html = send_http_request(server.local_addr(), "GET", "/", None);
|
||||
let technical_html = send_http_request(server.local_addr(), "GET", "/technical", None);
|
||||
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
|
||||
let technical_js = send_http_request(server.local_addr(), "GET", "/technical.js", None);
|
||||
let styles = send_http_request(server.local_addr(), "GET", "/styles.css", None);
|
||||
|
||||
assert_eq!(html.status_code, 200);
|
||||
@@ -46,19 +48,66 @@ fn root_and_web_assets_target_the_versioned_api_contract() {
|
||||
.get("content-type")
|
||||
.expect("content-type header")
|
||||
.starts_with("text/html"));
|
||||
assert!(html.body.contains("Preset Capture"));
|
||||
assert!(html.body.contains("Mapping Settings"));
|
||||
assert!(html.body.contains("<h2>Preview</h2>"));
|
||||
assert!(html.body.contains("Creative Snapshots"));
|
||||
assert!(html.body.contains("Event Stream"));
|
||||
assert!(html.body.contains("Selected Tile"));
|
||||
assert!(html.body.contains("Utilities"));
|
||||
assert!(html.body.contains("View & Output"));
|
||||
assert!(html.body.contains("Pending Transition"));
|
||||
assert!(html.body.contains("Trigger Transition"));
|
||||
assert!(html.body.contains("session-scope-label"));
|
||||
assert!(html.body.contains("Fade Go"));
|
||||
assert!(html.body.contains("Status & Eventfeed"));
|
||||
assert!(html.body.contains("Mapping Settings"));
|
||||
assert!(!html.body.contains("<h2>Groups</h2>"));
|
||||
assert!(html.body.contains("work-mode-select"));
|
||||
assert!(html.body.contains("LEDs Only"));
|
||||
assert!(!html.body.contains("preview-mode-select"));
|
||||
|
||||
assert_eq!(technical_html.status_code, 200);
|
||||
assert!(technical_html
|
||||
.body
|
||||
.contains("Infinity Vis Mapping Settings"));
|
||||
assert!(technical_html.body.contains("Backend & Output"));
|
||||
assert!(technical_html.body.contains("Node / IP Discovery"));
|
||||
assert!(technical_html.body.contains("Discover / Scan"));
|
||||
assert!(technical_html.body.contains("Node Targets"));
|
||||
assert!(technical_html.body.contains("Panel Mapping"));
|
||||
assert!(technical_html.body.contains("DDP (WLED)"));
|
||||
assert!(technical_html.body.contains("Preview Only"));
|
||||
assert!(technical_html.body.contains("Creative Surface"));
|
||||
|
||||
assert_eq!(app_js.status_code, 200);
|
||||
assert!(app_js.body.contains("/api/v1/state"));
|
||||
assert!(app_js.body.contains("/api/v1/preview"));
|
||||
assert!(app_js.body.contains("save_preset"));
|
||||
assert!(app_js.body.contains("delete_preset"));
|
||||
assert!(app_js.body.contains("save_creative_snapshot"));
|
||||
assert!(app_js.body.contains("show_control_session_required"));
|
||||
assert!(app_js.body.contains("trigger_transition"));
|
||||
assert!(app_js.body.contains("trigger_panel_test"));
|
||||
assert!(app_js.body.contains("commitState"));
|
||||
assert!(app_js.body.contains("Center Pulse"));
|
||||
assert!(app_js.body.contains("Checkerd"));
|
||||
assert!(app_js.body.contains("Wave Line"));
|
||||
assert!(app_js.body.contains("tile-led"));
|
||||
assert!(app_js.body.contains("show_event"));
|
||||
assert!(app_js.body.contains("test_edit"));
|
||||
assert!(app_js.body.contains("direct_mode_active"));
|
||||
assert!(!app_js.body.contains("Tile Colors"));
|
||||
assert!(!app_js.body.contains("Technical"));
|
||||
|
||||
assert_eq!(technical_js.status_code, 200);
|
||||
assert!(technical_js.body.contains("/api/v1/state"));
|
||||
assert!(technical_js.body.contains("set_output_backend_mode"));
|
||||
assert!(technical_js.body.contains("set_live_output_enabled"));
|
||||
assert!(technical_js.body.contains("set_output_fps"));
|
||||
assert!(technical_js.body.contains("set_node_reserved_ip"));
|
||||
assert!(technical_js.body.contains("update_panel_mapping"));
|
||||
assert!(technical_js.body.contains("/api/v1/discovery/scan"));
|
||||
assert!(technical_js.body.contains("runDiscoveryScan"));
|
||||
assert!(technical_js.body.contains("DDP (WLED)"));
|
||||
assert_eq!(styles.status_code, 200);
|
||||
assert!(styles
|
||||
.headers
|
||||
@@ -66,6 +115,9 @@ fn root_and_web_assets_target_the_versioned_api_contract() {
|
||||
.expect("content-type header")
|
||||
.starts_with("text/css"));
|
||||
assert!(styles.body.contains(".preview-grid"));
|
||||
assert!(styles.body.contains(".tile-led-ring"));
|
||||
assert!(styles.body.contains(".technical-workspace"));
|
||||
assert!(styles.body.contains(".technical-table"));
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
@@ -77,9 +129,11 @@ fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() {
|
||||
let mut stream = open_websocket(server.local_addr());
|
||||
|
||||
assert_eq!(html.status_code, 200);
|
||||
assert!(html.body.contains("Infinity Vis"));
|
||||
assert!(html.body.contains("Mapping Settings"));
|
||||
assert!(html.body.contains("<h2>Preview</h2>"));
|
||||
assert!(html.body.contains("connection-pill"));
|
||||
assert!(html.body.contains("preview-grid"));
|
||||
assert!(html.body.contains("workspace-stage"));
|
||||
|
||||
let first_frame = read_websocket_text_frame(&mut stream);
|
||||
let second_frame = read_websocket_text_frame(&mut stream);
|
||||
@@ -108,10 +162,28 @@ fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
|
||||
assert_eq!(state_body["api_version"], "v1");
|
||||
assert!(state_body.get("state").is_some());
|
||||
assert!(state_body.get("preview").is_none());
|
||||
assert_eq!(
|
||||
state_body["state"]["technical"]["backend_mode"],
|
||||
"preview_only"
|
||||
);
|
||||
assert_eq!(state_body["state"]["technical"]["output_enabled"], false);
|
||||
assert_eq!(state_body["state"]["technical"]["output_fps"], 40);
|
||||
assert_eq!(
|
||||
state_body["state"]["nodes"].as_array().map(Vec::len),
|
||||
Some(6)
|
||||
);
|
||||
assert!(state_body["state"]["nodes"]
|
||||
.as_array()
|
||||
.expect("nodes array")
|
||||
.iter()
|
||||
.all(|node| node["connection"] == "offline"
|
||||
&& node["error_status"] == "preview only - live output disabled"));
|
||||
assert!(state_body["state"]["panels"]
|
||||
.as_array()
|
||||
.expect("panels array")
|
||||
.iter()
|
||||
.all(|panel| panel["connection"] == "offline"
|
||||
&& panel["driver_kind"] == "pending_validation"));
|
||||
|
||||
assert_eq!(preview.status_code, 200);
|
||||
assert_eq!(preview_body["api_version"], "v1");
|
||||
@@ -130,6 +202,110 @@ fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn technical_surface_commands_update_backend_node_targets_and_panel_mapping() {
|
||||
let server = start_server();
|
||||
|
||||
let responses = [
|
||||
send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"set_output_backend_mode","payload":{"mode":"ddp_wled"}}}"#,
|
||||
),
|
||||
send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":true}}}"#,
|
||||
),
|
||||
send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"set_output_fps","payload":{"output_fps":55}}}"#,
|
||||
),
|
||||
send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"set_node_reserved_ip","payload":{"node_id":"node-01","reserved_ip":"192.168.40.151"}}}"#,
|
||||
),
|
||||
send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"update_panel_mapping","payload":{"node_id":"node-01","panel_position":"top","physical_output_name":"GPIO 18","driver_kind":"gpio","driver_reference":"GPIO18","led_count":120,"direction":"reverse","color_order":"rgb","enabled":false}}}"#,
|
||||
),
|
||||
];
|
||||
|
||||
for response in responses {
|
||||
assert_eq!(response.status_code, 200);
|
||||
}
|
||||
|
||||
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
|
||||
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
|
||||
|
||||
assert_eq!(state_body["state"]["technical"]["backend_mode"], "ddp_wled");
|
||||
assert_eq!(state_body["state"]["technical"]["output_enabled"], true);
|
||||
assert_eq!(state_body["state"]["technical"]["output_fps"], 55);
|
||||
assert_eq!(
|
||||
state_body["state"]["technical"]["live_status"],
|
||||
"DDP (WLED) armed - 0/6 nodes online"
|
||||
);
|
||||
assert!(state_body["state"]["nodes"]
|
||||
.as_array()
|
||||
.expect("nodes array")
|
||||
.iter()
|
||||
.any(|node| node["node_id"] == "node-01"
|
||||
&& node["reserved_ip"] == "192.168.40.151"
|
||||
&& node["error_status"] == "ddp (wled) output enabled - no live client connected"));
|
||||
assert!(state_body["state"]["panels"]
|
||||
.as_array()
|
||||
.expect("panels array")
|
||||
.iter()
|
||||
.any(|panel| panel["node_id"] == "node-01"
|
||||
&& panel["panel_position"] == "top"
|
||||
&& panel["physical_output_name"] == "GPIO 18"
|
||||
&& panel["driver_kind"] == "gpio"
|
||||
&& panel["driver_reference"] == "GPIO18"
|
||||
&& panel["led_count"] == 120
|
||||
&& panel["direction"] == "reverse"
|
||||
&& panel["color_order"] == "rgb"
|
||||
&& panel["enabled"] == false
|
||||
&& panel["error_status"] == "output disabled"));
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_scan_endpoint_returns_structured_results_and_rejects_invalid_subnets() {
|
||||
let server = start_server();
|
||||
|
||||
let invalid = send_http_request(
|
||||
server.local_addr(),
|
||||
"POST",
|
||||
"/api/v1/discovery/scan",
|
||||
Some(r#"{"subnet":"192.168.40.0"}"#),
|
||||
);
|
||||
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid subnet json");
|
||||
assert_eq!(invalid.status_code, 400);
|
||||
assert_eq!(invalid_body["error"]["code"], "invalid_subnet_cidr");
|
||||
|
||||
let valid = send_http_request(
|
||||
server.local_addr(),
|
||||
"POST",
|
||||
"/api/v1/discovery/scan",
|
||||
Some(r#"{"subnet":"192.168.40.0/30"}"#),
|
||||
);
|
||||
let valid_body: Value = serde_json::from_str(&valid.body).expect("valid subnet json");
|
||||
assert_eq!(valid.status_code, 200);
|
||||
assert_eq!(valid_body["api_version"], "v1");
|
||||
assert_eq!(valid_body["subnet"], "192.168.40.0/30");
|
||||
assert_eq!(valid_body["scanned_hosts"], 2);
|
||||
assert!(valid_body["results"].is_array());
|
||||
if let Some(first) = valid_body["results"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
{
|
||||
assert!(first.get("ip").is_some());
|
||||
assert!(first.get("reachable").is_some());
|
||||
assert!(first.get("detected_type").is_some());
|
||||
}
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_flow_updates_group_parameters_transition_and_blackout() {
|
||||
let server = start_server();
|
||||
@@ -260,6 +436,47 @@ fn presets_and_creative_snapshots_persist_across_restart() {
|
||||
let _ = std::fs::remove_file(runtime_state_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_presets_can_be_deleted_but_builtin_presets_are_protected() {
|
||||
let server = start_server();
|
||||
|
||||
let save = send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"save_preset","payload":{"preset_id":"runtime_delete_me","overwrite":false}}}"#,
|
||||
);
|
||||
assert_eq!(save.status_code, 200);
|
||||
|
||||
let delete_runtime = send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"delete_preset","payload":{"preset_id":"runtime_delete_me"}}}"#,
|
||||
);
|
||||
assert_eq!(delete_runtime.status_code, 200);
|
||||
|
||||
let catalog_after_delete =
|
||||
send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
|
||||
let catalog_body: Value =
|
||||
serde_json::from_str(&catalog_after_delete.body).expect("catalog json");
|
||||
assert!(!catalog_body["presets"]
|
||||
.as_array()
|
||||
.expect("preset array")
|
||||
.iter()
|
||||
.any(|preset| preset["preset_id"] == "runtime_delete_me"));
|
||||
|
||||
let delete_builtin = send_command_json(
|
||||
server.local_addr(),
|
||||
r#"{"command":{"type":"delete_preset","payload":{"preset_id":"ocean_gradient"}}}"#,
|
||||
);
|
||||
let delete_builtin_body: Value =
|
||||
serde_json::from_str(&delete_builtin.body).expect("delete builtin json");
|
||||
assert_eq!(delete_builtin.status_code, 400);
|
||||
assert_eq!(
|
||||
delete_builtin_body["error"]["code"],
|
||||
"preset_delete_forbidden"
|
||||
);
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_control_flows_cover_runtime_group_preset_snapshot_transition_blackout_and_eventfeed() {
|
||||
let server = start_server();
|
||||
|
||||
Reference in New Issue
Block a user