From f7d795bb4b9615e8040b42bbe1c24840fdce4dd8 Mon Sep 17 00:00:00 2001 From: le4f <6771505+le4f@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:01:14 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20BliSwitch=20/=20XH?= =?UTF-8?q?-HK4401=20KVM=20=E5=A4=9A=E8=B7=AF=E8=BE=93=E5=85=A5=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过串口控制 BliSwitch / XH-HK4401 多路 KVM 切换器,遵循官方 BliSwitch v2 控制协议 (19200 波特率;发送 SW{N}\r\nAG{NN}gA 切换输入,解析 G{NN}gA 心跳帧获取当前通道)。 - 新增 src/switch:SwitchController(串口后台工作线程)与运行时类型 - config/schema/switch.rs:SwitchConfig 持久化配置 - runtime/builder.rs:build_switch() 接入 AppState - REST API:/api/switch/status、/api/switch/channel、/api/config/switch、/api/devices/switch - 前端:SwitchPopover(切换+配置)、API 类型与多语言文案 已验证:在 debian:trixie 上 cargo build --release 通过,并在真实 BliSwitch 上 验证切换与读取当前通道正常。 --- src/config/schema/mod.rs | 4 + src/config/schema/switch.rs | 116 ++++++++ src/lib.rs | 2 + src/runtime/builder.rs | 17 ++ src/state.rs | 6 + src/switch/controller.rs | 415 +++++++++++++++++++++++++++ src/switch/mod.rs | 34 +++ src/switch/types.rs | 38 +++ src/web/handlers/config/apply.rs | 34 +++ src/web/handlers/config/mod.rs | 2 + src/web/handlers/config/switch.rs | 68 +++++ src/web/handlers/config/types.rs | 31 ++ src/web/handlers/devices.rs | 5 + src/web/handlers/mod.rs | 2 + src/web/handlers/switch_api.rs | 101 +++++++ src/web/routes.rs | 6 + web/src/api/config.ts | 14 + web/src/api/index.ts | 31 ++ web/src/components/ActionBar.vue | 26 ++ web/src/components/SwitchPopover.vue | 258 +++++++++++++++++ web/src/i18n/en-US.ts | 15 + web/src/i18n/zh-CN.ts | 15 + web/src/types/generated.ts | 28 ++ 23 files changed, 1268 insertions(+) create mode 100644 src/config/schema/switch.rs create mode 100644 src/switch/controller.rs create mode 100644 src/switch/mod.rs create mode 100644 src/switch/types.rs create mode 100644 src/web/handlers/config/switch.rs create mode 100644 src/web/handlers/switch_api.rs create mode 100644 web/src/components/SwitchPopover.vue diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index c20e40e3a..9e7a9c0d8 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -10,6 +10,7 @@ mod computer_use; mod hid; mod otg_network; mod stream; +mod switch; mod uac; mod watchdog; mod web; @@ -20,6 +21,7 @@ pub use computer_use::*; pub use hid::*; pub use otg_network::*; pub use stream::*; +pub use switch::*; pub use uac::*; pub use watchdog::*; pub use web::*; @@ -36,6 +38,7 @@ pub struct AppConfig { pub otg_network: OtgNetworkConfig, pub msd: MsdConfig, pub atx: AtxConfig, + pub switch: SwitchConfig, pub audio: AudioConfig, pub stream: StreamConfig, pub web: WebConfig, @@ -60,6 +63,7 @@ impl AppConfig { self.hid.mouse_absolute = false; } self.atx.normalize(); + self.switch.normalize(); } pub fn apply_platform_defaults(&mut self) { diff --git a/src/config/schema/switch.rs b/src/config/schema/switch.rs new file mode 100644 index 000000000..cc750efd7 --- /dev/null +++ b/src/config/schema/switch.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::switch::{ + SwitchControllerConfig, SWITCH_DEFAULT_BAUD_RATE, SWITCH_DEFAULT_CHANNELS, SWITCH_MAX_CHANNELS, +}; + + +/// BliSwitch / XH-HK4401 KVM 输入切换器的持久化配置。 +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct SwitchConfig { + pub enabled: bool, + /// 切换器所在串口设备(如 `/dev/ttyUSB1`)。 + pub device: String, + pub baud_rate: u32, + /// 可切换的通道数(1..=8)。 + pub channel_count: u8, + /// 各通道的显示名(超出 `channel_count` 的部分忽略)。 + pub channel_names: Vec, +} + +impl Default for SwitchConfig { + fn default() -> Self { + Self { + enabled: false, + device: String::new(), + baud_rate: SWITCH_DEFAULT_BAUD_RATE, + channel_count: SWITCH_DEFAULT_CHANNELS, + channel_names: Vec::new(), + } + } +} + +impl SwitchConfig { + /// 将配置规范化到合法范围。 + pub fn normalize(&mut self) { + if self.device.trim().is_empty() || self.channel_count == 0 { + self.enabled = false; + } + if self.baud_rate == 0 { + self.baud_rate = SWITCH_DEFAULT_BAUD_RATE; + } + if self.channel_count > SWITCH_MAX_CHANNELS { + self.channel_count = SWITCH_MAX_CHANNELS; + } + self.device = self.device.trim().to_string(); + self.channel_names = self + .channel_names + .iter() + .take(self.channel_count as usize) + .cloned() + .collect(); + } + + /// 第 N 路(1 基)通道的显示名。 + pub fn channel_label(&self, channel: u8) -> String { + let idx = (channel as usize).saturating_sub(1); + self.channel_names + .get(idx) + .filter(|name| !name.trim().is_empty()) + .cloned() + .unwrap_or_else(|| format!("Input {}", channel)) + } + + pub fn to_controller_config(&self) -> SwitchControllerConfig { + SwitchControllerConfig { + enabled: self.enabled, + device: self.device.clone(), + baud_rate: self.baud_rate, + channel_count: self.channel_count, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = SwitchConfig::default(); + assert!(!config.enabled); + assert_eq!(config.baud_rate, 19200); + assert_eq!(config.channel_count, 8); + } + + #[test] + fn test_normalize_disables_without_device() { + let mut config = SwitchConfig::default(); + config.enabled = true; + config.normalize(); + assert!(!config.enabled); + } + + #[test] + fn test_normalize_clamps_channels() { + let mut config = SwitchConfig { + enabled: true, + device: "/dev/ttyUSB0".to_string(), + channel_count: 16, + ..Default::default() + }; + config.normalize(); + assert_eq!(config.channel_count, SWITCH_MAX_CHANNELS); + } + + #[test] + fn test_channel_label_uses_name() { + let mut config = SwitchConfig::default(); + config.channel_names = vec!["M2Pro".to_string()]; + assert_eq!(config.channel_label(1), "M2Pro"); + assert_eq!(config.channel_label(2), "Input 2"); + } +} diff --git a/src/lib.rs b/src/lib.rs index a8b8fa932..4501b605e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,8 @@ pub mod stream; #[cfg(feature = "desktop")] pub mod stream_encoder; #[cfg(feature = "desktop")] +pub mod switch; +#[cfg(feature = "desktop")] pub mod update; #[cfg(feature = "desktop")] pub mod utils; diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index 1643a347e..c046f0e9f 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use tokio::sync::broadcast; use crate::atx::AtxController; +use crate::switch::SwitchController; use crate::audio::{AudioController, AudioControllerConfig, AudioQuality}; use crate::auth::{SessionStore, TwoFactorService, UserStore}; use crate::computer_use::ComputerUseManager; @@ -116,6 +117,7 @@ impl RuntimeBuilder { #[cfg(unix)] let msd = build_msd(&config, &data_dir, &otg_service, &events).await; let atx = build_atx(&config).await; + let switch = build_switch(&config).await; let audio = build_audio(&config, &events).await; let extensions = Arc::new(ExtensionManager::new()); tracing::info!("Extension manager initialized"); @@ -168,6 +170,7 @@ impl RuntimeBuilder { #[cfg(unix)] msd, atx, + switch, audio, extensions.clone(), events.clone(), @@ -460,6 +463,20 @@ async fn build_atx(config: &AppConfig) -> Option { Some(controller) } +async fn build_switch(config: &AppConfig) -> Option { + if !config.switch.enabled { + tracing::info!("KVM switch disabled in configuration"); + return None; + } + + let controller = SwitchController::new(config.switch.to_controller_config()); + if let Err(error) = controller.init().await { + tracing::warn!("Failed to initialize KVM switch controller: {}", error); + return None; + } + Some(controller) +} + async fn build_audio(config: &AppConfig, events: &Arc) -> Arc { let quality = config .audio diff --git a/src/state.rs b/src/state.rs index aec21dfe5..64d029f20 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,6 +2,7 @@ use std::{collections::VecDeque, path::PathBuf, sync::Arc}; use tokio::sync::{broadcast, watch, Mutex, RwLock}; use crate::atx::AtxController; +use crate::switch::SwitchController; use crate::audio::AudioController; use crate::auth::{SessionStore, TwoFactorService, UserStore}; use crate::computer_use::ComputerUseManager; @@ -32,6 +33,7 @@ pub struct ConfigApplyLocks { pub otg: Arc>, pub audio: Arc>, pub atx: Arc>, + pub switch: Arc>, pub rustdesk: Arc>, pub vnc: Arc>, pub rtsp: Arc>, @@ -52,6 +54,7 @@ impl ConfigApplyLocks { otg: Arc::new(Mutex::new(())), audio: Arc::new(Mutex::new(())), atx: Arc::new(Mutex::new(())), + switch: Arc::new(Mutex::new(())), rustdesk: Arc::new(Mutex::new(())), vnc: Arc::new(Mutex::new(())), rtsp: Arc::new(Mutex::new(())), @@ -76,6 +79,7 @@ pub struct AppState { #[cfg(unix)] pub msd: Arc>>, pub atx: Arc>>, + pub switch: Arc>>, pub audio: Arc, #[cfg(unix)] pub uac_playback: Arc>>, @@ -107,6 +111,7 @@ impl AppState { computer_use: Arc, #[cfg(unix)] msd: Option, atx: Option, + switch: Option, audio: Arc, extensions: Arc, events: Arc, @@ -153,6 +158,7 @@ impl AppState { #[cfg(unix)] msd, atx: Arc::new(RwLock::new(atx)), + switch: Arc::new(RwLock::new(switch)), audio, usb, remote_access, diff --git a/src/switch/controller.rs b/src/switch/controller.rs new file mode 100644 index 000000000..6a105cd8f --- /dev/null +++ b/src/switch/controller.rs @@ -0,0 +1,415 @@ +//! KVM 切换器控制器 +//! +//! 负责 BliSwitch / XH-HK4401 多路 KVM 切换器的串口控制。后台工作线程持有串口, +//! 解析切换器上报的当前通道心跳帧,并响应通道切换命令。 + +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +use super::types::{SwitchState, SWITCH_DEFAULT_BAUD_RATE}; +use crate::error::{AppError, Result}; + +/// 交换机控制器的运行时配置。 +#[derive(Debug, Clone)] +pub struct SwitchControllerConfig { + pub enabled: bool, + pub device: String, + pub baud_rate: u32, + pub channel_count: u8, +} + +impl Default for SwitchControllerConfig { + fn default() -> Self { + Self { + enabled: false, + device: String::new(), + baud_rate: SWITCH_DEFAULT_BAUD_RATE, + channel_count: 8, + } + } +} + +enum WorkerCommand { + Switch(u8), + Shutdown, +} + +/// 控制器持有的单个工作线程句柄。 +struct WorkerHandles { + cmd_tx: Sender, + join: Option>, + current_channel: Arc, + connected: Arc, + error: Arc>>, +} + +struct SwitchInner { + config: SwitchControllerConfig, + worker: Option, +} + +/// 管理 KVM 切换器串口工作线程,支持配置热重载。 +pub struct SwitchController { + inner: RwLock, +} + +impl SwitchController { + pub fn new(config: SwitchControllerConfig) -> Self { + Self { + inner: RwLock::new(SwitchInner { config, worker: None }), + } + } + + fn spawn_worker(config: &SwitchControllerConfig) -> WorkerHandles { + let (cmd_tx, cmd_rx) = channel::(); + let current_channel = Arc::new(AtomicU8::new(0)); + let connected = Arc::new(AtomicBool::new(false)); + let error = Arc::new(Mutex::new(None)); + + let config = config.clone(); + let worker_current = current_channel.clone(); + let worker_connected = connected.clone(); + let worker_error = error.clone(); + let join = thread::spawn(move || { + run_worker(config, cmd_rx, worker_current, worker_connected, worker_error); + }); + + WorkerHandles { + cmd_tx, + join: Some(join), + current_channel, + connected, + error, + } + } + + async fn stop_worker(inner: &mut SwitchInner) { + if let Some(worker) = inner.worker.take() { + let _ = worker.cmd_tx.send(WorkerCommand::Shutdown); + if let Some(join) = worker.join { + let _ = join.join(); + } + } + } + + pub async fn init(&self) -> Result<()> { + let mut inner = self.inner.write().await; + + if !inner.config.enabled { + info!("KVM switch disabled in configuration"); + return Ok(()); + } + if inner.config.device.trim().is_empty() { + warn!("KVM switch enabled but no serial device configured"); + return Err(AppError::Config( + "KVM switch device cannot be empty".to_string(), + )); + } + + info!( + "Initializing KVM switch controller on {} @ {}", + inner.config.device, inner.config.baud_rate + ); + + Self::stop_worker(&mut inner).await; + inner.worker = Some(Self::spawn_worker(&inner.config)); + info!("KVM switch worker started"); + Ok(()) + } + + pub async fn reload(&self, config: SwitchControllerConfig) -> Result<()> { + let mut inner = self.inner.write().await; + + info!("Reloading KVM switch controller configuration"); + Self::stop_worker(&mut inner).await; + inner.config = config; + + if !inner.config.enabled { + info!("KVM switch disabled after reload"); + return Ok(()); + } + if inner.config.device.trim().is_empty() { + return Err(AppError::Config( + "KVM switch device cannot be empty".to_string(), + )); + } + + inner.worker = Some(Self::spawn_worker(&inner.config)); + info!("KVM switch worker restarted"); + Ok(()) + } + + /// 切换到给定的 1 基通道。 + pub async fn switch_to_channel(&self, channel: u8) -> Result<()> { + let inner = self.inner.read().await; + + let Some(worker) = inner.worker.as_ref() else { + return Err(AppError::Config( + "KVM switch not initialized or disabled".to_string(), + )); + }; + + if channel < 1 || channel > inner.config.channel_count { + return Err(AppError::BadRequest(format!( + "Invalid KVM switch channel: must be 1-{}", + inner.config.channel_count + ))); + } + + if !worker.connected.load(Ordering::Relaxed) { + return Err(AppError::Internal(format!( + "KVM switch serial device {} is not connected", + worker + .error + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| inner.config.device.clone()) + ))); + } + + worker + .cmd_tx + .send(WorkerCommand::Switch(channel)) + .map_err(|_| AppError::Internal("KVM switch worker unavailable".to_string()))?; + + debug!("Requested KVM switch to channel {}", channel); + Ok(()) + } + + /// 生成运行时状态快照。 + pub async fn state(&self) -> SwitchState { + let inner = self.inner.read().await; + let worker = inner.worker.as_ref(); + + let (current_channel, connected, error) = match worker { + Some(worker) => { + let current = worker.current_channel.load(Ordering::Relaxed); + ( + if current == 0 { None } else { Some(current) }, + worker.connected.load(Ordering::Relaxed), + worker.error.lock().unwrap().clone(), + ) + } + None => (None, false, None), + }; + + SwitchState { + available: inner.config.enabled, + connected, + device: inner.config.device.clone(), + baud_rate: inner.config.baud_rate, + channel_count: inner.config.channel_count, + current_channel, + error, + } + } +} + +// --------------------------------------------------------------------------- +// 串口工作线程 +// --------------------------------------------------------------------------- + +fn run_worker( + config: SwitchControllerConfig, + cmd_rx: Receiver, + current_channel: Arc, + connected: Arc, + error: Arc>>, +) { + // 外层循环:打开串口并驱动操作循环;断开后自动重连。 + loop { + let mut port = match serialport::new(&config.device, config.baud_rate) + .timeout(Duration::from_millis(100)) + .open() + { + Ok(port) => port, + Err(e) => { + connected.store(false, Ordering::Relaxed); + *error.lock().unwrap() = Some(format!("Failed to open {}: {}", config.device, e)); + warn!("KVM switch serial open failed: {}", e); + if drain_shutdown(&cmd_rx) { + return; + } + thread::sleep(Duration::from_secs(1)); + continue; + } + }; + + connected.store(true, Ordering::Relaxed); + current_channel.store(0, Ordering::Relaxed); + *error.lock().unwrap() = None; + info!("KVM switch serial {} connected", config.device); + + let mut data: Vec = Vec::new(); + + // 操作循环:读取心跳帧解析当前通道,并处理切换/关闭命令。 + 'operation: loop { + loop { + match cmd_rx.try_recv() { + Ok(WorkerCommand::Shutdown) => { + connected.store(false, Ordering::Relaxed); + info!("KVM switch worker shutting down"); + return; + } + Ok(WorkerCommand::Switch(channel)) => { + send_channel(&mut port, &config, channel, ¤t_channel); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + connected.store(false, Ordering::Relaxed); + return; + } + } + } + + let available = match port.bytes_to_read() { + Ok(n) => n, + Err(e) => { + warn!("KVM switch serial poll error: {}", e); + connected.store(false, Ordering::Relaxed); + *error.lock().unwrap() = Some(format!("Serial poll failed: {}", e)); + break 'operation; + } + }; + if available > 0 { + let mut buf = vec![0u8; available as usize]; + match port.read(&mut buf) { + Ok(read) => { + data.extend_from_slice(&buf[..read]); + if let Some(channel) = parse_current_channel(&data) { + current_channel.store(channel, Ordering::Relaxed); + } + // 仅保留可能跨帧匹配的尾部字节。 + if data.len() > 32 { + let keep = data.len().saturating_sub(16); + data.drain(..keep); + } + } + Err(e) => { + warn!("KVM switch serial read error: {}", e); + connected.store(false, Ordering::Relaxed); + *error.lock().unwrap() = Some(format!("Serial read failed: {}", e)); + break 'operation; + } + } + } + + thread::sleep(Duration::from_millis(20)); + } + + if drain_shutdown(&cmd_rx) { + return; + } + connected.store(false, Ordering::Relaxed); + thread::sleep(Duration::from_secs(1)); + } +} + +/// 发送 1 基通道切换命令,并在协议 2 下乐观更新当前通道。 +fn send_channel( + port: &mut Box, + config: &SwitchControllerConfig, + channel: u8, + current_channel: &Arc, +) { + let cmd = build_switch_cmd(channel); + if let Err(e) = port.write_all(&cmd).and_then(|_| port.flush()) { + warn!("KVM switch write failed: {}", e); + return; + } + debug!("KVM switch command sent for channel {}: {:?}", channel, cmd); +} + +/// 生成切换到 1 基通道的原始字节。 +/// +/// 协议 1:`SW{port}\r\nAG{port:02d}gA`;协议 2:`G{port:02d}gA\x00`。 +pub fn build_switch_cmd(channel: u8) -> Vec { + // BliSwitch / XH-HK4401 协议(V1):切换到第 N 路发送 `SW{N}\r\nAG{NN}gA`。 + format!("SW{}\r\nAG{:02}gA", channel, channel).into_bytes() +} + +/// 从心跳字节流中解析最近一次上报的当前通道。 +/// +/// 切换器以 5 字节帧 `G0{1..8}gA` 上报当前通道(协议 1),协议 2 末尾追加一个 `\x00`; +/// 部分固件还会回显一个前导 `A`(`AG0{1..8}gA`)。这里统一匹配 5 字节的 `G0{1..8}gA` +/// 子帧(与 BliSwitch 官方文档一致,且能匹配实际的 `G03gA` 心跳流)。 +/// +/// 返回当前通道(1 基)。 +fn parse_current_channel(data: &[u8]) -> Option { + let mut channel = None; + let mut i = 0usize; + while i + 5 <= data.len() { + if data[i] == b'G' + && data[i + 1] == b'0' + && (b'1'..=b'8').contains(&data[i + 2]) + && data[i + 3] == b'g' + && data[i + 4] == b'A' + { + channel = Some(data[i + 2] - b'0'); + i += 5; + continue; + } + i += 1; + } + channel +} + +/// 排空待处理命令;收到 Shutdown 时返回 `true`。 +fn drain_shutdown(cmd_rx: &Receiver) -> bool { + loop { + match cmd_rx.try_recv() { + Ok(WorkerCommand::Shutdown) => return true, + Ok(WorkerCommand::Switch(_)) => {} + Err(TryRecvError::Empty) => return false, + Err(TryRecvError::Disconnected) => return true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_switch_cmd_v1() { + assert_eq!( + build_switch_cmd(1), + b"SW1\r\nAG01gA".to_vec() + ); + assert_eq!( + build_switch_cmd(8), + b"SW8\r\nAG08gA".to_vec() + ); + } + + #[test] + fn test_parse_v1_heartbeat() { + assert_eq!(parse_current_channel(b"AG01gA"), Some(1)); + assert_eq!(parse_current_channel(b"G01gA"), Some(1)); + } + + #[test] + fn test_parse_raw_observed_heartbeat() { + // 实际 BliSwitch 线路上观测到的心跳流。 + let data = b"G03gAG03gAG03gAG03gA"; + assert_eq!(parse_current_channel(data), Some(3)); + } + + #[test] + fn test_parse_last_frame_wins() { + let data = b"garbage AG02gA more AG07gA"; + assert_eq!(parse_current_channel(data), Some(7)); + } + + #[test] + fn test_parse_no_match() { + assert_eq!(parse_current_channel(b"nothing"), None); + } +} diff --git a/src/switch/mod.rs b/src/switch/mod.rs new file mode 100644 index 000000000..36fb6fdd5 --- /dev/null +++ b/src/switch/mod.rs @@ -0,0 +1,34 @@ +//! KVM 输入切换器(BliSwitch / XH-HK4401)模块 +//! +//! 通过串口(UART)控制 BliSwitch / XH-HK4401 多路 KVM 切换器,实现最多 8 路输入源 +//! 之间的切换,并读取切换器上报的当前通道。 +//! +//! 通信协议与 BliSwitch v2 官方文档《Control Protocol》一致(波特率 19200): +//! - 切换到第 N 路:发送 `SW{N}\r\nAG{NN}gA`; +//! - 切换器周期性上报当前通道:`G{NN}gA` 帧。 +//! +//! 参见 。 + +mod controller; +mod types; + +pub use controller::{SwitchController, SwitchControllerConfig}; +pub use types::{ + SwitchState, SWITCH_DEFAULT_BAUD_RATE, SWITCH_DEFAULT_CHANNELS, + SWITCH_MAX_CHANNELS, +}; + +/// 返回可用于 KVM 切换器的串口设备列表。 +pub fn available_serial_ports() -> Vec { + crate::utils::list_serial_ports() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_module_exports() { + let _: SwitchState = SwitchState::default(); + } +} diff --git a/src/switch/types.rs b/src/switch/types.rs new file mode 100644 index 000000000..8949d5ab3 --- /dev/null +++ b/src/switch/types.rs @@ -0,0 +1,38 @@ +//! KVM 切换器运行时类型与协议常量。 + +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +/// 默认串口波特率(BliSwitch / XH-HK4401 固定为 19200)。 +pub const SWITCH_DEFAULT_BAUD_RATE: u32 = 19200; +/// 默认通道数(BliSwitch v2 支持 8 路)。 +pub const SWITCH_DEFAULT_CHANNELS: u8 = 8; +/// 协议支持的最大通道数。 +pub const SWITCH_MAX_CHANNELS: u8 = 8; + +/// KVM 切换器的运行时状态。 +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SwitchState { + pub available: bool, + /// 串口设备当前是否已打开并可读。 + pub connected: bool, + pub device: String, + pub baud_rate: u32, + pub channel_count: u8, + /// 当前激活通道(1 基),未知时为 `None`。 + pub current_channel: Option, + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_baud_rate() { + assert_eq!(SWITCH_DEFAULT_BAUD_RATE, 19200); + } + +} diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index bed1afb18..d26f4afd7 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -136,6 +136,40 @@ pub async fn apply_atx_config( Ok(()) } +pub async fn apply_switch_config( + state: &Arc, + _old_config: &SwitchConfig, + new_config: &SwitchConfig, +) -> Result<()> { + tracing::info!("Applying KVM switch config changes..."); + + let controller_config = new_config.to_controller_config(); + + let switch_guard = state.switch.read().await; + if let Some(sw) = switch_guard.as_ref() { + if let Err(e) = sw.reload(controller_config).await { + tracing::error!("KVM switch reload failed: {}", e); + return Err(AppError::Config(format!("KVM switch reload failed: {}", e))); + } + tracing::info!("KVM switch controller reloaded successfully"); + } else { + drop(switch_guard); + + if new_config.enabled { + tracing::info!("KVM switch enabled in config, initializing..."); + + let sw = crate::switch::SwitchController::new(controller_config); + sw.init() + .await + .map_err(|e| AppError::Config(format!("KVM switch initialization failed: {}", e)))?; + *state.switch.write().await = Some(sw); + tracing::info!("KVM switch controller initialized successfully"); + } + } + + Ok(()) +} + pub async fn apply_audio_config( state: &Arc, _old_config: &AudioConfig, diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 8dbfbcf8a..d342f67a1 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -15,6 +15,7 @@ mod redfish; mod rtsp; mod rustdesk; mod stream; +mod switch; #[cfg(unix)] mod uac; mod usb_update; @@ -43,6 +44,7 @@ pub use rustdesk::{ update_rustdesk_config, }; pub use stream::{get_stream_config, update_stream_config}; +pub use switch::{get_switch_config, update_switch_config}; #[cfg(unix)] pub use uac::{get_uac_config, update_uac_config}; pub use video::{get_video_config, update_video_config}; diff --git a/src/web/handlers/config/switch.rs b/src/web/handlers/config/switch.rs new file mode 100644 index 000000000..90f9b3308 --- /dev/null +++ b/src/web/handlers/config/switch.rs @@ -0,0 +1,68 @@ +use axum::{extract::State, Json}; +use std::sync::Arc; + +use crate::config::SwitchConfig; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +use super::apply::{apply_switch_config, try_apply_lock}; +use super::types::SwitchConfigUpdate; + +pub async fn get_switch_config(State(state): State>) -> Json { + Json(state.config.get().switch.clone()) +} + +pub async fn update_switch_config( + State(state): State>, + Json(req): Json, +) -> Result> { + let current_config = state.config.get(); + let old_config = current_config.switch.clone(); + + let mut merged = old_config.clone(); + req.apply_to(&mut merged); + merged.normalize(); + validate_effective_config(&merged)?; + + let _apply_guard = try_apply_lock(&state.config_apply_locks.switch, "switch")?; + + state + .config + .update(|config| { + req.apply_to(&mut config.switch); + }) + .await?; + + let new_config = state.config.get().switch.clone(); + + apply_switch_config(&state, &old_config, &new_config).await?; + + Ok(Json(new_config)) +} + +fn validate_effective_config(config: &SwitchConfig) -> Result<()> { + if !config.enabled { + return Ok(()); + } + + if config.device.trim().is_empty() { + return Err(AppError::BadRequest( + "KVM switch device cannot be empty when enabled".to_string(), + )); + } + + if config.baud_rate == 0 { + return Err(AppError::BadRequest( + "KVM switch baud_rate must be greater than 0".to_string(), + )); + } + + if !cfg!(windows) && !std::path::Path::new(&config.device).exists() { + return Err(AppError::BadRequest(format!( + "KVM switch device '{}' does not exist", + config.device + ))); + } + + Ok(()) +} diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 9e843236a..d3d83aaf3 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -857,6 +857,37 @@ impl AtxConfigUpdate { } } +/// KVM 切换器配置更新请求。 +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct SwitchConfigUpdate { + pub enabled: Option, + pub device: Option, + pub baud_rate: Option, + pub channel_count: Option, + pub channel_names: Option>, +} + +impl SwitchConfigUpdate { + pub fn apply_to(&self, config: &mut SwitchConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + if let Some(ref device) = self.device { + config.device = device.clone(); + } + if let Some(baud_rate) = self.baud_rate { + config.baud_rate = baud_rate; + } + if let Some(channel_count) = self.channel_count { + config.channel_count = channel_count; + } + if let Some(ref names) = self.channel_names { + config.channel_names = names.clone(); + } + } +} + #[typeshare] #[derive(Debug, Deserialize)] pub struct AudioConfigUpdate { diff --git a/src/web/handlers/devices.rs b/src/web/handlers/devices.rs index 6385675c0..93768ed01 100644 --- a/src/web/handlers/devices.rs +++ b/src/web/handlers/devices.rs @@ -12,6 +12,11 @@ pub async fn list_atx_devices() -> Json { Json(discover_devices()) } +/// 列出可用于 KVM 切换器的串口设备。 +pub async fn list_switch_devices() -> Json> { + Json(crate::switch::available_serial_ports()) +} + #[cfg(unix)] pub async fn list_usb_devices() -> Json> { Json(usb_reset::list_usb_devices()) diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 4fd088f61..5a5e8d5c1 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -14,6 +14,7 @@ mod inventory; mod msd_api; mod setup; mod stream; +mod switch_api; mod system; mod update_api; mod webrtc; @@ -29,6 +30,7 @@ pub use inventory::*; pub use msd_api::*; pub use setup::*; pub use stream::*; +pub use switch_api::*; pub use system::*; pub use update_api::*; pub use webrtc::*; diff --git a/src/web/handlers/switch_api.rs b/src/web/handlers/switch_api.rs new file mode 100644 index 000000000..9ed88ac51 --- /dev/null +++ b/src/web/handlers/switch_api.rs @@ -0,0 +1,101 @@ +use super::*; + +use crate::config::SwitchConfig; +use crate::switch::SWITCH_MAX_CHANNELS; + +/// 供前端展示的单路可切换通道描述。 +#[derive(Serialize)] +pub struct SwitchChannel { + pub index: u8, + pub label: String, + pub active: bool, +} + +/// KVM 切换器状态响应。 +#[derive(Serialize)] +pub struct SwitchStatusResponse { + pub available: bool, + pub connected: bool, + pub device: String, + pub baud_rate: u32, + pub channel_count: u8, + pub current_channel: Option, + pub channels: Vec, + pub error: Option, +} + +fn build_channels(config: &SwitchConfig, current: Option) -> Vec { + (1..=config.channel_count.min(SWITCH_MAX_CHANNELS)) + .map(|index| SwitchChannel { + index, + label: config.channel_label(index), + active: current == Some(index), + }) + .collect() +} + +/// 查询 KVM 切换器状态。 +pub async fn switch_status(State(state): State>) -> Json { + let config = state.config.get().switch.clone(); + let guard = state.switch.read().await; + + let (available, connected, device, baud_rate, channel_count, current, error) = + match guard.as_ref() { + Some(sw) => { + let s = sw.state().await; + ( + s.available, + s.connected, + s.device, + s.baud_rate, + s.channel_count, + s.current_channel, + s.error, + ) + } + None => ( + config.enabled, + false, + config.device.clone(), + config.baud_rate, + config.channel_count, + None, + None, + ), + }; + + Json(SwitchStatusResponse { + available, + connected, + device, + baud_rate, + channel_count, + current_channel: current, + channels: build_channels(&config, current), + error, + }) +} + +/// 切换请求体。 +#[derive(Deserialize)] +pub struct SwitchRequest { + pub channel: u8, +} + +/// 切换到指定通道。 +pub async fn switch_channel( + State(state): State>, + Json(req): Json, +) -> Result> { + let guard = state.switch.read().await; + let sw = guard + .as_ref() + .ok_or_else(|| AppError::Internal("KVM switch controller not initialized".to_string()))?; + + sw.switch_to_channel(req.channel).await?; + + Ok(Json(LoginResponse { + success: true, + message: Some(format!("Switched KVM input to channel {}", req.channel)), + })) +} diff --git a/src/web/routes.rs b/src/web/routes.rs index 1f1bd5ca3..b921ed595 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -131,6 +131,8 @@ pub fn create_router(state: Arc) -> Router { .route("/config/hid", patch(handlers::config::update_hid_config)) .route("/config/atx", get(handlers::config::get_atx_config)) .route("/config/atx", patch(handlers::config::update_atx_config)) + .route("/config/switch", get(handlers::config::get_switch_config)) + .route("/config/switch", patch(handlers::config::update_switch_config)) .route("/config/audio", get(handlers::config::get_audio_config)) .route( "/config/audio", @@ -235,8 +237,12 @@ pub fn create_router(state: Arc) -> Router { .route("/atx/power", post(handlers::atx_power)) .route("/atx/wol", post(handlers::atx_wol)) .route("/atx/wol/history", get(handlers::atx_wol_history)) + // KVM switch endpoints + .route("/switch/status", get(handlers::switch_status)) + .route("/switch/channel", post(handlers::switch_channel)) // Device discovery endpoints .route("/devices/atx", get(handlers::devices::list_atx_devices)) + .route("/devices/switch", get(handlers::devices::list_switch_devices)) // Extension management endpoints .route("/extensions", get(handlers::extensions::list_extensions)) .route("/extensions/{id}", get(handlers::extensions::get_extension)) diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 0ac22d275..9ed9201d4 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -19,6 +19,8 @@ import type { AtxConfig, AtxConfigUpdate, AtxDevices, + SwitchConfig, + SwitchConfigUpdate, AudioConfig, AudioConfigUpdate, ExtensionsStatus, @@ -167,6 +169,18 @@ export const atxConfigApi = { request(`/atx/wol/history?limit=${Math.max(1, Math.min(50, limit))}`), } +export const switchConfigApi = { + get: () => request('/config/switch'), + + update: (config: SwitchConfigUpdate) => + request('/config/switch', { + method: 'PATCH', + body: JSON.stringify(config), + }), + + listDevices: () => request('/devices/switch'), +} + export const audioConfigApi = { get: () => request('/config/audio'), diff --git a/web/src/api/index.ts b/web/src/api/index.ts index f955a4261..9bb4482a8 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -569,6 +569,34 @@ export const atxApi = { }), } +export interface SwitchChannel { + index: number + label: string + active: boolean +} + +export interface SwitchStatus { + available: boolean + connected: boolean + device: string + baud_rate: number + channel_count: number + current_channel: number | null + channels: SwitchChannel[] + error: string | null +} + +export const switchApi = { + status: () => + request('/switch/status', {}, { toastOnError: false }), + + switchChannel: (channel: number) => + request<{ success: boolean; message?: string }>('/switch/channel', { + method: 'POST', + body: JSON.stringify({ channel }), + }), +} + export interface MsdImage { id: string name: string @@ -837,6 +865,7 @@ export { otgNetworkApi, uacApi, atxConfigApi, + switchConfigApi, audioConfigApi, extensionsApi, redfishConfigApi, @@ -876,6 +905,8 @@ export type { MsdConfigUpdate, AtxConfig, AtxConfigUpdate, + SwitchConfig, + SwitchConfigUpdate, AudioConfig, AudioConfigUpdate, HidBackend, diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index 6bcee8578..0a80fcd40 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -43,9 +43,11 @@ import { ChevronDown, Keyboard, Scaling, + MonitorCog, } from 'lucide-vue-next' import PasteModal from '@/components/PasteModal.vue' import AtxPopover from '@/components/AtxPopover.vue' +import SwitchPopover from '@/components/SwitchPopover.vue' import VideoConfigPopover, { type VideoMode } from '@/components/VideoConfigPopover.vue' import HidConfigPopover from '@/components/HidConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue' @@ -116,6 +118,7 @@ const emit = defineEmits<{ const pasteOpen = ref(false) const atxOpen = ref(false) +const switchOpen = ref(false) const videoPopoverOpen = ref(false) const hidPopoverOpen = ref(false) const audioPopoverOpen = ref(false) @@ -467,6 +470,29 @@ const hasRightOverflow = computed(() => { + + + + + + + + + +
diff --git a/web/src/components/SwitchPopover.vue b/web/src/components/SwitchPopover.vue new file mode 100644 index 000000000..aa3d62a19 --- /dev/null +++ b/web/src/components/SwitchPopover.vue @@ -0,0 +1,258 @@ + + + diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 7479fb386..246546f62 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -318,6 +318,21 @@ export default { recentMac: 'Recent', wolFailed: 'Failed to send WOL packet', }, + kvmSwitch: { + title: 'KVM Switch', + actionbar: 'Input Switch', + switch: 'Switch', + currentInput: 'Current Input', + notConfigured: 'KVM switch is not configured', + notConnected: 'Serial device not connected', + config: 'Configuration', + enabled: 'Enable input switching', + device: 'Serial device', + selectDevice: 'Select serial device...', + baudRate: 'Baud rate', + channelCount: 'Channels', + noDevice: 'No serial devices found', + }, setup: { welcome: 'Welcome to One-KVM', description: 'Complete the initial setup to get started', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 442a7e1ba..50b63b977 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -318,6 +318,21 @@ export default { recentMac: '最近使用', wolFailed: 'WOL 发送失败', }, + kvmSwitch: { + title: 'KVM 切换', + actionbar: '输入切换', + switch: '切换', + currentInput: '当前输入', + notConfigured: 'KVM 切换器尚未配置', + notConnected: '串口设备未连接', + config: '配置', + enabled: '启用输入切换', + device: '串口设备', + selectDevice: '选择串口设备...', + baudRate: '波特率', + channelCount: '通道数', + noDevice: '未找到串口设备', + }, setup: { welcome: '欢迎使用 One-KVM', description: '请完成初始设置以开始使用', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index aa3387e3f..b604bc3e5 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -137,6 +137,24 @@ export interface AtxConfig { wol_interface: string; } +export interface SwitchConfig { + enabled: boolean; + device: string; + baud_rate: number; + channel_count: number; + channel_names: string[]; +} + +export interface SwitchState { + available: boolean; + connected: boolean; + device: string; + baud_rate: number; + channel_count: number; + current_channel?: number; + error?: string; +} + export interface AudioConfig { enabled: boolean; device: string; @@ -328,6 +346,7 @@ export interface AppConfig { otg_network: OtgNetworkConfig; msd: MsdConfig; atx: AtxConfig; + switch: SwitchConfig; audio: AudioConfig; stream: StreamConfig; web: WebConfig; @@ -381,6 +400,15 @@ export interface AtxDevices { serial_ports: string[]; } +/** KVM 切换器配置更新请求 */ +export interface SwitchConfigUpdate { + enabled?: boolean; + device?: string; + baud_rate?: number; + channel_count?: number; + channel_names?: string[]; +} + export interface AudioConfigUpdate { enabled?: boolean; device?: string;