feat(hid): 支持 CH9329 + OTG 组合模式

CH9329 负责键鼠,OTG 提供虚拟媒体、USB 网络和音频;分别配置设备、报告运行状态,并在 OTG 配置变更和回滚时保留未变化的 CH9329 实例。

补齐双设备校验、串口冲突检查、初始化引导和功能入口,修复组合模式下鼠标兼容选项缺失。

验证:独立功能版本的 591 项 Rust 库测试、前端生产构建、3 项引导逻辑测试及 2 组浏览器回归通过。未进行目标 KVM 硬件验证。
This commit is contained in:
mofeng-git
2026-10-26 14:33:38 +08:00
parent b4161f3534
commit f303a8cb2f
30 changed files with 718 additions and 225 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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)]

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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"]),

View File

@@ -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)]

View File

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

View File

@@ -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]

View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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!({