mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-11-01 18:30:51 +08:00
feat(hid): 支持 CH9329 + OTG 组合模式
CH9329 负责键鼠,OTG 提供虚拟媒体、USB 网络和音频;分别配置设备、报告运行状态,并在 OTG 配置变更和回滚时保留未变化的 CH9329 实例。 补齐双设备校验、串口冲突检查、初始化引导和功能入口,修复组合模式下鼠标兼容选项缺失。 验证:独立功能版本的 591 项 Rust 库测试、前端生产构建、3 项引导逻辑测试及 2 组浏览器回归通过。未进行目标 KVM 硬件验证。
This commit is contained in:
@@ -56,11 +56,37 @@ impl BluetoothHidConfig {
|
||||
pub enum HidBackend {
|
||||
Otg,
|
||||
Ch9329,
|
||||
#[serde(rename = "ch9329_otg")]
|
||||
Ch9329Otg,
|
||||
Bluetooth,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl HidBackend {
|
||||
pub fn uses_ch9329(&self) -> bool {
|
||||
matches!(self, Self::Ch9329 | Self::Ch9329Otg)
|
||||
}
|
||||
|
||||
pub fn uses_otg_hid(&self) -> bool {
|
||||
matches!(self, Self::Otg)
|
||||
}
|
||||
|
||||
pub fn supports_otg_functions(&self) -> bool {
|
||||
matches!(self, Self::Otg | Self::Ch9329Otg)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Otg => "otg",
|
||||
Self::Ch9329 => "ch9329",
|
||||
Self::Ch9329Otg => "ch9329_otg",
|
||||
Self::Bluetooth => "bluetooth",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OtgDescriptorConfig {
|
||||
@@ -141,6 +167,15 @@ pub struct OtgHidFunctions {
|
||||
}
|
||||
|
||||
impl OtgHidFunctions {
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
keyboard: false,
|
||||
mouse_relative: false,
|
||||
mouse_absolute: false,
|
||||
consumer: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full() -> Self {
|
||||
Self {
|
||||
keyboard: true,
|
||||
@@ -264,15 +299,49 @@ impl HidConfig {
|
||||
}
|
||||
|
||||
pub fn effective_otg_keyboard_leds(&self) -> bool {
|
||||
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
|
||||
self.otg_keyboard_leds && self.constrained_otg_functions().keyboard
|
||||
}
|
||||
|
||||
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
|
||||
self.effective_otg_functions()
|
||||
if self.backend.uses_otg_hid() {
|
||||
self.effective_otg_functions()
|
||||
} else {
|
||||
OtgHidFunctions::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_platform(&self) -> crate::error::Result<()> {
|
||||
if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 + OTG requires Linux with a USB device controller".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_composite_selection(&self) -> crate::error::Result<()> {
|
||||
if self.backend != HidBackend::Ch9329Otg {
|
||||
return Ok(());
|
||||
}
|
||||
if self.ch9329_port.trim().is_empty()
|
||||
|| ![9600, 19200, 38400, 57600, 115200].contains(&self.ch9329_baudrate)
|
||||
{
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 + OTG requires a serial port and a supported baudrate".into(),
|
||||
));
|
||||
}
|
||||
if !self.otg_udc.as_ref().is_some_and(|udc| {
|
||||
!udc.trim().is_empty() && !udc.contains('/') && udc != "." && udc != ".."
|
||||
}) {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 + OTG requires an explicit USB device controller".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
if !self.backend.uses_otg_hid() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -293,7 +362,7 @@ impl HidConfig {
|
||||
|
||||
#[inline]
|
||||
pub fn resolved_otg_udc(&self) -> Option<String> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
if !self.backend.supports_otg_functions() {
|
||||
return None;
|
||||
}
|
||||
self.otg_udc
|
||||
@@ -301,6 +370,9 @@ impl HidConfig {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
if !self.backend.uses_otg_hid() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
crate::otg::OtgGadgetManager::find_udc()
|
||||
@@ -316,6 +388,53 @@ impl HidConfig {
|
||||
#[cfg(test)]
|
||||
mod bluetooth_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn composite_mode_preserves_usb_functions_and_saved_otg_profile() {
|
||||
let mut config = crate::config::AppConfig::default();
|
||||
config.hid.backend = HidBackend::Ch9329Otg;
|
||||
config.hid.otg_profile = OtgHidProfile::LegacyKeyboard;
|
||||
config.hid.otg_keyboard_leds = true;
|
||||
config.hid.mouse_macos_drag = true;
|
||||
config.msd.enabled = true;
|
||||
config.otg_network.enabled = true;
|
||||
config.uac.enabled = true;
|
||||
config.enforce_invariants();
|
||||
assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled);
|
||||
assert!(config.hid.constrained_otg_functions().is_empty());
|
||||
assert!(!config.hid.effective_otg_keyboard_leds());
|
||||
assert!(config.hid.validate_otg_functions().is_ok());
|
||||
let saved = serde_json::to_value(&config.hid).unwrap();
|
||||
assert_eq!(saved["backend"], "ch9329_otg");
|
||||
assert_eq!(saved["otg_profile"], "legacy_keyboard");
|
||||
config.hid = serde_json::from_value(saved).unwrap();
|
||||
config.hid.backend = HidBackend::Otg;
|
||||
assert!(config.hid.constrained_otg_functions().keyboard);
|
||||
assert!(config.hid.effective_otg_keyboard_leds());
|
||||
config.hid.backend = HidBackend::Ch9329;
|
||||
config.enforce_invariants();
|
||||
assert!(!config.msd.enabled && !config.otg_network.enabled && !config.uac.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_selection_requires_both_devices_without_auto_udc_fallback() {
|
||||
let mut config = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config.validate_composite_selection().is_err());
|
||||
assert!(config.resolved_otg_udc().is_none());
|
||||
config.otg_udc = Some("test.udc".into());
|
||||
assert!(config.validate_composite_selection().is_ok());
|
||||
assert_eq!(config.resolved_otg_udc().as_deref(), Some("test.udc"));
|
||||
for udc in ["", "../other", "/sys/class/udc/test", ".."] {
|
||||
config.otg_udc = Some(udc.into());
|
||||
assert!(config.validate_composite_selection().is_err());
|
||||
}
|
||||
config.otg_udc = Some("test.udc".into());
|
||||
config.ch9329_port.clear();
|
||||
assert!(config.validate_composite_selection().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_compatibility_defaults_alias_and_otg_validation() {
|
||||
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
||||
|
||||
@@ -51,7 +51,7 @@ pub struct AppConfig {
|
||||
|
||||
impl AppConfig {
|
||||
pub fn enforce_invariants(&mut self) {
|
||||
if self.hid.backend != HidBackend::Otg {
|
||||
if !self.hid.backend.supports_otg_functions() {
|
||||
self.msd.enabled = false;
|
||||
self.otg_network.enabled = false;
|
||||
self.uac.enabled = false;
|
||||
|
||||
@@ -37,6 +37,27 @@ pub enum HidBackendType {
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<&crate::config::HidConfig> for HidBackendType {
|
||||
fn from(config: &crate::config::HidConfig) -> Self {
|
||||
use crate::config::HidBackend;
|
||||
match config.backend {
|
||||
HidBackend::Otg => Self::Otg {
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Ch9329 | HidBackend::Ch9329Otg => Self::Ch9329 {
|
||||
port: config.ch9329_port.clone(),
|
||||
baud_rate: config.ch9329_baudrate,
|
||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Bluetooth => Self::Bluetooth {
|
||||
config: config.bluetooth.clone(),
|
||||
},
|
||||
HidBackend::None => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HidBackendType {
|
||||
pub fn name_str(&self) -> &str {
|
||||
match self {
|
||||
|
||||
@@ -767,6 +767,60 @@ fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
|
||||
#[cfg(test)]
|
||||
mod queue_tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn composite_otg_updates_and_rollback_preserve_hid_instance() {
|
||||
let mut old = crate::config::AppConfig::default();
|
||||
old.hid.backend = crate::config::HidBackend::Ch9329;
|
||||
old.enforce_invariants();
|
||||
let otg = Arc::new(OtgService::new());
|
||||
let controller = Arc::new(HidController::new(
|
||||
HidBackendType::from(&old.hid),
|
||||
Some(otg.clone()),
|
||||
));
|
||||
let pressed = Arc::new(AtomicBool::new(true));
|
||||
let (runtime, _) = tokio::sync::watch::channel(());
|
||||
let backend: Arc<dyn HidBackend> = Arc::new(TestBackend {
|
||||
pressed: pressed.clone(),
|
||||
reset_done: Arc::new(tokio::sync::Notify::new()),
|
||||
runtime,
|
||||
});
|
||||
*controller.backend.write().await = Some(backend.clone());
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let coordinator = crate::runtime::UsbCoordinator::new(
|
||||
controller.clone(),
|
||||
otg,
|
||||
Arc::new(RwLock::new(None)),
|
||||
Arc::new(RwLock::new(None)),
|
||||
Arc::new(EventBus::new()),
|
||||
data.path().into(),
|
||||
);
|
||||
let mut new = old.clone();
|
||||
new.hid.backend = crate::config::HidBackend::Ch9329Otg;
|
||||
new.hid.otg_udc = Some("test.udc".into());
|
||||
new.hid.otg_descriptor.product = "Advanced USB".into();
|
||||
new.hid.otg_keyboard_leds = true;
|
||||
coordinator.apply_config(&old, &new).await.unwrap();
|
||||
assert!(Arc::ptr_eq(
|
||||
controller.backend.read().await.as_ref().unwrap(),
|
||||
&backend
|
||||
));
|
||||
coordinator.apply_config(&new, &old).await.unwrap();
|
||||
assert!(Arc::ptr_eq(
|
||||
controller.backend.read().await.as_ref().unwrap(),
|
||||
&backend
|
||||
));
|
||||
assert!(pressed.load(Ordering::Acquire));
|
||||
// Invalid OTG configuration must not touch the healthy input backend.
|
||||
new.uac.enabled = true;
|
||||
new.uac.sample_rate = 8000;
|
||||
assert!(coordinator.apply_config(&old, &new).await.is_err());
|
||||
assert!(Arc::ptr_eq(
|
||||
controller.backend.read().await.as_ref().unwrap(),
|
||||
&backend
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -149,8 +149,11 @@ fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool {
|
||||
|
||||
/// OTG self-check status for troubleshooting USB gadget issues
|
||||
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg);
|
||||
let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled;
|
||||
let hid_backend_is_otg = config.hid.backend.uses_otg_hid();
|
||||
let gadget_expected = hid_backend_is_otg
|
||||
|| config.msd.enabled
|
||||
|| config.otg_network.enabled
|
||||
|| config.uac.enabled;
|
||||
let mut checks = Vec::new();
|
||||
|
||||
let build_response = |checks: Vec<OtgSelfCheckItem>,
|
||||
@@ -173,7 +176,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
overall_ok: error_count == 0,
|
||||
error_count,
|
||||
warning_count,
|
||||
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
|
||||
hid_backend: config.hid.backend.as_str().to_string(),
|
||||
selected_udc,
|
||||
bound_udc,
|
||||
udc_state,
|
||||
@@ -415,7 +418,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Warn
|
||||
},
|
||||
"Check one-kvm gadget presence",
|
||||
Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"),
|
||||
Some("Enable OTG HID, MSD, USB Ethernet, or USB audio to create the one-kvm gadget"),
|
||||
Some(one_kvm_path.display().to_string()),
|
||||
);
|
||||
}
|
||||
@@ -459,7 +462,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
false,
|
||||
OtgSelfCheckLevel::Warn,
|
||||
"Check one-kvm UDC binding",
|
||||
Some("Ensure HID/MSD is enabled and initialized successfully"),
|
||||
Some("Ensure an OTG function is enabled and initialized successfully"),
|
||||
Some(one_kvm_udc_path.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
@@ -494,18 +497,19 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if hid_functions.is_empty() {
|
||||
let hid_functions_ok = hid_backend_is_otg != hid_functions.is_empty();
|
||||
if !hid_functions_ok {
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
"hid_functions_present",
|
||||
false,
|
||||
if hid_backend_is_otg {
|
||||
OtgSelfCheckLevel::Error
|
||||
OtgSelfCheckLevel::Error,
|
||||
"Check OTG HID function selection",
|
||||
Some(if hid_backend_is_otg {
|
||||
"Check OTG HID config and enable at least one HID function"
|
||||
} else {
|
||||
OtgSelfCheckLevel::Warn
|
||||
},
|
||||
"Check HID function creation",
|
||||
Some("Check OTG HID config and enable at least one HID function"),
|
||||
"OTG HID must be disabled when input is handled by another backend"
|
||||
}),
|
||||
Some(functions_path.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
@@ -514,7 +518,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
"hid_functions_present",
|
||||
true,
|
||||
OtgSelfCheckLevel::Info,
|
||||
"Check HID function creation",
|
||||
"Check OTG HID function selection",
|
||||
None::<String>,
|
||||
Some(functions_path.display().to_string()),
|
||||
);
|
||||
@@ -569,9 +573,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
})
|
||||
.is_some_and(|name| {
|
||||
name == "okvm-br0" || name == config.otg_network.bridge_interface
|
||||
name == "okvm-br0"
|
||||
|| name == config.otg_network.bridge_interface
|
||||
|| (config.otg_network.bridge_interface.is_empty()
|
||||
&& std::path::Path::new("/sys/class/net").join(name).join("bridge").is_dir())
|
||||
&& std::path::Path::new("/sys/class/net")
|
||||
.join(name)
|
||||
.join("bridge")
|
||||
.is_dir())
|
||||
});
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
@@ -843,7 +851,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
overall_ok: error_count == 0,
|
||||
error_count,
|
||||
warning_count,
|
||||
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
|
||||
hid_backend: config.hid.backend.as_str().to_string(),
|
||||
selected_udc,
|
||||
bound_udc,
|
||||
udc_state,
|
||||
|
||||
@@ -8,8 +8,7 @@ use super::bridge::NetworkBridgeRuntime;
|
||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
|
||||
use crate::config::{
|
||||
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
||||
UacConfig,
|
||||
HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig,
|
||||
};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
@@ -40,6 +39,17 @@ pub struct OtgNetworkStatus {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct OtgRuntimeStatus {
|
||||
pub enabled: bool,
|
||||
pub health: OtgRuntimeHealth,
|
||||
pub gadget_active: bool,
|
||||
pub udc: Option<String>,
|
||||
pub udc_state: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl HidDevicePaths {
|
||||
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
||||
[
|
||||
@@ -93,7 +103,7 @@ impl OtgDesiredState {
|
||||
network.validate()?;
|
||||
uac.validate()?;
|
||||
msd.validate()?;
|
||||
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||
let hid_functions = if hid.backend.uses_otg_hid() {
|
||||
let functions = hid.constrained_otg_functions();
|
||||
Some(functions)
|
||||
} else {
|
||||
@@ -107,7 +117,13 @@ impl OtgDesiredState {
|
||||
.as_ref()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(OtgGadgetManager::find_udc)
|
||||
.or_else(|| {
|
||||
if hid.backend.uses_ch9329() {
|
||||
None
|
||||
} else {
|
||||
OtgGadgetManager::find_udc()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -249,6 +265,25 @@ impl OtgService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn runtime_status(&self, enabled: bool) -> OtgRuntimeStatus {
|
||||
let state = self.state.read().await;
|
||||
let udc_state = state.configured_udc.as_ref().and_then(|udc| {
|
||||
// Only UDC names, never a path supplied through a configuration file.
|
||||
if udc.contains('/') || udc == "." || udc == ".." {
|
||||
return None;
|
||||
}
|
||||
crate::utils::read_trimmed(&PathBuf::from(format!("/sys/class/udc/{udc}/state")))
|
||||
});
|
||||
OtgRuntimeStatus {
|
||||
enabled,
|
||||
health: state.health,
|
||||
gadget_active: state.gadget_active,
|
||||
udc: state.configured_udc.clone(),
|
||||
udc_state,
|
||||
error: state.error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_config(
|
||||
&self,
|
||||
hid: &HidConfig,
|
||||
@@ -687,6 +722,38 @@ impl From<&OtgDescriptorConfig> for GadgetDescriptor {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::HidBackend;
|
||||
|
||||
#[test]
|
||||
fn composite_gadget_supports_every_advanced_function_combination_without_hid() {
|
||||
let hid = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
otg_udc: Some("test.udc".into()),
|
||||
otg_keyboard_leds: true,
|
||||
..Default::default()
|
||||
};
|
||||
for mask in 0..8 {
|
||||
let msd = MsdConfig {
|
||||
enabled: mask & 1 != 0,
|
||||
..Default::default()
|
||||
};
|
||||
let network = OtgNetworkConfig {
|
||||
enabled: mask & 2 != 0,
|
||||
..Default::default()
|
||||
};
|
||||
let uac = UacConfig {
|
||||
enabled: mask & 4 != 0,
|
||||
..Default::default()
|
||||
};
|
||||
let desired = OtgDesiredState::from_config(&hid, &msd, &network, &uac).unwrap();
|
||||
assert!(!desired.hid_enabled());
|
||||
assert!(!desired.keyboard_leds);
|
||||
assert_eq!(desired.msd_enabled, msd.enabled);
|
||||
assert_eq!(desired.network_enabled(), network.enabled);
|
||||
assert_eq!(desired.uac.enabled, uac.enabled);
|
||||
assert_eq!(desired.udc.is_some(), mask != 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_new_and_availability_probe() {
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn capabilities() -> PlatformCapabilities {
|
||||
encoder: FeatureCapability::available([
|
||||
"software", "vaapi", "nvenc", "qsv", "amf", "rkmpp", "v4l2m2m",
|
||||
]),
|
||||
hid: FeatureCapability::available(["otg", "ch9329", "bluetooth", "none"]),
|
||||
hid: FeatureCapability::available(["otg", "ch9329", "ch9329_otg", "bluetooth", "none"]),
|
||||
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
|
||||
msd: FeatureCapability::available(["configfs"]),
|
||||
otg: FeatureCapability::available(["configfs"]),
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::sync::broadcast;
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||
use crate::auth::{SessionStore, TwoFactorService, UserStore};
|
||||
use crate::config::{self, AppConfig, ConfigStore};
|
||||
use crate::config::{AppConfig, ConfigStore};
|
||||
use crate::db::{open_database_pool, DatabasePool};
|
||||
use crate::events::EventBus;
|
||||
use crate::extensions::ExtensionManager;
|
||||
@@ -81,6 +81,7 @@ impl RuntimeBuilder {
|
||||
web_overrides,
|
||||
} = self;
|
||||
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
|
||||
config.hid.validate_platform()?;
|
||||
web_overrides.apply(&mut config);
|
||||
|
||||
let sessions = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||
@@ -400,26 +401,13 @@ async fn build_otg(config: &AppConfig) -> Arc<OtgService> {
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to apply OTG config: {}", error);
|
||||
service.mark_degraded(error.to_string()).await;
|
||||
}
|
||||
service
|
||||
}
|
||||
|
||||
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
|
||||
match config.hid.backend {
|
||||
config::HidBackend::Otg => HidBackendType::Otg {
|
||||
macos_drag: config.hid.mouse_macos_drag,
|
||||
},
|
||||
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.hid.ch9329_port.clone(),
|
||||
baud_rate: config.hid.ch9329_baudrate,
|
||||
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
||||
macos_drag: config.hid.mouse_macos_drag,
|
||||
},
|
||||
config::HidBackend::None => HidBackendType::None,
|
||||
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||
config: config.hid.bluetooth.clone(),
|
||||
},
|
||||
}
|
||||
HidBackendType::from(&config.hid)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -51,24 +51,31 @@ impl UsbCoordinator {
|
||||
}
|
||||
|
||||
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> {
|
||||
new_config.hid.validate_platform()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
|
||||
&& new_config.hid.backend != HidBackend::Otg;
|
||||
let hid_unchanged = old_config.hid == new_config.hid;
|
||||
let gadget_rebuilt = old_config.msd != new_config.msd
|
||||
|| old_config.otg_network != new_config.otg_network
|
||||
|| old_config.uac != new_config.uac
|
||||
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|
||||
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|
||||
|| old_config.hid.backend != new_config.hid.backend
|
||||
|| old_config.hid.constrained_otg_functions()
|
||||
!= new_config.hid.constrained_otg_functions()
|
||||
|| old_config.hid.effective_otg_keyboard_leds()
|
||||
!= new_config.hid.effective_otg_keyboard_leds();
|
||||
let hid_unchanged = !hid_requires_reload(&old_config.hid, &new_config.hid);
|
||||
let gadget_rebuilt = crate::otg::service::OtgDesiredState::from_config(
|
||||
&old_config.hid,
|
||||
&old_config.msd,
|
||||
&old_config.otg_network,
|
||||
&old_config.uac,
|
||||
)? != crate::otg::service::OtgDesiredState::from_config(
|
||||
&new_config.hid,
|
||||
&new_config.msd,
|
||||
&new_config.otg_network,
|
||||
&new_config.uac,
|
||||
)?;
|
||||
let restart_uac =
|
||||
old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt);
|
||||
|
||||
if gadget_rebuilt {
|
||||
// This is a no-op for CH9329, including the composite mode.
|
||||
self.hid.prepare_otg_rebuild().await?;
|
||||
}
|
||||
|
||||
if restart_uac {
|
||||
let playback = self.uac_playback.write().await.take();
|
||||
if let Some(playback) = playback {
|
||||
@@ -167,29 +174,11 @@ impl UsbCoordinator {
|
||||
uac_config: &UacConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
new_config.validate_platform()?;
|
||||
new_config.validate_otg_functions()?;
|
||||
new_config.bluetooth.validate()?;
|
||||
|
||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||
let hid_functions_changed =
|
||||
old_config.constrained_otg_functions() != new_config.constrained_otg_functions();
|
||||
let keyboard_leds_changed =
|
||||
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
||||
let mouse_compatibility_changed = old_config.ch9329_hybrid_mouse
|
||||
!= new_config.ch9329_hybrid_mouse
|
||||
|| old_config.mouse_macos_drag != new_config.mouse_macos_drag;
|
||||
|
||||
if old_config.backend == new_config.backend
|
||||
&& old_config.ch9329_port == new_config.ch9329_port
|
||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||
&& old_config.bluetooth == new_config.bluetooth
|
||||
&& !mouse_compatibility_changed
|
||||
&& old_config.otg_udc == new_config.otg_udc
|
||||
&& !descriptor_changed
|
||||
&& !hid_functions_changed
|
||||
&& !keyboard_leds_changed
|
||||
&& !options.force
|
||||
{
|
||||
if !hid_requires_reload(old_config, new_config) && !options.force {
|
||||
tracing::info!("HID config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -256,7 +245,7 @@ impl UsbCoordinator {
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
let old_enabled = old_config.enabled;
|
||||
let new_enabled = new_config.enabled && hid_config.backend == HidBackend::Otg;
|
||||
let new_enabled = new_config.enabled && hid_config.backend.supports_otg_functions();
|
||||
let directory_changed = old_config.msd_dir != new_config.msd_dir;
|
||||
let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string
|
||||
|| old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string;
|
||||
@@ -309,28 +298,72 @@ impl UsbCoordinator {
|
||||
}
|
||||
|
||||
fn hid_backend_type(config: &HidConfig) -> HidBackendType {
|
||||
match config.backend {
|
||||
HidBackend::Otg => HidBackendType::Otg {
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.ch9329_port.clone(),
|
||||
baud_rate: config.ch9329_baudrate,
|
||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::None => HidBackendType::None,
|
||||
HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||
config: config.bluetooth.clone(),
|
||||
},
|
||||
}
|
||||
HidBackendType::from(config)
|
||||
}
|
||||
|
||||
fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool {
|
||||
old_config.backend == HidBackend::Otg
|
||||
|| new_config.backend == HidBackend::Otg
|
||||
|| old_config.otg_udc != new_config.otg_udc
|
||||
|| old_config.otg_descriptor != new_config.otg_descriptor
|
||||
|| old_config.constrained_otg_functions() != new_config.constrained_otg_functions()
|
||||
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
|
||||
fn hid_requires_reload(old: &HidConfig, new: &HidConfig) -> bool {
|
||||
HidBackendType::from(old) != HidBackendType::from(new)
|
||||
|| (new.backend.uses_otg_hid() && hid_otg_config_changed(old, new))
|
||||
}
|
||||
|
||||
fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool {
|
||||
old.backend.uses_otg_hid() != new.backend.uses_otg_hid()
|
||||
|| old.otg_udc != new.otg_udc
|
||||
|| old.otg_descriptor != new.otg_descriptor
|
||||
|| old.constrained_otg_functions() != new.constrained_otg_functions()
|
||||
|| old.effective_otg_keyboard_leds() != new.effective_otg_keyboard_leds()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() {
|
||||
let old = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
otg_udc: Some("first.udc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut new = old.clone();
|
||||
new.otg_udc = Some("second.udc".into());
|
||||
new.otg_descriptor.product = "Advanced USB".into();
|
||||
new.otg_keyboard_leds = !old.otg_keyboard_leds;
|
||||
new.otg_profile = crate::config::OtgHidProfile::LegacyKeyboard;
|
||||
assert!(!hid_requires_reload(&old, &new));
|
||||
assert!(!hid_requires_reload(&new, &old));
|
||||
|
||||
let input_changes: [fn(&mut HidConfig); 4] = [
|
||||
|hid: &mut HidConfig| hid.ch9329_port.push('1'),
|
||||
|hid: &mut HidConfig| hid.ch9329_baudrate = 115200,
|
||||
|hid: &mut HidConfig| hid.ch9329_hybrid_mouse = !hid.ch9329_hybrid_mouse,
|
||||
|hid: &mut HidConfig| hid.mouse_macos_drag = !hid.mouse_macos_drag,
|
||||
];
|
||||
for change in input_changes {
|
||||
let mut changed = old.clone();
|
||||
change(&mut changed);
|
||||
assert!(hid_requires_reload(&old, &changed));
|
||||
assert!(hid_requires_reload(&changed, &old));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_mode_switch_only_reloads_when_input_driver_changes() {
|
||||
let composite = HidConfig {
|
||||
backend: HidBackend::Ch9329Otg,
|
||||
otg_udc: Some("test.udc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
for (backend, reload) in [
|
||||
(HidBackend::Ch9329, false),
|
||||
(HidBackend::Otg, true),
|
||||
(HidBackend::Bluetooth, true),
|
||||
(HidBackend::None, true),
|
||||
] {
|
||||
let mut other = composite.clone();
|
||||
other.backend = backend;
|
||||
assert_eq!(hid_requires_reload(&composite, &other), reload);
|
||||
assert_eq!(hid_requires_reload(&other, &composite), reload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::atx::AtxDriverType;
|
||||
use crate::config::{AtxConfig, HidBackend, HidConfig};
|
||||
use crate::config::{AtxConfig, HidConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -17,12 +17,13 @@ pub async fn update_atx_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AtxConfigUpdate>,
|
||||
) -> Result<Json<AtxConfig>> {
|
||||
let _usb_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
|
||||
let current_config = state.config.get();
|
||||
let old_atx_config = current_config.atx.clone();
|
||||
|
||||
req.validate_with_current(&old_atx_config)?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
|
||||
let mut merged_atx_config = old_atx_config.clone();
|
||||
req.apply_to(&mut merged_atx_config);
|
||||
validate_windows_atx_backends(&merged_atx_config)?;
|
||||
@@ -56,8 +57,8 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
||||
if hid.backend != HidBackend::Ch9329 {
|
||||
pub(super) fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
||||
if !hid.backend.uses_ch9329() {
|
||||
return Ok(());
|
||||
}
|
||||
let reserved = hid.ch9329_port.trim();
|
||||
@@ -78,6 +79,7 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::HidBackend;
|
||||
|
||||
#[test]
|
||||
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
|
||||
@@ -90,6 +92,8 @@ mod tests {
|
||||
hid.ch9329_port = "/dev/ttyUSB0".to_string();
|
||||
|
||||
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
||||
hid.backend = HidBackend::Ch9329Otg;
|
||||
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
|
||||
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig, UacConfig};
|
||||
use crate::error::Result;
|
||||
use crate::otg::OtgNetworkStatus;
|
||||
use crate::web::state::UsbApiState;
|
||||
@@ -16,6 +16,7 @@ pub struct OtgConfigResponse {
|
||||
pub hid: HidConfig,
|
||||
pub msd: MsdConfig,
|
||||
pub network: OtgNetworkConfig,
|
||||
pub uac: UacConfig,
|
||||
pub status: OtgNetworkStatus,
|
||||
}
|
||||
|
||||
@@ -48,6 +49,10 @@ pub(super) async fn update_otg_config_inner(
|
||||
if let Some(ref update) = request.network {
|
||||
update.apply_to(&mut staged.otg_network);
|
||||
}
|
||||
if let Some(ref uac) = request.uac {
|
||||
uac.validate()?;
|
||||
staged.uac = uac.clone();
|
||||
}
|
||||
|
||||
Ok(requested_ch9329_descriptor)
|
||||
})
|
||||
@@ -57,6 +62,7 @@ pub(super) async fn update_otg_config_inner(
|
||||
hid: staged_config.hid,
|
||||
msd: staged_config.msd,
|
||||
network: staged_config.otg_network,
|
||||
uac: staged_config.uac,
|
||||
status: state.otg.network_status().await,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -427,6 +427,7 @@ pub struct OtgConfigUpdate {
|
||||
pub hid: Option<HidConfigUpdate>,
|
||||
pub msd: Option<MsdConfigUpdate>,
|
||||
pub network: Option<OtgNetworkConfigUpdate>,
|
||||
pub uac: Option<crate::config::UacConfig>,
|
||||
}
|
||||
|
||||
impl HidConfigUpdate {
|
||||
|
||||
@@ -47,6 +47,21 @@ where
|
||||
let requested_ch9329_descriptor = stage_update(&mut staged_config)?;
|
||||
|
||||
staged_config.enforce_invariants();
|
||||
staged_config.hid.validate_platform()?;
|
||||
staged_config.hid.validate_composite_selection()?;
|
||||
#[cfg(unix)]
|
||||
if staged_config.hid.backend == HidBackend::Ch9329Otg
|
||||
&& (old_config.hid.backend != staged_config.hid.backend
|
||||
|| old_config.hid.otg_udc != staged_config.hid.otg_udc)
|
||||
&& !crate::otg::list_udc_devices()
|
||||
.iter()
|
||||
.any(|udc| Some(udc) == staged_config.hid.otg_udc.as_ref())
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Selected USB device controller is missing".into(),
|
||||
));
|
||||
}
|
||||
super::atx::validate_serial_device_conflict(&staged_config.atx, &staged_config.hid)?;
|
||||
staged_config.hid.validate_otg_functions()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -179,7 +194,7 @@ where
|
||||
}
|
||||
|
||||
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
|
||||
if staged_config.hid.backend == HidBackend::Ch9329 {
|
||||
if staged_config.hid.backend.uses_ch9329() {
|
||||
match state.hid.apply_ch9329_descriptor(descriptor).await {
|
||||
Ok(actual) => {
|
||||
staged_config.hid.ch9329_descriptor = actual.descriptor;
|
||||
@@ -250,7 +265,7 @@ async fn rollback_after_failure(
|
||||
{
|
||||
rollback_errors.push(format!("runtime rollback failed: {error}"));
|
||||
}
|
||||
if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 {
|
||||
if restore_descriptor && old_config.hid.backend.uses_ch9329() {
|
||||
if let Err(error) = state
|
||||
.hid
|
||||
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
|
||||
@@ -276,6 +291,31 @@ mod tests {
|
||||
use crate::config::HidBackend;
|
||||
use crate::web::handlers::config::types::Ch9329DescriptorConfigUpdate;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn composite_advanced_features_can_be_submitted_in_one_usb_transaction() {
|
||||
let request: super::super::types::OtgConfigUpdate = serde_json::from_value(serde_json::json!({
|
||||
"hid": { "backend": "ch9329_otg", "ch9329_port": "/dev/ttyUSB1", "otg_udc": "test.udc" },
|
||||
"msd": { "enabled": true },
|
||||
"network": { "enabled": true },
|
||||
"uac": { "enabled": true, "sample_rate": 48000, "channels": 2 }
|
||||
})).unwrap();
|
||||
let mut config = AppConfig::default();
|
||||
stage_hid_config_update(&mut config.hid, request.hid.as_ref().unwrap()).unwrap();
|
||||
request.msd.as_ref().unwrap().apply_to(&mut config.msd);
|
||||
request
|
||||
.network
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.apply_to(&mut config.otg_network);
|
||||
config.uac = request.uac.unwrap();
|
||||
config.enforce_invariants();
|
||||
assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled);
|
||||
assert!(config.hid.constrained_otg_functions().is_empty());
|
||||
assert_eq!(config.hid.ch9329_port, "/dev/ttyUSB1");
|
||||
assert_eq!(config.hid.otg_udc.as_deref(), Some("test.udc"));
|
||||
}
|
||||
|
||||
fn hid_update() -> HidConfigUpdate {
|
||||
HidConfigUpdate {
|
||||
bluetooth_reset_pairing: None,
|
||||
|
||||
@@ -11,6 +11,9 @@ pub struct Ch9329DescriptorQuery {
|
||||
pub struct HidStatus {
|
||||
pub available: bool,
|
||||
pub backend: String,
|
||||
pub configured_backend: String,
|
||||
#[cfg(unix)]
|
||||
pub otg: crate::otg::service::OtgRuntimeStatus,
|
||||
pub initialized: bool,
|
||||
pub online: bool,
|
||||
pub supports_absolute_mouse: bool,
|
||||
@@ -34,9 +37,21 @@ pub async fn hid_otg_self_check(
|
||||
/// Get HID status
|
||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||
let hid = state.hid.snapshot().await;
|
||||
let config = state.config.get();
|
||||
Json(HidStatus {
|
||||
available: hid.available,
|
||||
backend: hid.backend,
|
||||
configured_backend: config.hid.backend.as_str().to_string(),
|
||||
#[cfg(unix)]
|
||||
otg: state
|
||||
.otg_service
|
||||
.runtime_status(
|
||||
config.hid.backend.uses_otg_hid()
|
||||
|| config.msd.enabled
|
||||
|| config.otg_network.enabled
|
||||
|| config.uac.enabled,
|
||||
)
|
||||
.await,
|
||||
initialized: hid.initialized,
|
||||
online: hid.online,
|
||||
supports_absolute_mouse: hid.supports_absolute_mouse,
|
||||
|
||||
@@ -69,6 +69,8 @@ impl SetupRequest {
|
||||
if let Some(enabled) = self.hid_otg_keyboard_leds {
|
||||
hid.otg_keyboard_leds = enabled;
|
||||
}
|
||||
hid.validate_platform()?;
|
||||
hid.validate_composite_selection()?;
|
||||
hid.bluetooth.validate()?;
|
||||
hid.validate_otg_functions()?;
|
||||
Ok(hid)
|
||||
@@ -305,6 +307,21 @@ mod tests {
|
||||
assert!(hid.effective_otg_keyboard_leds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn setup_applies_composite_selection_to_both_usb_paths() {
|
||||
let req = request(json!({
|
||||
"hid_backend": "ch9329_otg", "hid_ch9329_port": "/dev/ttyUSB2",
|
||||
"hid_ch9329_baudrate": 115200, "hid_otg_udc": "test.udc"
|
||||
}));
|
||||
let hid = req.hid_config(&HidConfig::default()).unwrap();
|
||||
assert_eq!(hid.backend, HidBackend::Ch9329Otg);
|
||||
assert_eq!(hid.ch9329_port, "/dev/ttyUSB2");
|
||||
assert_eq!(hid.ch9329_baudrate, 115200);
|
||||
assert_eq!(hid.otg_udc.as_deref(), Some("test.udc"));
|
||||
assert!(hid.constrained_otg_functions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_applies_selected_ch9329_port_and_baudrate() {
|
||||
let req = request(json!({
|
||||
|
||||
Reference in New Issue
Block a user