mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +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 {
|
pub enum HidBackend {
|
||||||
Otg,
|
Otg,
|
||||||
Ch9329,
|
Ch9329,
|
||||||
|
#[serde(rename = "ch9329_otg")]
|
||||||
|
Ch9329Otg,
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
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]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct OtgDescriptorConfig {
|
pub struct OtgDescriptorConfig {
|
||||||
@@ -141,6 +167,15 @@ pub struct OtgHidFunctions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OtgHidFunctions {
|
impl OtgHidFunctions {
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
keyboard: false,
|
||||||
|
mouse_relative: false,
|
||||||
|
mouse_absolute: false,
|
||||||
|
consumer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn full() -> Self {
|
pub fn full() -> Self {
|
||||||
Self {
|
Self {
|
||||||
keyboard: true,
|
keyboard: true,
|
||||||
@@ -264,15 +299,49 @@ impl HidConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_otg_keyboard_leds(&self) -> bool {
|
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 {
|
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<()> {
|
pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
|
||||||
if self.backend != HidBackend::Otg {
|
if !self.backend.uses_otg_hid() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +362,7 @@ impl HidConfig {
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn resolved_otg_udc(&self) -> Option<String> {
|
pub fn resolved_otg_udc(&self) -> Option<String> {
|
||||||
if self.backend != HidBackend::Otg {
|
if !self.backend.supports_otg_functions() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.otg_udc
|
self.otg_udc
|
||||||
@@ -301,6 +370,9 @@ impl HidConfig {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
|
if !self.backend.uses_otg_hid() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
crate::otg::OtgGadgetManager::find_udc()
|
crate::otg::OtgGadgetManager::find_udc()
|
||||||
@@ -316,6 +388,53 @@ impl HidConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod bluetooth_tests {
|
mod bluetooth_tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn mouse_compatibility_defaults_alias_and_otg_validation() {
|
fn mouse_compatibility_defaults_alias_and_otg_validation() {
|
||||||
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn enforce_invariants(&mut self) {
|
pub fn enforce_invariants(&mut self) {
|
||||||
if self.hid.backend != HidBackend::Otg {
|
if !self.hid.backend.supports_otg_functions() {
|
||||||
self.msd.enabled = false;
|
self.msd.enabled = false;
|
||||||
self.otg_network.enabled = false;
|
self.otg_network.enabled = false;
|
||||||
self.uac.enabled = false;
|
self.uac.enabled = false;
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ pub enum HidBackendType {
|
|||||||
None,
|
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 {
|
impl HidBackendType {
|
||||||
pub fn name_str(&self) -> &str {
|
pub fn name_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -767,6 +767,60 @@ fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod queue_tests {
|
mod queue_tests {
|
||||||
use super::*;
|
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]
|
#[tokio::test]
|
||||||
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
|
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
|
||||||
#[cfg(unix)]
|
#[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
|
/// OTG self-check status for troubleshooting USB gadget issues
|
||||||
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||||
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg);
|
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;
|
let gadget_expected = hid_backend_is_otg
|
||||||
|
|| config.msd.enabled
|
||||||
|
|| config.otg_network.enabled
|
||||||
|
|| config.uac.enabled;
|
||||||
let mut checks = Vec::new();
|
let mut checks = Vec::new();
|
||||||
|
|
||||||
let build_response = |checks: Vec<OtgSelfCheckItem>,
|
let build_response = |checks: Vec<OtgSelfCheckItem>,
|
||||||
@@ -173,7 +176,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
overall_ok: error_count == 0,
|
overall_ok: error_count == 0,
|
||||||
error_count,
|
error_count,
|
||||||
warning_count,
|
warning_count,
|
||||||
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
|
hid_backend: config.hid.backend.as_str().to_string(),
|
||||||
selected_udc,
|
selected_udc,
|
||||||
bound_udc,
|
bound_udc,
|
||||||
udc_state,
|
udc_state,
|
||||||
@@ -415,7 +418,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
OtgSelfCheckLevel::Warn
|
OtgSelfCheckLevel::Warn
|
||||||
},
|
},
|
||||||
"Check one-kvm gadget presence",
|
"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()),
|
Some(one_kvm_path.display().to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -459,7 +462,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
false,
|
false,
|
||||||
OtgSelfCheckLevel::Warn,
|
OtgSelfCheckLevel::Warn,
|
||||||
"Check one-kvm UDC binding",
|
"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()),
|
Some(one_kvm_udc_path.display().to_string()),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -494,18 +497,19 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.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(
|
push_otg_check(
|
||||||
&mut checks,
|
&mut checks,
|
||||||
"hid_functions_present",
|
"hid_functions_present",
|
||||||
false,
|
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 {
|
} else {
|
||||||
OtgSelfCheckLevel::Warn
|
"OTG HID must be disabled when input is handled by another backend"
|
||||||
},
|
}),
|
||||||
"Check HID function creation",
|
|
||||||
Some("Check OTG HID config and enable at least one HID function"),
|
|
||||||
Some(functions_path.display().to_string()),
|
Some(functions_path.display().to_string()),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -514,7 +518,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
"hid_functions_present",
|
"hid_functions_present",
|
||||||
true,
|
true,
|
||||||
OtgSelfCheckLevel::Info,
|
OtgSelfCheckLevel::Info,
|
||||||
"Check HID function creation",
|
"Check OTG HID function selection",
|
||||||
None::<String>,
|
None::<String>,
|
||||||
Some(functions_path.display().to_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())
|
.map(|name| name.to_string_lossy().to_string())
|
||||||
})
|
})
|
||||||
.is_some_and(|name| {
|
.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()
|
|| (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(
|
push_otg_check(
|
||||||
&mut checks,
|
&mut checks,
|
||||||
@@ -843,7 +851,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
overall_ok: error_count == 0,
|
overall_ok: error_count == 0,
|
||||||
error_count,
|
error_count,
|
||||||
warning_count,
|
warning_count,
|
||||||
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
|
hid_backend: config.hid.backend.as_str().to_string(),
|
||||||
selected_udc,
|
selected_udc,
|
||||||
bound_udc,
|
bound_udc,
|
||||||
udc_state,
|
udc_state,
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use super::bridge::NetworkBridgeRuntime;
|
|||||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||||
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
|
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig,
|
||||||
UacConfig,
|
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
@@ -40,6 +39,17 @@ pub struct OtgNetworkStatus {
|
|||||||
pub error: Option<String>,
|
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 {
|
impl HidDevicePaths {
|
||||||
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
||||||
[
|
[
|
||||||
@@ -93,7 +103,7 @@ impl OtgDesiredState {
|
|||||||
network.validate()?;
|
network.validate()?;
|
||||||
uac.validate()?;
|
uac.validate()?;
|
||||||
msd.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();
|
let functions = hid.constrained_otg_functions();
|
||||||
Some(functions)
|
Some(functions)
|
||||||
} else {
|
} else {
|
||||||
@@ -107,7 +117,13 @@ impl OtgDesiredState {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.or_else(OtgGadgetManager::find_udc)
|
.or_else(|| {
|
||||||
|
if hid.backend.uses_ch9329() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
OtgGadgetManager::find_udc()
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
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(
|
pub async fn apply_config(
|
||||||
&self,
|
&self,
|
||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
@@ -687,6 +722,38 @@ impl From<&OtgDescriptorConfig> for GadgetDescriptor {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn service_new_and_availability_probe() {
|
fn service_new_and_availability_probe() {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub fn capabilities() -> PlatformCapabilities {
|
|||||||
encoder: FeatureCapability::available([
|
encoder: FeatureCapability::available([
|
||||||
"software", "vaapi", "nvenc", "qsv", "amf", "rkmpp", "v4l2m2m",
|
"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"]),
|
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
|
||||||
msd: FeatureCapability::available(["configfs"]),
|
msd: FeatureCapability::available(["configfs"]),
|
||||||
otg: FeatureCapability::available(["configfs"]),
|
otg: FeatureCapability::available(["configfs"]),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tokio::sync::broadcast;
|
|||||||
use crate::atx::AtxController;
|
use crate::atx::AtxController;
|
||||||
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||||
use crate::auth::{SessionStore, TwoFactorService, UserStore};
|
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::db::{open_database_pool, DatabasePool};
|
||||||
use crate::events::EventBus;
|
use crate::events::EventBus;
|
||||||
use crate::extensions::ExtensionManager;
|
use crate::extensions::ExtensionManager;
|
||||||
@@ -81,6 +81,7 @@ impl RuntimeBuilder {
|
|||||||
web_overrides,
|
web_overrides,
|
||||||
} = self;
|
} = self;
|
||||||
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
|
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
|
||||||
|
config.hid.validate_platform()?;
|
||||||
web_overrides.apply(&mut config);
|
web_overrides.apply(&mut config);
|
||||||
|
|
||||||
let sessions = SessionStore::new(config.auth.session_timeout_secs as i64);
|
let sessions = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||||
@@ -400,26 +401,13 @@ async fn build_otg(config: &AppConfig) -> Arc<OtgService> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to apply OTG config: {}", error);
|
tracing::warn!("Failed to apply OTG config: {}", error);
|
||||||
|
service.mark_degraded(error.to_string()).await;
|
||||||
}
|
}
|
||||||
service
|
service
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
|
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
|
||||||
match config.hid.backend {
|
HidBackendType::from(&config.hid)
|
||||||
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(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -51,24 +51,31 @@ impl UsbCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> {
|
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> {
|
||||||
|
new_config.hid.validate_platform()?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
|
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
|
||||||
&& new_config.hid.backend != HidBackend::Otg;
|
&& new_config.hid.backend != HidBackend::Otg;
|
||||||
let hid_unchanged = old_config.hid == new_config.hid;
|
let hid_unchanged = !hid_requires_reload(&old_config.hid, &new_config.hid);
|
||||||
let gadget_rebuilt = old_config.msd != new_config.msd
|
let gadget_rebuilt = crate::otg::service::OtgDesiredState::from_config(
|
||||||
|| old_config.otg_network != new_config.otg_network
|
&old_config.hid,
|
||||||
|| old_config.uac != new_config.uac
|
&old_config.msd,
|
||||||
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|
&old_config.otg_network,
|
||||||
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|
&old_config.uac,
|
||||||
|| old_config.hid.backend != new_config.hid.backend
|
)? != crate::otg::service::OtgDesiredState::from_config(
|
||||||
|| old_config.hid.constrained_otg_functions()
|
&new_config.hid,
|
||||||
!= new_config.hid.constrained_otg_functions()
|
&new_config.msd,
|
||||||
|| old_config.hid.effective_otg_keyboard_leds()
|
&new_config.otg_network,
|
||||||
!= new_config.hid.effective_otg_keyboard_leds();
|
&new_config.uac,
|
||||||
|
)?;
|
||||||
let restart_uac =
|
let restart_uac =
|
||||||
old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt);
|
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 {
|
if restart_uac {
|
||||||
let playback = self.uac_playback.write().await.take();
|
let playback = self.uac_playback.write().await.take();
|
||||||
if let Some(playback) = playback {
|
if let Some(playback) = playback {
|
||||||
@@ -167,29 +174,11 @@ impl UsbCoordinator {
|
|||||||
uac_config: &UacConfig,
|
uac_config: &UacConfig,
|
||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
new_config.validate_platform()?;
|
||||||
new_config.validate_otg_functions()?;
|
new_config.validate_otg_functions()?;
|
||||||
new_config.bluetooth.validate()?;
|
new_config.bluetooth.validate()?;
|
||||||
|
|
||||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
if !hid_requires_reload(old_config, new_config) && !options.force {
|
||||||
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
|
|
||||||
{
|
|
||||||
tracing::info!("HID config unchanged, skipping reload");
|
tracing::info!("HID config unchanged, skipping reload");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -256,7 +245,7 @@ impl UsbCoordinator {
|
|||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let old_enabled = old_config.enabled;
|
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 directory_changed = old_config.msd_dir != new_config.msd_dir;
|
||||||
let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string
|
let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string
|
||||||
|| old_config.cdrom_inquiry_string != new_config.cdrom_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 {
|
fn hid_backend_type(config: &HidConfig) -> HidBackendType {
|
||||||
match config.backend {
|
HidBackendType::from(config)
|
||||||
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(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool {
|
fn hid_requires_reload(old: &HidConfig, new: &HidConfig) -> bool {
|
||||||
old_config.backend == HidBackend::Otg
|
HidBackendType::from(old) != HidBackendType::from(new)
|
||||||
|| new_config.backend == HidBackend::Otg
|
|| (new.backend.uses_otg_hid() && hid_otg_config_changed(old, new))
|
||||||
|| 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()
|
fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool {
|
||||||
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use crate::atx::AtxDriverType;
|
use crate::atx::AtxDriverType;
|
||||||
use crate::config::{AtxConfig, HidBackend, HidConfig};
|
use crate::config::{AtxConfig, HidConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -17,12 +17,13 @@ pub async fn update_atx_config(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<AtxConfigUpdate>,
|
Json(req): Json<AtxConfigUpdate>,
|
||||||
) -> Result<Json<AtxConfig>> {
|
) -> 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 current_config = state.config.get();
|
||||||
let old_atx_config = current_config.atx.clone();
|
let old_atx_config = current_config.atx.clone();
|
||||||
|
|
||||||
req.validate_with_current(&old_atx_config)?;
|
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();
|
let mut merged_atx_config = old_atx_config.clone();
|
||||||
req.apply_to(&mut merged_atx_config);
|
req.apply_to(&mut merged_atx_config);
|
||||||
validate_windows_atx_backends(&merged_atx_config)?;
|
validate_windows_atx_backends(&merged_atx_config)?;
|
||||||
@@ -56,8 +57,8 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
pub(super) fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
||||||
if hid.backend != HidBackend::Ch9329 {
|
if !hid.backend.uses_ch9329() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let reserved = hid.ch9329_port.trim();
|
let reserved = hid.ch9329_port.trim();
|
||||||
@@ -78,6 +79,7 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::HidBackend;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
|
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
|
||||||
@@ -90,6 +92,8 @@ mod tests {
|
|||||||
hid.ch9329_port = "/dev/ttyUSB0".to_string();
|
hid.ch9329_port = "/dev/ttyUSB0".to_string();
|
||||||
|
|
||||||
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
||||||
|
hid.backend = HidBackend::Ch9329Otg;
|
||||||
|
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
|
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig, UacConfig};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::otg::OtgNetworkStatus;
|
use crate::otg::OtgNetworkStatus;
|
||||||
use crate::web::state::UsbApiState;
|
use crate::web::state::UsbApiState;
|
||||||
@@ -16,6 +16,7 @@ pub struct OtgConfigResponse {
|
|||||||
pub hid: HidConfig,
|
pub hid: HidConfig,
|
||||||
pub msd: MsdConfig,
|
pub msd: MsdConfig,
|
||||||
pub network: OtgNetworkConfig,
|
pub network: OtgNetworkConfig,
|
||||||
|
pub uac: UacConfig,
|
||||||
pub status: OtgNetworkStatus,
|
pub status: OtgNetworkStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +49,10 @@ pub(super) async fn update_otg_config_inner(
|
|||||||
if let Some(ref update) = request.network {
|
if let Some(ref update) = request.network {
|
||||||
update.apply_to(&mut staged.otg_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)
|
Ok(requested_ch9329_descriptor)
|
||||||
})
|
})
|
||||||
@@ -57,6 +62,7 @@ pub(super) async fn update_otg_config_inner(
|
|||||||
hid: staged_config.hid,
|
hid: staged_config.hid,
|
||||||
msd: staged_config.msd,
|
msd: staged_config.msd,
|
||||||
network: staged_config.otg_network,
|
network: staged_config.otg_network,
|
||||||
|
uac: staged_config.uac,
|
||||||
status: state.otg.network_status().await,
|
status: state.otg.network_status().await,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ pub struct OtgConfigUpdate {
|
|||||||
pub hid: Option<HidConfigUpdate>,
|
pub hid: Option<HidConfigUpdate>,
|
||||||
pub msd: Option<MsdConfigUpdate>,
|
pub msd: Option<MsdConfigUpdate>,
|
||||||
pub network: Option<OtgNetworkConfigUpdate>,
|
pub network: Option<OtgNetworkConfigUpdate>,
|
||||||
|
pub uac: Option<crate::config::UacConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidConfigUpdate {
|
impl HidConfigUpdate {
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ where
|
|||||||
let requested_ch9329_descriptor = stage_update(&mut staged_config)?;
|
let requested_ch9329_descriptor = stage_update(&mut staged_config)?;
|
||||||
|
|
||||||
staged_config.enforce_invariants();
|
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()?;
|
staged_config.hid.validate_otg_functions()?;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -179,7 +194,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
|
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 {
|
match state.hid.apply_ch9329_descriptor(descriptor).await {
|
||||||
Ok(actual) => {
|
Ok(actual) => {
|
||||||
staged_config.hid.ch9329_descriptor = actual.descriptor;
|
staged_config.hid.ch9329_descriptor = actual.descriptor;
|
||||||
@@ -250,7 +265,7 @@ async fn rollback_after_failure(
|
|||||||
{
|
{
|
||||||
rollback_errors.push(format!("runtime rollback failed: {error}"));
|
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
|
if let Err(error) = state
|
||||||
.hid
|
.hid
|
||||||
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
|
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
|
||||||
@@ -276,6 +291,31 @@ mod tests {
|
|||||||
use crate::config::HidBackend;
|
use crate::config::HidBackend;
|
||||||
use crate::web::handlers::config::types::Ch9329DescriptorConfigUpdate;
|
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 {
|
fn hid_update() -> HidConfigUpdate {
|
||||||
HidConfigUpdate {
|
HidConfigUpdate {
|
||||||
bluetooth_reset_pairing: None,
|
bluetooth_reset_pairing: None,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ pub struct Ch9329DescriptorQuery {
|
|||||||
pub struct HidStatus {
|
pub struct HidStatus {
|
||||||
pub available: bool,
|
pub available: bool,
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
|
pub configured_backend: String,
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub otg: crate::otg::service::OtgRuntimeStatus,
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
pub supports_absolute_mouse: bool,
|
pub supports_absolute_mouse: bool,
|
||||||
@@ -34,9 +37,21 @@ pub async fn hid_otg_self_check(
|
|||||||
/// Get HID status
|
/// Get HID status
|
||||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||||
let hid = state.hid.snapshot().await;
|
let hid = state.hid.snapshot().await;
|
||||||
|
let config = state.config.get();
|
||||||
Json(HidStatus {
|
Json(HidStatus {
|
||||||
available: hid.available,
|
available: hid.available,
|
||||||
backend: hid.backend,
|
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,
|
initialized: hid.initialized,
|
||||||
online: hid.online,
|
online: hid.online,
|
||||||
supports_absolute_mouse: hid.supports_absolute_mouse,
|
supports_absolute_mouse: hid.supports_absolute_mouse,
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ impl SetupRequest {
|
|||||||
if let Some(enabled) = self.hid_otg_keyboard_leds {
|
if let Some(enabled) = self.hid_otg_keyboard_leds {
|
||||||
hid.otg_keyboard_leds = enabled;
|
hid.otg_keyboard_leds = enabled;
|
||||||
}
|
}
|
||||||
|
hid.validate_platform()?;
|
||||||
|
hid.validate_composite_selection()?;
|
||||||
hid.bluetooth.validate()?;
|
hid.bluetooth.validate()?;
|
||||||
hid.validate_otg_functions()?;
|
hid.validate_otg_functions()?;
|
||||||
Ok(hid)
|
Ok(hid)
|
||||||
@@ -305,6 +307,21 @@ mod tests {
|
|||||||
assert!(hid.effective_otg_keyboard_leds());
|
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]
|
#[test]
|
||||||
fn setup_applies_selected_ch9329_port_and_baudrate() {
|
fn setup_applies_selected_ch9329_port_and_baudrate() {
|
||||||
let req = request(json!({
|
let req = request(json!({
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const systemApi = {
|
|||||||
video_width?: number
|
video_width?: number
|
||||||
video_height?: number
|
video_height?: number
|
||||||
video_fps?: number
|
video_fps?: number
|
||||||
hid_backend?: 'otg' | 'ch9329' | 'bluetooth' | 'none'
|
hid_backend?: 'otg' | 'ch9329' | 'ch9329_otg' | 'bluetooth' | 'none'
|
||||||
hid_bluetooth?: { adapter: string; name: string }
|
hid_bluetooth?: { adapter: string; name: string }
|
||||||
hid_ch9329_port?: string
|
hid_ch9329_port?: string
|
||||||
hid_ch9329_baudrate?: number
|
hid_ch9329_baudrate?: number
|
||||||
@@ -411,6 +411,8 @@ export const hidApi = {
|
|||||||
request<{
|
request<{
|
||||||
available: boolean
|
available: boolean
|
||||||
backend: string
|
backend: string
|
||||||
|
configured_backend: string
|
||||||
|
otg?: import('@/lib/hidGuide').OtgDeviceStatus
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
online: boolean
|
online: boolean
|
||||||
supports_absolute_mouse: boolean
|
supports_absolute_mouse: boolean
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ const systemStore = useSystemStore()
|
|||||||
|
|
||||||
const overflowMenuOpen = ref(false)
|
const overflowMenuOpen = ref(false)
|
||||||
|
|
||||||
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
|
const showMsd = computed(() => !!systemStore.msd?.available)
|
||||||
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
|
|
||||||
const showMsd = computed(() => {
|
|
||||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
|
||||||
})
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
layout?: ConsoleLayout
|
layout?: ConsoleLayout
|
||||||
mouseMode?: 'absolute' | 'relative'
|
mouseMode?: 'absolute' | 'relative'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MousePointer, Move, Loader2 } from 'lucide-vue-next'
|
|||||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { HidBackend, OtgHidProfile } from '@/types/generated'
|
import { HidBackend, OtgHidProfile } from '@/types/generated'
|
||||||
|
import { usesCh9329 } from '@/lib/hidGuide'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -66,7 +67,7 @@ watch(showCursor, (newValue, oldValue) => {
|
|||||||
const buttonText = computed(() => t('actionbar.hidConfig'))
|
const buttonText = computed(() => t('actionbar.hidConfig'))
|
||||||
const hidSaving = ref(false)
|
const hidSaving = ref(false)
|
||||||
const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid)
|
const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid)
|
||||||
const supportsLinuxCompatibility = computed(() => configStore.hid?.backend === HidBackend.Ch9329)
|
const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend))
|
||||||
const supportsMacosCompatibility = computed(() =>
|
const supportsMacosCompatibility = computed(() =>
|
||||||
supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg
|
supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { HidConfig } from '@/types/generated'
|
import type { HidConfig } from '@/types/generated'
|
||||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||||
import { hidDeviceError, hidDeviceStage, type HidDeviceStatus } from '@/lib/hidGuide'
|
import { hidDeviceError, hidDeviceStage, otgDeviceStage, usesCh9329, supportsOtgFunctions, type HidDeviceStatus } from '@/lib/hidGuide'
|
||||||
const props = defineProps<{ hid: HidConfig | null; status?: HidDeviceStatus | null; bluetooth?: BluetoothStatus | null; error?: string }>()
|
const props = defineProps<{ hid: HidConfig | null; status?: HidDeviceStatus | null; bluetooth?: BluetoothStatus | null; error?: string }>()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const connectionError = computed(() => props.error || hidDeviceError(props.status) || props.bluetooth?.error)
|
const connectionError = computed(() => props.error || hidDeviceError(props.status) || props.bluetooth?.error)
|
||||||
@@ -13,8 +13,8 @@ const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? nul
|
|||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<p class="font-medium">{{ !hid ? t('hidGuide.unconfigured') : hid.backend === 'none' ? t('hidGuide.disabled') : t(`hidGuide.driver_${hid.backend}`) }}</p>
|
<p class="font-medium">{{ !hid ? t('hidGuide.unconfigured') : hid.backend === 'none' ? t('hidGuide.disabled') : t(`hidGuide.driver_${hid.backend}`) }}</p>
|
||||||
<dl v-if="hid && hid.backend !== 'none'" class="space-y-1 break-words">
|
<dl v-if="hid && hid.backend !== 'none'" class="space-y-1 break-words">
|
||||||
<div v-if="hid.backend === 'otg'"><dt class="inline text-muted-foreground">UDC: </dt><dd class="inline">{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '—' }}</dd></div>
|
<div v-if="supportsOtgFunctions(hid.backend)"><dt class="inline text-muted-foreground">UDC: </dt><dd class="inline">{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '—' }}</dd></div>
|
||||||
<template v-if="hid.backend === 'ch9329'">
|
<template v-if="usesCh9329(hid.backend)">
|
||||||
<div><dt class="inline text-muted-foreground">{{ t('hidGuide.device_ch9329') }}: </dt><dd class="inline">{{ hid.ch9329_port || '—' }}</dd></div>
|
<div><dt class="inline text-muted-foreground">{{ t('hidGuide.device_ch9329') }}: </dt><dd class="inline">{{ hid.ch9329_port || '—' }}</dd></div>
|
||||||
<div><dt class="inline text-muted-foreground">{{ t('actionbar.baudrate') }}: </dt><dd class="inline">{{ hid.ch9329_baudrate }}</dd></div>
|
<div><dt class="inline text-muted-foreground">{{ t('actionbar.baudrate') }}: </dt><dd class="inline">{{ hid.ch9329_baudrate }}</dd></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,9 +25,13 @@ const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? nul
|
|||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
<p v-if="hid && hid.backend !== 'none'" role="status" :class="!connectionError && stage === 'ready' ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'">
|
<p v-if="hid && hid.backend !== 'none'" role="status" :class="!connectionError && stage === 'ready' ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'">
|
||||||
{{ t(`hidGuide.${stage}`) }}
|
<span v-if="hid.backend === 'ch9329_otg'">{{ t('hidGuide.keyboardChannel') }}: </span>{{ t(`hidGuide.${stage}`) }}
|
||||||
<span v-if="bluetooth?.pairing_seconds"> · {{ bluetooth.pairing_seconds }}s</span>
|
<span v-if="bluetooth?.pairing_seconds"> · {{ bluetooth.pairing_seconds }}s</span>
|
||||||
</p>
|
</p>
|
||||||
|
<template v-if="hid?.backend === 'ch9329_otg'">
|
||||||
|
<p role="status" class="text-muted-foreground">{{ t('hidGuide.otgChannel') }}: {{ t(`hidGuide.${otgDeviceStage(status?.otg)}`) }}</p>
|
||||||
|
<p v-if="status?.otg?.error" role="alert" class="text-destructive break-words">{{ status.otg.error }}</p>
|
||||||
|
</template>
|
||||||
<p v-if="connectionError" role="alert" class="text-destructive break-words">{{ connectionError }}</p>
|
<p v-if="connectionError" role="alert" class="text-destructive break-words">{{ connectionError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useSystemStore } from '@/stores/system'
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
import type { HidConfig, MsdConfig } from '@/types/generated'
|
import type { HidConfig, MsdConfig } from '@/types/generated'
|
||||||
import { useHidConnection } from '@/composables/useHidConnection'
|
import { useHidConnection } from '@/composables/useHidConnection'
|
||||||
import { deviceRequest, matchesSelection, selectionFrom, type HidSelection } from '@/lib/hidGuide'
|
import { deviceRequest, matchesSelection, selectionFrom, supportsOtgFunctions, type HidSelection } from '@/lib/hidGuide'
|
||||||
import HidDriverForm from './HidDriverForm.vue'
|
import HidDriverForm from './HidDriverForm.vue'
|
||||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||||
import HidDeviceOverview from './HidDeviceOverview.vue'
|
import HidDeviceOverview from './HidDeviceOverview.vue'
|
||||||
@@ -127,7 +127,7 @@ async function load() {
|
|||||||
if (!systemStore.platform) await systemStore.fetchSystemInfo()
|
if (!systemStore.platform) await systemStore.fetchSystemInfo()
|
||||||
await refreshHid()
|
await refreshHid()
|
||||||
draft.value = selectionFrom(store.hid)
|
draft.value = selectionFrom(store.hid)
|
||||||
if (otgSupported.value && store.hid?.backend === 'otg') {
|
if (otgSupported.value && supportsOtgFunctions(store.hid?.backend)) {
|
||||||
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
|
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
|
||||||
disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean)
|
disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
<p v-if="connectionTimedOut && !ready && draft.backend !== 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.connectionTimeout') }}</p>
|
<p v-if="connectionTimedOut && !ready && draft.backend !== 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.connectionTimeout') }}</p>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="!applied && store.hid?.backend === 'otg' && draft.backend !== 'otg' && disabledUsb.length" class="text-sm text-warning">{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}</p>
|
<p v-if="!applied && supportsOtgFunctions(store.hid?.backend) && !supportsOtgFunctions(draft.backend) && disabledUsb.length" class="text-sm text-warning">{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}</p>
|
||||||
<p v-if="!applied && draft.backend === 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.resetWarning') }}</p>
|
<p v-if="!applied && draft.backend === 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.resetWarning') }}</p>
|
||||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||||
<DialogFooter class="gap-2">
|
<DialogFooter class="gap-2">
|
||||||
|
|||||||
@@ -9,57 +9,82 @@ import { Cable, RefreshCw } from 'lucide-vue-next'
|
|||||||
import { configApi, type PlatformCapabilities } from '@/api'
|
import { configApi, type PlatformCapabilities } from '@/api'
|
||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
import type { BluetoothAdapter } from '@/types/bluetooth'
|
import type { BluetoothAdapter } from '@/types/bluetooth'
|
||||||
import { selectDevice, validName, type HidSelection, type Driver } from '@/lib/hidGuide'
|
import { selectDevice, validName, usesCh9329, supportsOtgFunctions, type HidSelection, type Driver } from '@/lib/hidGuide'
|
||||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||||
const props = defineProps<{ modelValue: HidSelection; platform: PlatformCapabilities | null; locked?: boolean }>()
|
const props = withDefaults(defineProps<{ modelValue: HidSelection; platform: PlatformCapabilities | null; locked?: boolean; deviceOnly?: boolean; idPrefix?: string }>(), { idPrefix: 'hid' })
|
||||||
const emit = defineEmits<{ 'update:modelValue': [HidSelection]; valid: [boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [HidSelection]; valid: [boolean] }>()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||||
const options = ref<Array<{ value: string; label: string }>>([])
|
type DeviceField = 'serial' | 'udc' | 'bluetooth'
|
||||||
const loading = ref(false), error = ref(''), missing = ref('')
|
type DeviceOption = { value: string; label: string }
|
||||||
|
const options = ref<Record<DeviceField, DeviceOption[]>>({ serial: [], udc: [], bluetooth: [] })
|
||||||
|
const loading = ref(false), error = ref('')
|
||||||
let generation = 0
|
let generation = 0
|
||||||
const drivers = computed(() => (['otg', 'ch9329', 'bluetooth', 'none'] as Driver[])
|
const drivers = computed(() => (['otg', 'ch9329', 'ch9329_otg', 'bluetooth', 'none'] as Driver[])
|
||||||
.filter(driver => props.platform?.hid.backends.includes(driver)))
|
.filter(driver => props.platform?.hid.backends.includes(driver)))
|
||||||
const selected = computed(() => props.modelValue.backend === 'otg' ? props.modelValue.otg_udc : props.modelValue.backend === 'ch9329' ? props.modelValue.ch9329_port : props.modelValue.bluetooth.adapter)
|
const fields = computed<DeviceField[]>(() => {
|
||||||
function device(value: string) {
|
if (props.modelValue.backend === 'bluetooth') return ['bluetooth']
|
||||||
|
const result: DeviceField[] = []
|
||||||
|
if (usesCh9329(props.modelValue.backend)) result.push('serial')
|
||||||
|
if (supportsOtgFunctions(props.modelValue.backend)) result.push('udc')
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
function selected(field: DeviceField): string {
|
||||||
|
return field === 'serial' ? props.modelValue.ch9329_port : field === 'udc' ? props.modelValue.otg_udc : props.modelValue.bluetooth.adapter
|
||||||
|
}
|
||||||
|
function deviceLabel(field: DeviceField): string {
|
||||||
|
return options.value[field].find(option => option.value === selected(field))?.label
|
||||||
|
|| selected(field) || t('hidGuide.selectDevice')
|
||||||
|
}
|
||||||
|
function assign(draft: HidSelection, field: DeviceField, value: string) {
|
||||||
|
if (field === 'serial') draft.ch9329_port = value
|
||||||
|
if (field === 'udc') draft.otg_udc = value
|
||||||
|
if (field === 'bluetooth') draft.bluetooth.adapter = value
|
||||||
|
}
|
||||||
|
function device(field: DeviceField, value: string) {
|
||||||
|
if (value === selected(field)) return
|
||||||
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
|
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
|
||||||
if (draft.backend === 'otg') draft.otg_udc = value
|
assign(draft, field, value)
|
||||||
if (draft.backend === 'ch9329') draft.ch9329_port = value
|
|
||||||
if (draft.backend === 'bluetooth') draft.bluetooth.adapter = value
|
|
||||||
emit('update:modelValue', draft)
|
emit('update:modelValue', draft)
|
||||||
}
|
}
|
||||||
|
function fieldLabel(field: DeviceField): string {
|
||||||
|
return field === 'udc' && props.deviceOnly ? t('hidPage.udcLabel') : t(`hidGuide.device_${field === 'serial' ? 'ch9329' : field === 'udc' ? 'otg' : 'bluetooth'}`)
|
||||||
|
}
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const own = ++generation
|
const own = ++generation
|
||||||
options.value = []; error.value = ''; missing.value = ''; loading.value = true
|
options.value = { serial: [], udc: [], bluetooth: [] }; error.value = ''; loading.value = true
|
||||||
try {
|
try {
|
||||||
if (!drivers.value.includes(props.modelValue.backend) || props.modelValue.backend === 'none') return
|
if (!drivers.value.includes(props.modelValue.backend) || props.modelValue.backend === 'none') return
|
||||||
let next: typeof options.value
|
const next: Record<DeviceField, DeviceOption[]> = { serial: [], udc: [], bluetooth: [] }
|
||||||
if (props.modelValue.backend === 'bluetooth') {
|
if (props.modelValue.backend === 'bluetooth') {
|
||||||
next = (await request<BluetoothAdapter[]>('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` }))
|
next.bluetooth = (await request<BluetoothAdapter[]>('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` }))
|
||||||
} else {
|
} else {
|
||||||
const devices = await configApi.listDevices()
|
const devices = await configApi.listDevices()
|
||||||
next = props.modelValue.backend === 'otg' ? devices.udc.map(d => ({ value: d.name, label: d.name })) : devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` }))
|
next.udc = devices.udc.map(d => ({ value: d.name, label: d.name }))
|
||||||
|
next.serial = devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` }))
|
||||||
}
|
}
|
||||||
if (own !== generation) return
|
if (own !== generation) return
|
||||||
options.value = next
|
options.value = next
|
||||||
if (selected.value && !next.some(d => d.value === selected.value)) missing.value = t('hidGuide.missingDevice', { device: selected.value })
|
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
|
||||||
device(selectDevice(selected.value, next.map(d => d.value)))
|
for (const field of fields.value) assign(draft, field, selectDevice(selected(field), next[field].map(d => d.value)))
|
||||||
if (!next.length) error.value = t('hidGuide.noDevices')
|
if (JSON.stringify(draft) !== JSON.stringify(props.modelValue)) emit('update:modelValue', draft)
|
||||||
} catch (e) { if (own === generation) error.value = e instanceof Error ? e.message : String(e) }
|
} catch (e) { if (own === generation) error.value = e instanceof Error ? e.message : String(e) }
|
||||||
finally { if (own === generation) loading.value = false }
|
finally { if (own === generation) loading.value = false }
|
||||||
}
|
}
|
||||||
const valid = computed(() => drivers.value.includes(props.modelValue.backend) && (props.modelValue.backend === 'none' || (!loading.value && !error.value && options.value.some(d => d.value === selected.value)
|
const valid = computed(() => drivers.value.includes(props.modelValue.backend) && (props.modelValue.backend === 'none' || (!loading.value && !error.value
|
||||||
|
&& fields.value.every(field => options.value[field].some(d => d.value === selected(field)))
|
||||||
&& (props.modelValue.backend !== 'bluetooth' || validName(props.modelValue.bluetooth.name))
|
&& (props.modelValue.backend !== 'bluetooth' || validName(props.modelValue.bluetooth.name))
|
||||||
&& (props.modelValue.backend !== 'ch9329' || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate)))))
|
&& (!usesCh9329(props.modelValue.backend) || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate)))))
|
||||||
watch(valid, value => emit('valid', value), { immediate: true })
|
watch(valid, value => emit('valid', value), { immediate: true })
|
||||||
watch(() => [props.modelValue.backend, drivers.value.includes(props.modelValue.backend)], refresh, { immediate: true })
|
watch([() => props.modelValue.backend, () => drivers.value.includes(props.modelValue.backend)], refresh, { immediate: true })
|
||||||
onUnmounted(() => generation++)
|
onUnmounted(() => generation++)
|
||||||
|
defineExpose({ refresh, loading })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<fieldset :disabled="locked" class="space-y-4 min-w-0">
|
<fieldset :disabled="loading || locked" class="space-y-4 min-w-0">
|
||||||
<div class="space-y-1 text-sm">
|
<div v-if="!deviceOnly" class="space-y-1 text-sm">
|
||||||
<div class="flex min-h-8 items-center justify-between gap-2">
|
<div class="flex min-h-8 items-center justify-between gap-2">
|
||||||
<label for="hid-driver">{{ t('hidGuide.driver') }}</label>
|
<label :for="`${idPrefix}-driver`">{{ t('hidGuide.driver') }}</label>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<HoverCard v-if="modelValue.backend !== 'none'" :open-delay="150" :close-delay="100">
|
<HoverCard v-if="modelValue.backend !== 'none'" :open-delay="150" :close-delay="100">
|
||||||
<HoverCardTrigger as-child>
|
<HoverCardTrigger as-child>
|
||||||
@@ -77,7 +102,7 @@ onUnmounted(() => generation++)
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
:disabled="loading"
|
:disabled="loading || locked"
|
||||||
:aria-label="t('hidGuide.refreshDevices')"
|
:aria-label="t('hidGuide.refreshDevices')"
|
||||||
:title="t('hidGuide.refreshDevices')"
|
:title="t('hidGuide.refreshDevices')"
|
||||||
@click="refresh"
|
@click="refresh"
|
||||||
@@ -88,9 +113,10 @@ onUnmounted(() => generation++)
|
|||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
:model-value="modelValue.backend"
|
:model-value="modelValue.backend"
|
||||||
|
:disabled="loading || locked"
|
||||||
@update:model-value="value => emit('update:modelValue', { ...modelValue, backend: String(value) as Driver })"
|
@update:model-value="value => emit('update:modelValue', { ...modelValue, backend: String(value) as Driver })"
|
||||||
>
|
>
|
||||||
<SelectTrigger id="hid-driver" class="w-full">
|
<SelectTrigger :id="`${idPrefix}-driver`" class="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -101,46 +127,48 @@ onUnmounted(() => generation++)
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="modelValue.backend === 'none'" class="text-sm text-muted-foreground">{{ t('hidGuide.disabledHelp') }}</p>
|
<p v-if="modelValue.backend === 'none'" class="text-sm text-muted-foreground">{{ t('hidGuide.disabledHelp') }}</p>
|
||||||
<label v-if="modelValue.backend !== 'none'" class="block space-y-1 text-sm">
|
<p v-if="!deviceOnly && modelValue.backend === 'ch9329_otg'" class="text-sm text-muted-foreground">{{ t('hidGuide.compositeHelp') }}</p>
|
||||||
<span>{{ t(`hidGuide.device_${modelValue.backend}`) }}</span>
|
<div v-for="field in fields" :key="field" class="grid min-w-0 gap-4"
|
||||||
<Select
|
:class="{ 'grid-cols-[minmax(0,1fr)_7rem]': field === 'serial' }">
|
||||||
:model-value="selected || EMPTY_SELECT_VALUE"
|
<div class="min-w-0 space-y-1 text-sm">
|
||||||
:disabled="loading"
|
<label :for="`${idPrefix}-device-${field}`">{{ fieldLabel(field) }}</label>
|
||||||
@update:model-value="value => device(value === EMPTY_SELECT_VALUE ? '' : String(value))"
|
<Select :model-value="selected(field) || EMPTY_SELECT_VALUE" :disabled="loading || locked"
|
||||||
>
|
@update:model-value="value => device(field, value === EMPTY_SELECT_VALUE ? '' : String(value))">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger :id="`${idPrefix}-device-${field}`" class="w-full">
|
||||||
<SelectValue :placeholder="t('hidGuide.selectDevice')" />
|
<SelectValue :placeholder="t('hidGuide.selectDevice')">{{ deviceLabel(field) }}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem :value="EMPTY_SELECT_VALUE" disabled>{{ t('hidGuide.selectDevice') }}</SelectItem>
|
<SelectItem :value="EMPTY_SELECT_VALUE" disabled>{{ t('hidGuide.selectDevice') }}</SelectItem>
|
||||||
<SelectItem v-for="option in options" :key="option.value" :value="option.value">
|
<SelectItem v-if="selected(field) && !options[field].some(d => d.value === selected(field))" :value="selected(field)" disabled>{{ selected(field) }}</SelectItem>
|
||||||
{{ option.label }}
|
<SelectItem v-for="option in options[field]" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<p v-if="!loading && selected(field) && !options[field].some(d => d.value === selected(field))" class="text-warning">{{ t('hidGuide.missingDevice', { device: selected(field) }) }}</p>
|
||||||
</label>
|
<p v-if="!loading && !options[field].length" class="text-destructive">{{ fieldLabel(field) }}: {{ t('hidGuide.noDevices') }}</p>
|
||||||
<label v-if="modelValue.backend === 'ch9329'" class="block space-y-1 text-sm">
|
</div>
|
||||||
<span>{{ t('actionbar.baudrate') }}</span>
|
<div v-if="field === 'serial'" class="min-w-0 space-y-1 text-sm">
|
||||||
<Select
|
<label :for="`${idPrefix}-baudrate`">{{ t('actionbar.baudrate') }}</label>
|
||||||
:model-value="String(modelValue.ch9329_baudrate)"
|
<Select
|
||||||
@update:model-value="value => emit('update:modelValue', { ...modelValue, ch9329_baudrate: Number(value) })"
|
:model-value="String(modelValue.ch9329_baudrate)"
|
||||||
>
|
:disabled="loading || locked"
|
||||||
<SelectTrigger class="w-full">
|
@update:model-value="value => emit('update:modelValue', { ...modelValue, ch9329_baudrate: Number(value) })"
|
||||||
<SelectValue />
|
>
|
||||||
</SelectTrigger>
|
<SelectTrigger :id="`${idPrefix}-baudrate`" class="w-full">
|
||||||
<SelectContent>
|
<SelectValue />
|
||||||
<SelectItem v-for="rate in [9600, 19200, 38400, 57600, 115200]" :key="rate" :value="String(rate)">
|
</SelectTrigger>
|
||||||
{{ rate }}
|
<SelectContent>
|
||||||
</SelectItem>
|
<SelectItem v-for="rate in [9600, 19200, 38400, 57600, 115200]" :key="rate" :value="String(rate)">
|
||||||
</SelectContent>
|
{{ rate }}
|
||||||
</Select>
|
</SelectItem>
|
||||||
</label>
|
</SelectContent>
|
||||||
<label v-if="modelValue.backend === 'bluetooth'" class="block space-y-1 text-sm">
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label v-if="modelValue.backend === 'bluetooth'" :for="`${idPrefix}-bluetooth-name`" class="block space-y-1 text-sm">
|
||||||
<span>{{ t('bluetoothHid.name') }}</span>
|
<span>{{ t('bluetoothHid.name') }}</span>
|
||||||
<Input :model-value="modelValue.bluetooth.name" @update:model-value="emit('update:modelValue', { ...modelValue, bluetooth: { ...modelValue.bluetooth, name: String($event) } })" />
|
<Input :id="`${idPrefix}-bluetooth-name`" :disabled="loading || locked" :model-value="modelValue.bluetooth.name" @update:model-value="emit('update:modelValue', { ...modelValue, bluetooth: { ...modelValue.bluetooth, name: String($event) } })" />
|
||||||
<span v-if="!validName(modelValue.bluetooth.name)" class="text-destructive text-xs">{{ t('hidGuide.nameInvalid') }}</span>
|
<span v-if="!validName(modelValue.bluetooth.name)" class="text-destructive text-xs">{{ t('hidGuide.nameInvalid') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="missing" class="text-sm text-warning">{{ missing }}</p>
|
|
||||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,7 +8,27 @@ const { t } = useI18n()
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<figure v-if="backend !== 'none'" class="wiring-diagram rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
|
<figure v-if="backend !== 'none'" class="wiring-diagram rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
|
||||||
<svg viewBox="0 0 460 184" class="block w-full text-foreground" role="img" :aria-label="t(`hidGuide.wiring_${backend}`)">
|
<svg v-if="backend === 'ch9329_otg'" viewBox="0 0 480 190" class="block w-full text-foreground" role="img" :aria-label="t('hidGuide.wiring_ch9329_otg')">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.2">
|
||||||
|
<rect x="8" y="51" width="112" height="100" rx="8" />
|
||||||
|
<rect x="174" y="24" width="112" height="35" rx="6" />
|
||||||
|
<rect x="332" y="19" width="140" height="151" rx="8" />
|
||||||
|
</g>
|
||||||
|
<g fill="currentColor" text-anchor="middle" font-size="12">
|
||||||
|
<text x="64" y="80">One-KVM</text>
|
||||||
|
<text x="230" y="46">CH9329</text>
|
||||||
|
<text x="402" y="43">{{ t('hidGuide.kvmSwitch') }}</text>
|
||||||
|
<text x="402" y="84">{{ t('hidGuide.dedicatedHidPort') }}</text>
|
||||||
|
<text x="402" y="143">{{ t('hidGuide.generalUsbPort') }}</text>
|
||||||
|
<text x="146" y="33">UART</text>
|
||||||
|
<text x="229" y="127">OTG USB</text>
|
||||||
|
</g>
|
||||||
|
<g class="connection" fill="none" stroke="currentColor" stroke-linejoin="round">
|
||||||
|
<path d="M120 90h22V42h32m112 0h20v35h26m-8-5 8 5-8 5" />
|
||||||
|
<path d="M120 136h212m-8-5 8 5-8 5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 460 184" class="block w-full text-foreground" role="img" :aria-label="t(`hidGuide.wiring_${backend}`)">
|
||||||
<!-- A faint ground plane anchors the perspective without shadows or filters. -->
|
<!-- A faint ground plane anchors the perspective without shadows or filters. -->
|
||||||
<g fill="none" stroke="currentColor" stroke-width=".65" opacity=".07" aria-hidden="true">
|
<g fill="none" stroke="currentColor" stroke-width=".65" opacity=".07" aria-hidden="true">
|
||||||
<path d="m7 87 101-46 75 38-101 46Zm270-5 98-45 79 39-98 46M31 99l101-46m-77 58 101-46M302 94l98-45m-71 58 98-45" />
|
<path d="m7 87 101-46 75 38-101 46Zm270-5 98-45 79 39-98 46M31 99l101-46m-77 58 101-46M302 94l98-45m-71 58 98-45" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ref, watch, onMounted, onUnmounted, onDeactivated, onActivated, type Ref } from 'vue'
|
import { ref, watch, onMounted, onUnmounted, onDeactivated, onActivated, type Ref } from 'vue'
|
||||||
import type { hidApi } from '@/api'
|
import type { hidApi } from '@/api'
|
||||||
|
import { runtimeBackend } from '@/lib/hidGuide'
|
||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||||
export function useHidConnection(active: Ref<boolean>, backend: Ref<string | undefined>) {
|
export function useHidConnection(active: Ref<boolean>, backend: Ref<string | undefined>) {
|
||||||
@@ -26,8 +27,8 @@ export function useHidConnection(active: Ref<boolean>, backend: Ref<string | und
|
|||||||
if (hidResult.status === 'rejected') throw hidResult.reason
|
if (hidResult.status === 'rejected') throw hidResult.reason
|
||||||
if (btResult.status === 'rejected') throw btResult.reason
|
if (btResult.status === 'rejected') throw btResult.reason
|
||||||
const hid = hidResult.value, bt = btResult.value
|
const hid = hidResult.value, bt = btResult.value
|
||||||
status.value = hid.backend === backend.value ? hid : null
|
status.value = hid.backend === runtimeBackend(backend.value) ? hid : null
|
||||||
bluetooth.value = hid.backend === backend.value ? bt : null
|
bluetooth.value = hid.backend === runtimeBackend(backend.value) ? bt : null
|
||||||
error.value = ''
|
error.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (own !== generation) return
|
if (own !== generation) return
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
export default {
|
export default {
|
||||||
hidGuide: {
|
hidGuide: {
|
||||||
|
driver_ch9329_otg: 'CH9329 + OTG composite mode',
|
||||||
|
compositeHelp: 'CH9329 handles keyboard and mouse. OTG provides virtual media, USB networking and USB audio. Enable advanced functions in their settings.',
|
||||||
|
wiring_ch9329_otg: 'Connect CH9329 to the dedicated keyboard/mouse port and OTG to a general USB port. Use an operation that switches video, keyboard/mouse and USB together.',
|
||||||
|
keyboardChannel: 'CH9329 input',
|
||||||
|
otgChannel: 'OTG functions',
|
||||||
|
kvmSwitch: 'KVM switch',
|
||||||
|
dedicatedHidPort: 'Keyboard/mouse port',
|
||||||
|
generalUsbPort: 'General USB port',
|
||||||
|
advancedFunctions: 'OTG advanced USB functions',
|
||||||
|
otgInactive: 'Not enabled',
|
||||||
|
otgConfigured: 'USB configured by host',
|
||||||
|
otgWaiting: 'Bound, waiting for host',
|
||||||
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
|
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
|
||||||
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
|
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
|
||||||
"deviceTitle": "Driver and device",
|
"deviceTitle": "Driver and device",
|
||||||
@@ -50,7 +62,7 @@ export default {
|
|||||||
"pairTimeout": "The pairing window ended. You can open it again.",
|
"pairTimeout": "The pairing window ended. You can open it again.",
|
||||||
"reopenPairing": "Reopen pairing",
|
"reopenPairing": "Reopen pairing",
|
||||||
"resetWarning": "Previous One-KVM HID bonds will be cleared. Remove the old device on the controlled computer before adding it again.",
|
"resetWarning": "Previous One-KVM HID bonds will be cleared. Remove the old device on the controlled computer before adding it again.",
|
||||||
"disableUsb": "Leaving USB OTG will disable: {functions}.",
|
"disableUsb": "Switching drivers will disable: {functions}.",
|
||||||
"msd": "Virtual disk",
|
"msd": "Virtual disk",
|
||||||
"network": "USB network",
|
"network": "USB network",
|
||||||
"audio": "USB audio",
|
"audio": "USB audio",
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
export default {
|
export default {
|
||||||
hidGuide: {
|
hidGuide: {
|
||||||
|
driver_ch9329_otg: 'CH9329 + OTG 复合模式',
|
||||||
|
compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。',
|
||||||
|
wiring_ch9329_otg: 'CH9329 接 KVM 专用键鼠口,OTG 接通用 USB 口。请使用同时切换视频、键鼠和通用 USB 的操作。',
|
||||||
|
keyboardChannel: 'CH9329 键鼠',
|
||||||
|
otgChannel: 'OTG 高级功能',
|
||||||
|
kvmSwitch: 'KVM 切换器',
|
||||||
|
dedicatedHidPort: '专用键鼠口',
|
||||||
|
generalUsbPort: '通用 USB 口',
|
||||||
|
advancedFunctions: 'OTG 高级 USB 功能',
|
||||||
|
otgInactive: '未启用',
|
||||||
|
otgConfigured: '主机已配置 USB',
|
||||||
|
otgWaiting: '已绑定,等待主机连接',
|
||||||
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
|
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
|
||||||
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
|
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
|
||||||
"deviceTitle": "驱动与设备",
|
"deviceTitle": "驱动与设备",
|
||||||
@@ -50,7 +62,7 @@ export default {
|
|||||||
"pairTimeout": "配对窗口已结束,可重新开启。",
|
"pairTimeout": "配对窗口已结束,可重新开启。",
|
||||||
"reopenPairing": "重新开启配对",
|
"reopenPairing": "重新开启配对",
|
||||||
"resetWarning": "将清除原 One-KVM HID 绑定;请在被控机中删除旧设备后重新添加。",
|
"resetWarning": "将清除原 One-KVM HID 绑定;请在被控机中删除旧设备后重新添加。",
|
||||||
"disableUsb": "切出 USB OTG 将关闭:{functions}。",
|
"disableUsb": "切换驱动将关闭:{functions}。",
|
||||||
"msd": "虚拟磁盘",
|
"msd": "虚拟磁盘",
|
||||||
"network": "USB 网络",
|
"network": "USB 网络",
|
||||||
"audio": "USB 音频",
|
"audio": "USB 音频",
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { HidConfig, HidConfigUpdate } from '../types/generated'
|
import type { HidConfig, HidConfigUpdate } from '../types/generated'
|
||||||
import type { BluetoothStatus } from '../types/bluetooth'
|
import type { BluetoothStatus } from '../types/bluetooth'
|
||||||
export type Driver = 'otg' | 'ch9329' | 'bluetooth' | 'none'
|
export type Driver = 'otg' | 'ch9329' | 'ch9329_otg' | 'bluetooth' | 'none'
|
||||||
|
export function usesCh9329(backend?: string): boolean {
|
||||||
|
return backend === 'ch9329' || backend === 'ch9329_otg'
|
||||||
|
}
|
||||||
|
export function supportsOtgFunctions(backend?: string): boolean {
|
||||||
|
return backend === 'otg' || backend === 'ch9329_otg'
|
||||||
|
}
|
||||||
|
export function runtimeBackend(backend?: string): string | undefined {
|
||||||
|
return backend === 'ch9329_otg' ? 'ch9329' : backend
|
||||||
|
}
|
||||||
export interface HidSelection {
|
export interface HidSelection {
|
||||||
backend: Driver; otg_udc: string; ch9329_port: string; ch9329_baudrate: number
|
backend: Driver; otg_udc: string; ch9329_port: string; ch9329_baudrate: number
|
||||||
bluetooth: { adapter: string; name: string }
|
bluetooth: { adapter: string; name: string }
|
||||||
@@ -11,7 +20,7 @@ export function selectionFrom(hid?: HidConfig | null): HidSelection {
|
|||||||
bluetooth: { adapter: hid?.bluetooth.adapter ?? '', name: hid?.bluetooth.name ?? 'One-KVM HID' } }
|
bluetooth: { adapter: hid?.bluetooth.adapter ?? '', name: hid?.bluetooth.name ?? 'One-KVM HID' } }
|
||||||
}
|
}
|
||||||
export function selectDevice(saved: string, candidates: string[]): string {
|
export function selectDevice(saved: string, candidates: string[]): string {
|
||||||
return candidates.includes(saved) ? saved : candidates.length === 1 ? candidates[0]! : ''
|
return saved || (candidates.length === 1 ? candidates[0]! : '')
|
||||||
}
|
}
|
||||||
export function validName(name: string): boolean {
|
export function validName(name: string): boolean {
|
||||||
const length = new TextEncoder().encode(name).length
|
const length = new TextEncoder().encode(name).length
|
||||||
@@ -21,6 +30,7 @@ export function deviceRequest(draft: HidSelection): HidConfigUpdate {
|
|||||||
const backend = draft.backend as HidConfigUpdate['backend']
|
const backend = draft.backend as HidConfigUpdate['backend']
|
||||||
switch (draft.backend) {
|
switch (draft.backend) {
|
||||||
case 'otg': return { backend, otg_udc: draft.otg_udc }
|
case 'otg': return { backend, otg_udc: draft.otg_udc }
|
||||||
|
case 'ch9329_otg': return { backend, otg_udc: draft.otg_udc, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
|
||||||
case 'ch9329': return { backend, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
|
case 'ch9329': return { backend, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
|
||||||
case 'bluetooth': return { backend, bluetooth: { ...draft.bluetooth }, bluetooth_reset_pairing: true }
|
case 'bluetooth': return { backend, bluetooth: { ...draft.bluetooth }, bluetooth_reset_pairing: true }
|
||||||
case 'none': return { backend }
|
case 'none': return { backend }
|
||||||
@@ -30,6 +40,7 @@ export function matchesSelection(hid: HidConfig, draft: HidSelection): boolean {
|
|||||||
if (hid.backend !== draft.backend) return false
|
if (hid.backend !== draft.backend) return false
|
||||||
switch (draft.backend) {
|
switch (draft.backend) {
|
||||||
case 'otg': return hid.otg_udc === draft.otg_udc
|
case 'otg': return hid.otg_udc === draft.otg_udc
|
||||||
|
case 'ch9329_otg': return hid.otg_udc === draft.otg_udc && hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
|
||||||
case 'ch9329': return hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
|
case 'ch9329': return hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
|
||||||
case 'bluetooth': return hid.bluetooth.adapter === draft.bluetooth.adapter && hid.bluetooth.name === draft.bluetooth.name
|
case 'bluetooth': return hid.bluetooth.adapter === draft.bluetooth.adapter && hid.bluetooth.name === draft.bluetooth.name
|
||||||
case 'none': return true
|
case 'none': return true
|
||||||
@@ -43,7 +54,25 @@ export function bluetoothStage(status: BluetoothStatus | null): string {
|
|||||||
if (status.pairing_seconds > 0) return 'pairing'
|
if (status.pairing_seconds > 0) return 'pairing'
|
||||||
return status.initialized ? 'waiting' : 'initializing'
|
return status.initialized ? 'waiting' : 'initializing'
|
||||||
}
|
}
|
||||||
|
export interface OtgDeviceStatus {
|
||||||
|
enabled: boolean
|
||||||
|
health: 'healthy' | 'applying' | 'degraded'
|
||||||
|
gadget_active: boolean
|
||||||
|
udc: string | null
|
||||||
|
udc_state: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
export function otgDeviceStage(status?: OtgDeviceStatus | null): string {
|
||||||
|
if (!status) return 'unknown'
|
||||||
|
if (!status.enabled) return 'otgInactive'
|
||||||
|
if (status.health === 'applying') return 'initializing'
|
||||||
|
if (status.health === 'degraded' || status.error) return 'unknown'
|
||||||
|
if (!status.gadget_active) return 'unknown'
|
||||||
|
return status.udc_state === 'configured' ? 'otgConfigured' : 'otgWaiting'
|
||||||
|
}
|
||||||
export interface HidDeviceStatus {
|
export interface HidDeviceStatus {
|
||||||
|
otg?: OtgDeviceStatus | null
|
||||||
|
configured_backend?: string
|
||||||
backend: string; online: boolean; error?: string | null; error_code?: string | null
|
backend: string; online: boolean; error?: string | null; error_code?: string | null
|
||||||
}
|
}
|
||||||
export function hidDeviceError(status?: HidDeviceStatus | null): string | null {
|
export function hidDeviceError(status?: HidDeviceStatus | null): string | null {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface BluetoothHidConfig {
|
|||||||
export enum HidBackend {
|
export enum HidBackend {
|
||||||
Otg = "otg",
|
Otg = "otg",
|
||||||
Ch9329 = "ch9329",
|
Ch9329 = "ch9329",
|
||||||
|
Ch9329Otg = "ch9329_otg",
|
||||||
Bluetooth = "bluetooth",
|
Bluetooth = "bluetooth",
|
||||||
None = "none",
|
None = "none",
|
||||||
}
|
}
|
||||||
@@ -548,7 +549,17 @@ export interface OtgNetworkStatus {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OtgRuntimeStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
health: OtgRuntimeHealth;
|
||||||
|
gadget_active: boolean;
|
||||||
|
udc?: string;
|
||||||
|
udc_state?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OtgConfigResponse {
|
export interface OtgConfigResponse {
|
||||||
|
uac: UacConfig;
|
||||||
hid: HidConfig;
|
hid: HidConfig;
|
||||||
msd: MsdConfig;
|
msd: MsdConfig;
|
||||||
network: OtgNetworkConfig;
|
network: OtgNetworkConfig;
|
||||||
@@ -564,6 +575,7 @@ export interface OtgNetworkConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OtgConfigUpdate {
|
export interface OtgConfigUpdate {
|
||||||
|
uac?: UacConfig;
|
||||||
hid?: HidConfigUpdate;
|
hid?: HidConfigUpdate;
|
||||||
msd?: MsdConfigUpdate;
|
msd?: MsdConfigUpdate;
|
||||||
network?: OtgNetworkConfigUpdate;
|
network?: OtgNetworkConfigUpdate;
|
||||||
|
|||||||
@@ -736,7 +736,7 @@ watch(videoLoading, (loading) => {
|
|||||||
|
|
||||||
|
|
||||||
const showMsdStatusCard = computed(() => {
|
const showMsdStatusCard = computed(() => {
|
||||||
return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329')
|
return !!(systemStore.msd?.available)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hidHoverAlign = computed<'start' | 'end'>(() => {
|
const hidHoverAlign = computed<'start' | 'end'>(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { usesCh9329, supportsOtgFunctions } from '@/lib/hidGuide'
|
||||||
import McpSettingsCard from '@/components/McpSettingsCard.vue'
|
import McpSettingsCard from '@/components/McpSettingsCard.vue'
|
||||||
import HidDeviceSettings from '@/components/HidDeviceSettings.vue'
|
import HidDeviceSettings from '@/components/HidDeviceSettings.vue'
|
||||||
import ConsoleLayoutPreview from '@/components/ConsoleLayoutPreview.vue'
|
import ConsoleLayoutPreview from '@/components/ConsoleLayoutPreview.vue'
|
||||||
@@ -1152,7 +1153,7 @@ function clearCh9329DescriptorState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCh9329DescriptorSourceCurrent = computed(() => {
|
const isCh9329DescriptorSourceCurrent = computed(() => {
|
||||||
if (config.value.hid_backend !== 'ch9329') return false
|
if (!usesCh9329(config.value.hid_backend)) return false
|
||||||
const source = ch9329DescriptorSource.value
|
const source = ch9329DescriptorSource.value
|
||||||
if (!source) return false
|
if (!source) return false
|
||||||
const current = currentCh9329DescriptorSource()
|
const current = currentCh9329DescriptorSource()
|
||||||
@@ -1160,7 +1161,7 @@ const isCh9329DescriptorSourceCurrent = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadCh9329Descriptor() {
|
async function loadCh9329Descriptor() {
|
||||||
if (config.value.hid_backend !== 'ch9329') return
|
if (!usesCh9329(config.value.hid_backend)) return
|
||||||
const source = currentCh9329DescriptorSource()
|
const source = currentCh9329DescriptorSource()
|
||||||
ch9329DescriptorLoading.value = true
|
ch9329DescriptorLoading.value = true
|
||||||
ch9329DescriptorLoaded.value = false
|
ch9329DescriptorLoaded.value = false
|
||||||
@@ -1182,14 +1183,14 @@ async function loadCh9329Descriptor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCh9329DescriptorValid = computed(() => {
|
const isCh9329DescriptorValid = computed(() => {
|
||||||
if (config.value.hid_backend !== 'ch9329') return true
|
if (!usesCh9329(config.value.hid_backend)) return true
|
||||||
return utf8ByteLength(ch9329Manufacturer.value) <= 23
|
return utf8ByteLength(ch9329Manufacturer.value) <= 23
|
||||||
&& utf8ByteLength(ch9329Product.value) <= 23
|
&& utf8ByteLength(ch9329Product.value) <= 23
|
||||||
&& utf8ByteLength(ch9329SerialNumber.value) <= 23
|
&& utf8ByteLength(ch9329SerialNumber.value) <= 23
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEditCh9329Descriptor = computed(() =>
|
const canEditCh9329Descriptor = computed(() =>
|
||||||
config.value.hid_backend === 'ch9329'
|
usesCh9329(config.value.hid_backend)
|
||||||
&& ch9329DescriptorLoaded.value
|
&& ch9329DescriptorLoaded.value
|
||||||
&& isCh9329DescriptorSourceCurrent.value
|
&& isCh9329DescriptorSourceCurrent.value
|
||||||
&& !ch9329DescriptorLoading.value
|
&& !ch9329DescriptorLoading.value
|
||||||
@@ -1268,7 +1269,7 @@ const atxDevices = ref<AtxDevices>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const ch9329ReservedSerialDevice = computed(() => {
|
const ch9329ReservedSerialDevice = computed(() => {
|
||||||
if (config.value.hid_backend !== 'ch9329') return ''
|
if (!usesCh9329(config.value.hid_backend)) return ''
|
||||||
return config.value.hid_serial_device.trim()
|
return config.value.hid_serial_device.trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1492,7 +1493,7 @@ async function saveConfig() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hidUpdate: HidConfigUpdate = {}
|
const hidUpdate: HidConfigUpdate = {}
|
||||||
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
|
if (usesCh9329(config.value.hid_backend) && isCh9329DescriptorDirty.value) {
|
||||||
hidUpdate.ch9329_descriptor = {
|
hidUpdate.ch9329_descriptor = {
|
||||||
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
||||||
product_id: parseInt(ch9329ProductIdHex.value, 16) || 0xe129,
|
product_id: parseInt(ch9329ProductIdHex.value, 16) || 0xe129,
|
||||||
@@ -1501,7 +1502,7 @@ async function saveConfig() {
|
|||||||
serial_number: ch9329SerialNumber.value || '',
|
serial_number: ch9329SerialNumber.value || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (config.value.hid_backend === 'otg') {
|
if (supportsOtgFunctions(config.value.hid_backend)) {
|
||||||
hidUpdate.otg_descriptor = {
|
hidUpdate.otg_descriptor = {
|
||||||
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
|
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
|
||||||
product_id: parseInt(otgProductIdHex.value, 16) || 0x0104,
|
product_id: parseInt(otgProductIdHex.value, 16) || 0x0104,
|
||||||
@@ -1509,12 +1510,14 @@ async function saveConfig() {
|
|||||||
product: otgProduct.value || 'One-KVM USB Device',
|
product: otgProduct.value || 'One-KVM USB Device',
|
||||||
serial_number: otgSerialNumber.value || undefined,
|
serial_number: otgSerialNumber.value || undefined,
|
||||||
}
|
}
|
||||||
hidUpdate.otg_profile = 'custom' as OtgHidProfile
|
if (config.value.hid_backend === 'otg') {
|
||||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
hidUpdate.otg_profile = 'custom' as OtgHidProfile
|
||||||
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||||
|
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (configStore.hid?.backend === 'otg') {
|
if (supportsOtgFunctions(configStore.hid?.backend)) {
|
||||||
const otgEnabled = config.value.hid_backend === 'otg'
|
const otgEnabled = supportsOtgFunctions(config.value.hid_backend)
|
||||||
const response = await configStore.updateOtg({
|
const response = await configStore.updateOtg({
|
||||||
hid: hidUpdate,
|
hid: hidUpdate,
|
||||||
msd: {
|
msd: {
|
||||||
@@ -1528,15 +1531,14 @@ async function saveConfig() {
|
|||||||
driver_mode: config.value.otg_network_driver as any,
|
driver_mode: config.value.otg_network_driver as any,
|
||||||
bridge_interface: config.value.otg_network_interface,
|
bridge_interface: config.value.otg_network_interface,
|
||||||
},
|
},
|
||||||
|
uac: {
|
||||||
|
enabled: otgEnabled && config.value.uac_enabled,
|
||||||
|
sample_rate: 48000,
|
||||||
|
channels: 2,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
otgNetworkStatus.value = response.status
|
otgNetworkStatus.value = response.status
|
||||||
|
} else if (usesCh9329(configStore.hid?.backend)) {
|
||||||
await uacApi.update({
|
|
||||||
enabled: otgEnabled && config.value.uac_enabled,
|
|
||||||
sample_rate: 48000,
|
|
||||||
channels: 2,
|
|
||||||
})
|
|
||||||
} else if (configStore.hid?.backend === 'ch9329') {
|
|
||||||
await configStore.updateHid(hidUpdate)
|
await configStore.updateHid(hidUpdate)
|
||||||
}
|
}
|
||||||
await loadConfig()
|
await loadConfig()
|
||||||
@@ -1632,11 +1634,11 @@ async function loadConfig() {
|
|||||||
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
|
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
|
||||||
}
|
}
|
||||||
if (hid.ch9329_descriptor) {
|
if (hid.ch9329_descriptor) {
|
||||||
if (hid.backend !== 'ch9329') {
|
if (!usesCh9329(hid.backend)) {
|
||||||
applyCh9329DescriptorForm(hid.ch9329_descriptor, true)
|
applyCh9329DescriptorForm(hid.ch9329_descriptor, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hid.backend === 'ch9329') {
|
if (usesCh9329(hid.backend)) {
|
||||||
await loadCh9329Descriptor()
|
await loadCh9329Descriptor()
|
||||||
} else {
|
} else {
|
||||||
clearCh9329DescriptorState()
|
clearCh9329DescriptorState()
|
||||||
@@ -2773,7 +2775,7 @@ watch(updateChannel, async () => {
|
|||||||
watch(() => config.value.hid_backend, () => {
|
watch(() => config.value.hid_backend, () => {
|
||||||
otgSelfCheckResult.value = null
|
otgSelfCheckResult.value = null
|
||||||
otgSelfCheckError.value = ''
|
otgSelfCheckError.value = ''
|
||||||
if (config.value.hid_backend === 'ch9329') {
|
if (usesCh9329(config.value.hid_backend)) {
|
||||||
void loadCh9329Descriptor()
|
void loadCh9329Descriptor()
|
||||||
} else {
|
} else {
|
||||||
clearCh9329DescriptorState()
|
clearCh9329DescriptorState()
|
||||||
@@ -2783,7 +2785,7 @@ watch(() => config.value.hid_backend, () => {
|
|||||||
watch(
|
watch(
|
||||||
() => [config.value.hid_serial_device, config.value.hid_serial_baudrate],
|
() => [config.value.hid_serial_device, config.value.hid_serial_baudrate],
|
||||||
() => {
|
() => {
|
||||||
if (config.value.hid_backend === 'ch9329' && !isCh9329DescriptorSourceCurrent.value) {
|
if (usesCh9329(config.value.hid_backend) && !isCh9329DescriptorSourceCurrent.value) {
|
||||||
clearCh9329DescriptorState()
|
clearCh9329DescriptorState()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3170,10 +3172,10 @@ watch(isWindows, () => {
|
|||||||
<!-- HID Section -->
|
<!-- HID Section -->
|
||||||
<div v-show="activeSection === 'hid'" class="space-y-4">
|
<div v-show="activeSection === 'hid'" class="space-y-4">
|
||||||
<HidDeviceSettings :active="activeSection === 'hid'" :dirty="hidFeaturesDirty" @applied="loadConfig" @discard="loadConfig" />
|
<HidDeviceSettings :active="activeSection === 'hid'" :dirty="hidFeaturesDirty" @applied="loadConfig" @discard="loadConfig" />
|
||||||
<Card v-if="configStore.hid?.backend === 'otg' || configStore.hid?.backend === 'ch9329'">
|
<Card v-if="supportsOtgFunctions(configStore.hid?.backend) || usesCh9329(configStore.hid?.backend)">
|
||||||
<CardHeader><CardTitle>{{ t('hidGuide.features') }}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{{ t('hidGuide.features') }}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<template v-if="config.hid_backend === 'ch9329'">
|
<template v-if="usesCh9329(config.hid_backend)">
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -3261,7 +3263,7 @@ watch(isWindows, () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- OTG Descriptor Settings -->
|
<!-- OTG Descriptor Settings -->
|
||||||
<template v-if="config.hid_backend === 'otg'">
|
<template v-if="supportsOtgFunctions(config.hid_backend)">
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -3323,10 +3325,10 @@ watch(isWindows, () => {
|
|||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
|
<h4 class="text-sm font-medium">{{ t(config.hid_backend === 'otg' ? 'settings.otgHidProfile' : 'hidGuide.advancedFunctions') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div v-if="config.hid_backend === 'otg'" class="grid gap-3 md:grid-cols-2">
|
||||||
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -3364,13 +3366,13 @@ watch(isWindows, () => {
|
|||||||
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="hasMacosDragInterfaceConflict" class="text-xs text-warning">{{ t('settings.mouseMacosDragRequiresBoth') }}</p>
|
<p v-if="hasMacosDragInterfaceConflict" class="text-xs text-warning">{{ t('settings.mouseMacosDragRequiresBoth') }}</p>
|
||||||
<Separator />
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<Label>{{ t('settings.uacMic') }}</Label>
|
|
||||||
<Switch v-model="config.uac_enabled" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-md border border-border/60 p-3">
|
||||||
|
<Label>{{ t('settings.uacMic') }}</Label>
|
||||||
|
<Switch v-model="config.uac_enabled" />
|
||||||
|
</div>
|
||||||
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -3466,7 +3468,7 @@ watch(isWindows, () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-warning">
|
<p v-if="config.hid_backend === 'otg'" class="text-xs text-warning">
|
||||||
{{ t('settings.otgProfileWarning') }}
|
{{ t('settings.otgProfileWarning') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -5419,7 +5421,7 @@ watch(isWindows, () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Button (sticky) -->
|
<!-- Save Button (sticky) -->
|
||||||
<div v-if="activeSection === 'video' || (activeSection === 'hid' && ['otg', 'ch9329'].includes(configStore.hid?.backend ?? ''))" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
<div v-if="activeSection === 'video' || (activeSection === 'hid' && (supportsOtgFunctions(configStore.hid?.backend) || usesCh9329(configStore.hid?.backend)))" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||||
<div class="flex items-center justify-between gap-2 sm:gap-3">
|
<div class="flex items-center justify-between gap-2 sm:gap-3">
|
||||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
||||||
<AlertTriangle class="size-3.5 shrink-0" />
|
<AlertTriangle class="size-3.5 shrink-0" />
|
||||||
@@ -5433,7 +5435,7 @@ watch(isWindows, () => {
|
|||||||
<AlertTriangle class="size-3.5 shrink-0" />
|
<AlertTriangle class="size-3.5 shrink-0" />
|
||||||
<span class="truncate">{{ t('settings.ch9329StringLengthWarning') }}</span>
|
<span class="truncate">{{ t('settings.ch9329StringLengthWarning') }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="activeSection === 'hid' && config.hid_backend === 'ch9329' && ch9329DescriptorLoading" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
<p v-else-if="activeSection === 'hid' && usesCh9329(config.hid_backend) && ch9329DescriptorLoading" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
||||||
<AlertTriangle class="size-3.5 shrink-0" />
|
<AlertTriangle class="size-3.5 shrink-0" />
|
||||||
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
|
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -387,10 +387,11 @@ async function handleSetup() {
|
|||||||
|
|
||||||
const hid = hidSelection.value
|
const hid = hidSelection.value
|
||||||
setupData.hid_backend = hid.backend
|
setupData.hid_backend = hid.backend
|
||||||
if (hid.backend === 'ch9329') {
|
if (hid.backend === 'ch9329' || hid.backend === 'ch9329_otg') {
|
||||||
setupData.hid_ch9329_port = hid.ch9329_port
|
setupData.hid_ch9329_port = hid.ch9329_port
|
||||||
setupData.hid_ch9329_baudrate = hid.ch9329_baudrate
|
setupData.hid_ch9329_baudrate = hid.ch9329_baudrate
|
||||||
} else if (hid.backend === 'otg') {
|
}
|
||||||
|
if (hid.backend === 'otg' || hid.backend === 'ch9329_otg') {
|
||||||
setupData.hid_otg_udc = hid.otg_udc
|
setupData.hid_otg_udc = hid.otg_udc
|
||||||
} else if (hid.backend === 'bluetooth') {
|
} else if (hid.backend === 'bluetooth') {
|
||||||
setupData.hid_bluetooth = { ...hid.bluetooth }
|
setupData.hid_bluetooth = { ...hid.bluetooth }
|
||||||
|
|||||||
Reference in New Issue
Block a user