feat: 初步支持 USB 网卡桥接;删除 OTG 端点预算管理

This commit is contained in:
mofeng-git
2026-07-16 19:19:24 +08:00
parent 63c2bb4ca2
commit 213359b6c8
35 changed files with 2367 additions and 727 deletions

View File

@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=One-KVM IP-KVM Service Description=One-KVM IP-KVM Service
Documentation=https://github.com/mofeng-git/One-KVM Documentation=https://github.com/mofeng-git/One-KVM
After=network.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]

View File

@@ -81,29 +81,6 @@ pub enum OtgHidProfile {
Custom, Custom,
} }
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum OtgEndpointBudget {
#[default]
Auto,
Five,
Six,
Unlimited,
}
impl OtgEndpointBudget {
pub fn endpoint_limit_raw(&self) -> Option<u8> {
match self {
Self::Five => Some(5),
Self::Six => Some(6),
Self::Unlimited => None,
Self::Auto => None,
}
}
}
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
@@ -154,26 +131,6 @@ impl OtgHidFunctions {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer !self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
} }
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
let mut endpoints = 0;
if self.keyboard {
endpoints += 1;
if keyboard_leds {
endpoints += 1;
}
}
if self.mouse_relative {
endpoints += 1;
}
if self.mouse_absolute {
endpoints += 1;
}
if self.consumer {
endpoints += 1;
}
endpoints
}
} }
impl Default for OtgHidFunctions { impl Default for OtgHidFunctions {
@@ -216,8 +173,6 @@ pub struct HidConfig {
#[serde(default)] #[serde(default)]
pub otg_profile: OtgHidProfile, pub otg_profile: OtgHidProfile,
#[serde(default)] #[serde(default)]
pub otg_endpoint_budget: OtgEndpointBudget,
#[serde(default)]
pub otg_functions: OtgHidFunctions, pub otg_functions: OtgHidFunctions,
#[serde(default)] #[serde(default)]
pub otg_keyboard_leds: bool, pub otg_keyboard_leds: bool,
@@ -237,7 +192,6 @@ impl Default for HidConfig {
otg_udc: None, otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(), otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(), otg_profile: OtgHidProfile::default(),
otg_endpoint_budget: OtgEndpointBudget::default(),
otg_functions: OtgHidFunctions::default(), otg_functions: OtgHidFunctions::default(),
otg_keyboard_leds: false, otg_keyboard_leds: false,
ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_port: "/dev/ttyUSB0".to_string(),
@@ -262,16 +216,7 @@ impl HidConfig {
self.effective_otg_functions() self.effective_otg_functions()
} }
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 { pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
let functions = self.effective_otg_functions();
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
if msd_enabled {
endpoints += 2;
}
endpoints
}
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
if self.backend != HidBackend::Otg { if self.backend != HidBackend::Otg {
return Ok(()); return Ok(());
} }
@@ -283,17 +228,6 @@ impl HidConfig {
)); ));
} }
let resolved_limit = self.resolved_otg_endpoint_limit();
let required = self.effective_otg_required_endpoints(msd_enabled);
if let Some(limit) = resolved_limit {
if required > limit {
return Err(crate::error::AppError::BadRequest(format!(
"OTG selection requires {} endpoints, but the configured limit is {}",
required, limit
)));
}
}
Ok(()) Ok(())
} }
@@ -317,30 +251,4 @@ impl HidConfig {
} }
}) })
} }
#[inline]
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
if self.backend != HidBackend::Otg {
return None;
}
match self.otg_endpoint_budget {
OtgEndpointBudget::Five => Some(5),
OtgEndpointBudget::Six => Some(6),
OtgEndpointBudget::Unlimited => None,
OtgEndpointBudget::Auto => {
#[cfg(unix)]
let udc = self.resolved_otg_udc().unwrap_or_default();
#[cfg(unix)]
if crate::otg::configfs::is_low_endpoint_udc(&udc) {
Some(5)
} else {
Some(6)
}
#[cfg(not(unix))]
{
Some(6)
}
}
}
}
} }

View File

@@ -8,6 +8,7 @@ mod atx;
mod common; mod common;
mod computer_use; mod computer_use;
mod hid; mod hid;
mod otg_network;
mod stream; mod stream;
mod web; mod web;
@@ -15,6 +16,7 @@ pub use atx::*;
pub use common::*; pub use common::*;
pub use computer_use::*; pub use computer_use::*;
pub use hid::*; pub use hid::*;
pub use otg_network::*;
pub use stream::*; pub use stream::*;
pub use web::*; pub use web::*;
@@ -27,6 +29,7 @@ pub struct AppConfig {
pub auth: AuthConfig, pub auth: AuthConfig,
pub video: VideoConfig, pub video: VideoConfig,
pub hid: HidConfig, pub hid: HidConfig,
pub otg_network: OtgNetworkConfig,
pub msd: MsdConfig, pub msd: MsdConfig,
pub atx: AtxConfig, pub atx: AtxConfig,
pub audio: AudioConfig, pub audio: AudioConfig,
@@ -44,6 +47,7 @@ impl AppConfig {
pub fn enforce_invariants(&mut self) { pub fn enforce_invariants(&mut self) {
if self.hid.backend != HidBackend::Otg { if self.hid.backend != HidBackend::Otg {
self.msd.enabled = false; self.msd.enabled = false;
self.otg_network.enabled = false;
} }
self.atx.normalize(); self.atx.normalize();
} }

View File

@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OtgNetworkDriverMode {
#[default]
Ncm,
Ecm,
Rndis,
}
impl OtgNetworkDriverMode {
pub fn function_name(self) -> &'static str {
match self {
Self::Ncm => "ncm",
Self::Ecm => "ecm",
Self::Rndis => "rndis",
}
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct OtgNetworkConfig {
pub enabled: bool,
pub driver_mode: OtgNetworkDriverMode,
/// Empty means select the connected NetworkManager Ethernet interface.
pub bridge_interface: String,
/// Empty values are resolved from the machine identity at runtime.
pub host_mac: String,
pub device_mac: String,
}
impl OtgNetworkConfig {
pub fn validate(&self) -> crate::error::Result<()> {
for (name, value) in [
("host_mac", self.host_mac.as_str()),
("device_mac", self.device_mac.as_str()),
] {
if !value.is_empty() && !is_valid_unicast_mac(value) {
return Err(crate::error::AppError::BadRequest(format!(
"OTG network {name} must be a locally administered unicast MAC address"
)));
}
}
if !self.host_mac.is_empty()
&& !self.device_mac.is_empty()
&& self.host_mac.eq_ignore_ascii_case(&self.device_mac)
{
return Err(crate::error::AppError::BadRequest(
"OTG network host_mac and device_mac must be different".to_string(),
));
}
if self.bridge_interface.contains('/') || self.bridge_interface.contains('\0') {
return Err(crate::error::AppError::BadRequest(
"Invalid OTG network bridge interface".to_string(),
));
}
Ok(())
}
}
fn is_valid_unicast_mac(value: &str) -> bool {
let bytes = value
.split(':')
.map(|part| u8::from_str_radix(part, 16))
.collect::<Result<Vec<_>, _>>();
matches!(bytes, Ok(ref bytes) if bytes.len() == 6 && bytes[0] & 0x03 == 0x02)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_local_unicast_mac_addresses() {
assert!(is_valid_unicast_mac("02:00:00:00:10:01"));
assert!(!is_valid_unicast_mac("01:00:00:00:10:01"));
assert!(!is_valid_unicast_mac("00:00:00:00:10:01"));
assert!(!is_valid_unicast_mac("bad"));
}
}

View File

@@ -304,7 +304,10 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("OTG Service created"); tracing::info!("OTG Service created");
#[cfg(unix)] #[cfg(unix)]
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await { if let Err(e) = otg_service
.apply_config(&config.hid, &config.msd, &config.otg_network)
.await
{
tracing::warn!("Failed to apply OTG config: {}", e); tracing::warn!("Failed to apply OTG config: {}", e);
} }

945
src/otg/bridge.rs Normal file
View File

@@ -0,0 +1,945 @@
use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use std::thread;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use uuid::Uuid;
use crate::error::{AppError, Result};
const BRIDGE_IF: &str = "okvm-br0";
const PROFILE_PREFIX: &str = "one-kvm-otg";
const JOURNAL_PATH: &str = "/run/one-kvm/otg-network-bridge.json";
const JOURNAL_VERSION: u8 = 2;
const NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
const DHCP_IDENTITY_PROPERTIES: &[&str] = &[
"ipv4.dhcp-client-id",
"ipv4.dhcp-iaid",
"ipv4.dhcp-hostname",
"ipv4.dhcp-fqdn",
"ipv4.dhcp-send-hostname",
"ipv4.dhcp-hostname-flags",
];
const STATIC_IPV4_PROPERTIES: &[&str] = &[
"ipv4.dns",
"ipv4.dns-search",
"ipv4.dns-options",
"ipv4.dns-priority",
"ipv4.routes",
"ipv4.route-table",
"ipv4.routing-rules",
"ipv4.never-default",
"ipv4.may-fail",
"ipv4.ignore-auto-routes",
"ipv4.ignore-auto-dns",
];
#[typeshare]
#[derive(Debug, Clone, Serialize)]
pub struct NetworkInterfaceInfo {
pub name: String,
pub interface_type: String,
pub state: String,
pub connection: String,
pub addresses: Vec<String>,
pub has_default_route: bool,
pub bridge_supported: bool,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NetworkManagerDevice {
name: String,
interface_type: String,
state: String,
connection: String,
addresses: Vec<String>,
has_default_route: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BridgeJournal {
version: u8,
uplink: String,
existing_bridge: bool,
original_connection_uuid: Option<String>,
bridge_profile_uuid: Option<String>,
uplink_profile_uuid: Option<String>,
usb_profile_uuid: String,
}
#[derive(Debug)]
struct TransactionProfiles {
bridge_name: String,
bridge_uuid: String,
uplink_name: String,
uplink_uuid: String,
usb_name: String,
usb_uuid: String,
}
impl TransactionProfiles {
fn new() -> Self {
let transaction = Uuid::new_v4().simple().to_string();
let suffix = &transaction[..12];
Self {
bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"),
bridge_uuid: Uuid::new_v4().to_string(),
uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"),
uplink_uuid: Uuid::new_v4().to_string(),
usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"),
usb_uuid: Uuid::new_v4().to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct NetworkBridgeRuntime {
journal: BridgeJournal,
}
impl NetworkBridgeRuntime {
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
ensure_command("nmcli")?;
ensure_command("ip")?;
let interfaces = list_network_interfaces()?;
let selected = select_bridge_candidate(&interfaces, requested)?;
prepare_device_for_network_manager(usb_interface, "ethernet")?;
Self::activate_physical_uplink(&selected.name, &selected.connection, usb_interface)
}
fn activate_physical_uplink(
uplink: &str,
original_connection: &str,
usb_interface: &str,
) -> Result<Self> {
if original_connection.is_empty() || original_connection == "--" {
return Err(AppError::BadRequest(format!(
"Ethernet interface {uplink} has no active NetworkManager connection"
)));
}
reset_bridge_interface()?;
let original_connection_uuid = active_connection_uuid(uplink)?;
let ipv4_method = connection_value(&original_connection_uuid, "ipv4.method")?;
if !matches!(ipv4_method.as_str(), "auto" | "manual") {
return Err(AppError::BadRequest(format!(
"Connection {original_connection} uses unsupported ipv4.method={ipv4_method}"
)));
}
let ipv6_method = connection_value(&original_connection_uuid, "ipv6.method")?;
if !matches!(ipv6_method.as_str(), "auto" | "disabled" | "ignore") {
return Err(AppError::BadRequest(format!(
"Connection {original_connection} uses unsupported ipv6.method={ipv6_method}"
)));
}
let ipv4_metric = connection_value(&original_connection_uuid, "ipv4.route-metric")?;
let ipv6_metric = connection_value(&original_connection_uuid, "ipv6.route-metric")?;
let original_had_default_route = default_route(uplink).is_some();
let mac_path = Path::new("/sys/class/net").join(uplink).join("address");
let uplink_mac = fs::read_to_string(&mac_path)
.map_err(|e| {
AppError::Internal(format!("Failed to read {}: {}", mac_path.display(), e))
})?
.trim()
.to_string();
let profiles = TransactionProfiles::new();
let journal = BridgeJournal {
version: JOURNAL_VERSION,
uplink: uplink.to_string(),
existing_bridge: false,
original_connection_uuid: Some(original_connection_uuid.clone()),
bridge_profile_uuid: Some(profiles.bridge_uuid.clone()),
uplink_profile_uuid: Some(profiles.uplink_uuid.clone()),
usb_profile_uuid: profiles.usb_uuid.clone(),
};
write_journal(&journal)?;
let prepare_result: Result<()> = (|| {
create_bridge_interface(&uplink_mac)?;
run_nmcli(&[
"connection",
"add",
"type",
"bridge",
"ifname",
BRIDGE_IF,
"con-name",
&profiles.bridge_name,
"connection.uuid",
&profiles.bridge_uuid,
])?;
run_nmcli(&[
"connection",
"modify",
&profiles.bridge_uuid,
"connection.interface-name",
BRIDGE_IF,
"bridge.mac-address",
&uplink_mac,
"bridge.stp",
"no",
"ipv6.method",
&ipv6_method,
"connection.autoconnect",
"no",
])?;
configure_ipv4_profile(
&original_connection_uuid,
&profiles.bridge_uuid,
&ipv4_method,
)?;
for (property, value) in [
("ipv4.route-metric", ipv4_metric.as_str()),
("ipv6.route-metric", ipv6_metric.as_str()),
] {
if !value.is_empty() && value != "-1" {
run_nmcli(&[
"connection",
"modify",
&profiles.bridge_uuid,
property,
value,
])?;
}
}
run_nmcli(&[
"connection",
"add",
"type",
"ethernet",
"ifname",
uplink,
"con-name",
&profiles.uplink_name,
"connection.uuid",
&profiles.uplink_uuid,
"master",
BRIDGE_IF,
"slave-type",
"bridge",
"connection.autoconnect",
"no",
])?;
run_nmcli(&[
"connection",
"add",
"type",
"ethernet",
"ifname",
usb_interface,
"con-name",
&profiles.usb_name,
"connection.uuid",
&profiles.usb_uuid,
"master",
BRIDGE_IF,
"slave-type",
"bridge",
"connection.autoconnect",
"no",
])?;
Ok(())
})();
if let Err(error) = prepare_result {
return Err(restore_or_combine(&journal, error));
}
let result = (|| {
run_nmcli(&["connection", "down", "uuid", &original_connection_uuid])?;
activate_connection(
"bridge",
&profiles.bridge_name,
&profiles.bridge_uuid,
Some(BRIDGE_IF),
)?;
activate_connection(
"uplink",
&profiles.uplink_name,
&profiles.uplink_uuid,
Some(uplink),
)?;
activate_connection(
"USB",
&profiles.usb_name,
&profiles.usb_uuid,
Some(usb_interface),
)?;
let deadline = Instant::now() + Duration::from_secs(35);
while Instant::now() < deadline {
if first_ipv4_address(BRIDGE_IF).is_some()
&& (!original_had_default_route || default_route(BRIDGE_IF).is_some())
{
break;
}
thread::sleep(Duration::from_secs(1));
}
let address = first_ipv4_address(BRIDGE_IF).ok_or_else(|| {
AppError::Internal(
"OTG bridge did not obtain an IPv4 address from upstream DHCP".to_string(),
)
})?;
let route = default_route(BRIDGE_IF);
if original_had_default_route && route.is_none() {
return Err(AppError::Internal(
"OTG bridge did not obtain the original default route".to_string(),
));
}
if let Some(route) = route.as_deref() {
if let Some(gateway) = gateway_from_route(route) {
if let Err(error) = run_command("ping", &["-c", "1", "-W", "2", gateway]) {
tracing::warn!(
"OTG bridge gateway ICMP diagnostic failed for {}: {}",
gateway,
error
);
}
}
}
Ok(address)
})();
match result {
Ok(_address) => Ok(Self { journal }),
Err(error) => Err(restore_or_combine(&journal, error)),
}
}
pub fn deactivate(&self) -> Result<()> {
restore_from_journal(&self.journal)
}
pub fn recover_stale_transaction() -> Result<()> {
let value = match fs::read_to_string(JOURNAL_PATH) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(AppError::Internal(format!(
"Failed to read OTG network recovery journal: {error}"
)))
}
};
match serde_json::from_str::<BridgeJournal>(&value) {
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
Ok(journal) => Err(AppError::Config(format!(
"Unsupported OTG network recovery journal version {}",
journal.version
))),
Err(error) => Err(AppError::Config(format!(
"Invalid OTG network recovery journal: {error}"
))),
}
}
}
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
let devices = enumerate_network_manager_devices()?;
Ok(bridge_candidates(devices, is_physical_network_interface))
}
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
ensure_command("nmcli")?;
let output = run_command(
"nmcli",
&[
"-t",
"--escape",
"no",
"-f",
"DEVICE,TYPE,STATE,CONNECTION",
"device",
"status",
],
)?;
let text = String::from_utf8_lossy(&output.stdout);
let mut devices = parse_network_manager_devices(&text);
for device in &mut devices {
device.addresses = ipv4_addresses(&device.name);
device.has_default_route = default_route(&device.name).is_some();
}
Ok(devices)
}
fn parse_network_manager_devices(text: &str) -> Vec<NetworkManagerDevice> {
let mut devices = Vec::new();
for line in text.lines() {
let fields = line.splitn(4, ':').collect::<Vec<_>>();
if fields.len() != 4 || fields[0].is_empty() {
continue;
}
devices.push(NetworkManagerDevice {
name: fields[0].to_string(),
interface_type: fields[1].to_string(),
state: fields[2].to_string(),
connection: fields[3].to_string(),
addresses: Vec::new(),
has_default_route: false,
});
}
devices
}
fn bridge_candidates(
devices: Vec<NetworkManagerDevice>,
is_physical: impl Fn(&str) -> bool,
) -> Vec<NetworkInterfaceInfo> {
devices
.into_iter()
.filter(|device| {
device.interface_type == "ethernet"
&& device.state == "connected"
&& !device.connection.is_empty()
&& device.connection != "--"
&& is_physical(&device.name)
})
.map(|device| NetworkInterfaceInfo {
name: device.name,
interface_type: device.interface_type,
state: device.state,
connection: device.connection,
addresses: device.addresses,
has_default_route: device.has_default_route,
bridge_supported: true,
reason: None,
})
.collect()
}
fn is_physical_network_interface(name: &str) -> bool {
Path::new("/sys/class/net")
.join(name)
.join("device")
.exists()
}
fn select_bridge_candidate<'a>(
interfaces: &'a [NetworkInterfaceInfo],
requested: &str,
) -> Result<&'a NetworkInterfaceInfo> {
if requested.trim().is_empty() {
return interfaces
.iter()
.max_by_key(|item| item.has_default_route)
.ok_or_else(|| {
AppError::Config(
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
.to_string(),
)
});
}
interfaces
.iter()
.find(|item| item.name == requested)
.ok_or_else(|| {
AppError::Config(format!(
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
))
})
}
fn restore_from_journal(journal: &BridgeJournal) -> Result<()> {
let mut errors = Vec::new();
for (kind, profile_uuid) in [
("USB", Some(journal.usb_profile_uuid.as_str())),
("uplink", journal.uplink_profile_uuid.as_deref()),
("bridge", journal.bridge_profile_uuid.as_deref()),
] {
let Some(profile_uuid) = profile_uuid else {
continue;
};
if let Err(error) = delete_connection(profile_uuid) {
errors.push(format!(
"failed to remove owned {kind} profile {profile_uuid}: {error}"
));
}
}
if !journal.existing_bridge {
if let Err(error) = delete_bridge_interface() {
errors.push(format!(
"failed to remove owned bridge interface {BRIDGE_IF}: {error}"
));
}
if let Some(ref original_uuid) = journal.original_connection_uuid {
if let Err(error) = run_nmcli(&[
"connection",
"up",
"uuid",
original_uuid,
"ifname",
&journal.uplink,
]) {
errors.push(format!(
"failed to restore original profile {original_uuid}: {error}"
));
}
}
}
if !errors.is_empty() {
return Err(AppError::Config(errors.join("; ")));
}
match fs::remove_file(JOURNAL_PATH) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AppError::Internal(format!(
"Failed to remove OTG network recovery journal: {error}"
))),
}
}
fn restore_or_combine(journal: &BridgeJournal, primary: AppError) -> AppError {
match restore_from_journal(journal) {
Ok(()) => primary,
Err(rollback) => AppError::Config(format!("{primary}; bridge rollback failed: {rollback}")),
}
}
fn reset_bridge_interface() -> Result<()> {
for profile_uuid in connection_uuids()? {
if connection_value(&profile_uuid, "connection.interface-name")? == BRIDGE_IF {
tracing::warn!(
"Removing NetworkManager profile {} bound to reserved interface {}",
profile_uuid,
BRIDGE_IF
);
run_nmcli(&["connection", "delete", "uuid", &profile_uuid])?;
}
}
delete_bridge_interface()
}
fn create_bridge_interface(mac_address: &str) -> Result<()> {
run_command("ip", &["link", "add", "name", BRIDGE_IF, "type", "bridge"])?;
run_command(
"ip",
&["link", "set", "dev", BRIDGE_IF, "address", mac_address],
)?;
prepare_device_for_network_manager(BRIDGE_IF, "bridge")
}
fn delete_bridge_interface() -> Result<()> {
if !Path::new("/sys/class/net").join(BRIDGE_IF).exists() {
return Ok(());
}
run_command("ip", &["link", "delete", BRIDGE_IF, "type", "bridge"])?;
Ok(())
}
fn prepare_device_for_network_manager(interface: &str, expected_type: &str) -> Result<()> {
run_command("ip", &["link", "set", interface, "up"])?;
let deadline = Instant::now() + NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT;
let mut requested_managed = false;
while Instant::now() < deadline {
match enumerate_network_manager_devices() {
Ok(devices) => {
if let Some(device) = devices.iter().find(|device| device.name == interface) {
if device.interface_type != expected_type {
return Err(AppError::BadRequest(format!(
"One-KVM interface {interface} has NetworkManager type {}, expected {expected_type}",
device.interface_type,
)));
}
if device.state != "unmanaged" {
return Ok(());
}
if !requested_managed {
tracing::info!(
"Marking One-KVM interface {} as managed by NetworkManager",
interface
);
run_nmcli(&["device", "set", interface, "managed", "yes"])?;
requested_managed = true;
}
}
}
Err(error) => {
tracing::debug!(
"Waiting for NetworkManager to discover One-KVM interface {}: {}",
interface,
error
);
}
}
thread::sleep(Duration::from_millis(100));
}
Err(AppError::Internal(format!(
"NetworkManager did not discover One-KVM {expected_type} interface {interface} within {} seconds",
NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT.as_secs()
)))
}
fn activate_connection(kind: &str, name: &str, uuid: &str, interface: Option<&str>) -> Result<()> {
let result = match interface {
Some(interface) => run_nmcli(&["connection", "up", name, "ifname", interface]),
None => run_nmcli(&["connection", "up", name]),
};
result.map_err(|error| {
let target = interface
.map(|value| format!(" on {value}"))
.unwrap_or_default();
AppError::Internal(format!(
"Failed to activate One-KVM {kind} profile {name} ({uuid}){target}: {error}"
))
})?;
Ok(())
}
fn active_connection_uuid(interface: &str) -> Result<String> {
let output = run_nmcli(&[
"--escape",
"no",
"-g",
"GENERAL.CON-UUID",
"device",
"show",
interface,
])?;
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if uuid.is_empty() || uuid == "--" {
return Err(AppError::BadRequest(format!(
"Ethernet interface {interface} has no active NetworkManager profile UUID"
)));
}
Ok(uuid)
}
fn connection_uuids() -> Result<Vec<String>> {
let output = run_nmcli(&["-t", "--escape", "no", "-f", "UUID", "connection", "show"])?;
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect())
}
fn delete_connection(profile_uuid: &str) -> Result<()> {
if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
return Ok(());
}
run_nmcli(&["connection", "delete", "uuid", profile_uuid])?;
Ok(())
}
fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> {
for property in properties {
let Ok(value) = connection_value(source, property) else {
tracing::debug!(
"Skipping unsupported NetworkManager property {} while configuring OTG bridge",
property
);
continue;
};
if value.is_empty() || value == "--" {
continue;
}
run_nmcli(&["connection", "modify", target, property, &value])?;
}
Ok(())
}
fn configure_ipv4_profile(source: &str, target: &str, method: &str) -> Result<()> {
match method {
"auto" => {
run_nmcli(&["connection", "modify", target, "ipv4.method", "auto"])?;
copy_connection_properties(source, target, DHCP_IDENTITY_PROPERTIES)
}
"manual" => {
let addresses = connection_value(source, "ipv4.addresses")?;
if addresses.is_empty() || addresses == "--" {
return Err(AppError::BadRequest(
"Static IPv4 profile has no ipv4.addresses value".to_string(),
));
}
let gateway = connection_value(source, "ipv4.gateway")?;
if gateway.is_empty() || gateway == "--" {
run_nmcli(&[
"connection",
"modify",
target,
"ipv4.method",
"manual",
"ipv4.addresses",
&addresses,
])?;
} else {
run_nmcli(&[
"connection",
"modify",
target,
"ipv4.method",
"manual",
"ipv4.addresses",
&addresses,
"ipv4.gateway",
&gateway,
])?;
}
copy_connection_properties(source, target, STATIC_IPV4_PROPERTIES)
}
_ => Err(AppError::BadRequest(format!(
"Unsupported IPv4 method {method}"
))),
}
}
fn write_journal(journal: &BridgeJournal) -> Result<()> {
let path = Path::new(JOURNAL_PATH);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
AppError::Internal(format!("Failed to create {}: {}", parent.display(), e))
})?;
}
let value = serde_json::to_vec(journal)
.map_err(|e| AppError::Internal(format!("Failed to serialize bridge journal: {e}")))?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, value)
.map_err(|e| AppError::Internal(format!("Failed to write bridge recovery journal: {e}")))?;
fs::rename(&temporary, path)
.map_err(|e| AppError::Internal(format!("Failed to commit bridge recovery journal: {e}")))
}
fn connection_value(connection: &str, property: &str) -> Result<String> {
let output = run_nmcli(&[
"--escape",
"no",
"-g",
property,
"connection",
"show",
connection,
])?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn first_ipv4_address(interface: &str) -> Option<String> {
ipv4_addresses(interface).into_iter().next()
}
fn ipv4_addresses(interface: &str) -> Vec<String> {
let Ok(output) = Command::new("ip")
.args(["-4", "-o", "address", "show", "dev", interface])
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let fields = line.split_whitespace().collect::<Vec<_>>();
fields
.iter()
.position(|field| *field == "inet")
.and_then(|index| fields.get(index + 1))
.map(|value| (*value).to_string())
})
.collect()
}
fn default_route(interface: &str) -> Option<String> {
let output = Command::new("ip")
.args(["-4", "route", "show", "default", "dev", interface])
.output()
.ok()?;
String::from_utf8_lossy(&output.stdout)
.lines()
.find(|line| !line.trim().is_empty())
.map(str::to_string)
}
fn gateway_from_route(route: &str) -> Option<&str> {
let fields = route.split_whitespace().collect::<Vec<_>>();
fields
.windows(2)
.find_map(|part| (part[0] == "via").then_some(part[1]))
}
fn ensure_command(name: &str) -> Result<()> {
let status = Command::new(name).arg("--version").output();
if status.is_err() {
return Err(AppError::BadRequest(format!(
"OTG bridge requires the {name} command"
)));
}
Ok(())
}
fn run_nmcli(args: &[&str]) -> Result<Output> {
run_command("nmcli", args)
}
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
let output = Command::new(command)
.env("LC_ALL", "C")
.args(args)
.output()
.map_err(|e| {
AppError::Internal(format!(
"Failed to execute {command} {}: {e}",
args.join(" ")
))
})?;
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Err(AppError::Internal(format!(
"{command} {} failed: {}",
args.join(" "),
if stderr.is_empty() { stdout } else { stderr }
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn device(
name: &str,
interface_type: &str,
state: &str,
connection: &str,
has_default_route: bool,
) -> NetworkManagerDevice {
NetworkManagerDevice {
name: name.to_string(),
interface_type: interface_type.to_string(),
state: state.to_string(),
connection: connection.to_string(),
addresses: Vec::new(),
has_default_route,
}
}
#[test]
fn bridge_journal_round_trip() {
let journal = BridgeJournal {
version: JOURNAL_VERSION,
uplink: "eth0".to_string(),
existing_bridge: false,
original_connection_uuid: Some("original-uuid".to_string()),
bridge_profile_uuid: Some("bridge-uuid".to_string()),
uplink_profile_uuid: Some("uplink-uuid".to_string()),
usb_profile_uuid: "usb-uuid".to_string(),
};
let value = serde_json::to_string(&journal).unwrap();
let decoded: BridgeJournal = serde_json::from_str(&value).unwrap();
assert_eq!(decoded.uplink, "eth0");
assert_eq!(decoded.bridge_profile_uuid.as_deref(), Some("bridge-uuid"));
}
#[test]
fn transaction_profiles_use_unique_names_and_uuids() {
let first = TransactionProfiles::new();
let second = TransactionProfiles::new();
assert_ne!(first.bridge_name, second.bridge_name);
assert_ne!(first.bridge_uuid, second.bridge_uuid);
assert!(first.usb_name.starts_with(PROFILE_PREFIX));
assert!(Uuid::parse_str(&first.usb_uuid).is_ok());
}
#[test]
fn gateway_is_optional_diagnostic_data() {
assert_eq!(
gateway_from_route("default via 192.0.2.1 dev okvm-br0"),
Some("192.0.2.1")
);
assert_eq!(gateway_from_route("default dev okvm-br0"), None);
}
#[test]
fn dhcp_identity_properties_include_client_id_and_hostname() {
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-client-id"));
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-iaid"));
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-hostname"));
}
#[test]
fn static_ipv4_properties_cover_dns_routes_and_policy() {
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.dns"));
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.routes"));
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.route-table"));
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.never-default"));
}
#[test]
fn full_network_manager_enumeration_keeps_runtime_devices() {
let devices = parse_network_manager_devices(
"eth0:ethernet:connected:Wired connection 1\n\
usb0:ethernet:disconnected:--\n\
okvm-br0:bridge:unmanaged:--\n",
);
assert_eq!(
devices
.iter()
.map(|device| device.name.as_str())
.collect::<Vec<_>>(),
["eth0", "usb0", "okvm-br0"]
);
}
#[test]
fn bridge_candidates_only_keep_connected_physical_ethernet() {
let devices = vec![
device("eth0", "ethernet", "connected", "one-kvm-otg-uplink", false),
device("wlx76012dc07213", "wifi", "connected", "Wi-Fi", true),
device("okvm-br0", "bridge", "connected", "Bridge", true),
device("usb0", "ethernet", "connected", "USB", false),
device("lo", "loopback", "connected", "lo", false),
device("bond0", "bond", "connected", "Bond", false),
device("tun0", "tun", "connected", "Tunnel", false),
device("veth0", "ethernet", "connected", "Virtual", false),
device("eth1", "ethernet", "disconnected", "--", false),
device("eth2", "ethernet", "connected", "--", false),
device("eth3", "ethernet", "connected", "", false),
];
let candidates = bridge_candidates(devices, |name| {
matches!(name, "eth0" | "eth1" | "eth2" | "eth3")
});
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].name, "eth0");
assert!(candidates[0].bridge_supported);
assert_eq!(candidates[0].reason, None);
}
#[test]
fn automatic_bridge_selection_prefers_default_route() {
let candidates = bridge_candidates(
vec![
device("eth0", "ethernet", "connected", "Wired 1", false),
device("eth1", "ethernet", "connected", "Wired 2", true),
],
|_| true,
);
let selected = select_bridge_candidate(&candidates, "").unwrap();
assert_eq!(selected.name, "eth1");
}
#[test]
fn bridge_selection_reports_when_no_candidate_exists() {
let error = select_bridge_candidate(&[], "").unwrap_err();
assert!(matches!(error, AppError::Config(_)));
assert!(error.to_string().contains("connected physical"));
}
}

View File

@@ -75,11 +75,6 @@ fn collect_dir_names(path: &Path, devices: &mut Vec<String>) {
} }
} }
pub fn is_low_endpoint_udc(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.contains("musb") || name.contains("musb-hdrc")
}
/// Sysfs/configfs: one write syscall with final buffer (incl. newline when needed). /// Sysfs/configfs: one write syscall with final buffer (incl. newline when needed).
pub fn write_file(path: &Path, content: &str) -> Result<()> { pub fn write_file(path: &Path, content: &str) -> Result<()> {
let mut file = OpenOptions::new() let mut file = OpenOptions::new()

View File

@@ -1,79 +0,0 @@
use crate::error::{AppError, Result};
pub const DEFAULT_MAX_ENDPOINTS: u8 = 16;
#[derive(Debug, Clone)]
pub struct EndpointAllocator {
max_endpoints: u8,
used_endpoints: u8,
}
impl EndpointAllocator {
pub fn new(max_endpoints: u8) -> Self {
Self {
max_endpoints,
used_endpoints: 0,
}
}
pub fn allocate(&mut self, count: u8) -> Result<()> {
if self.used_endpoints + count > self.max_endpoints {
return Err(AppError::Internal(format!(
"Not enough endpoints: need {}, available {}",
count,
self.available()
)));
}
self.used_endpoints += count;
Ok(())
}
pub fn release(&mut self, count: u8) {
self.used_endpoints = self.used_endpoints.saturating_sub(count);
}
pub fn available(&self) -> u8 {
self.max_endpoints.saturating_sub(self.used_endpoints)
}
pub fn used(&self) -> u8 {
self.used_endpoints
}
pub fn max(&self) -> u8 {
self.max_endpoints
}
pub fn can_allocate(&self, count: u8) -> bool {
self.available() >= count
}
}
impl Default for EndpointAllocator {
fn default() -> Self {
Self::new(DEFAULT_MAX_ENDPOINTS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_allocator() {
let mut alloc = EndpointAllocator::new(8);
assert_eq!(alloc.available(), 8);
alloc.allocate(2).unwrap();
assert_eq!(alloc.available(), 6);
assert_eq!(alloc.used(), 2);
alloc.allocate(4).unwrap();
assert_eq!(alloc.available(), 2);
assert!(alloc.allocate(3).is_err());
alloc.release(2);
assert_eq!(alloc.available(), 4);
}
}

View File

@@ -5,8 +5,6 @@ use crate::error::Result;
pub trait GadgetFunction: Send + Sync { pub trait GadgetFunction: Send + Sync {
fn name(&self) -> &str; fn name(&self) -> &str;
fn endpoints_required(&self) -> u8;
fn create(&self, gadget_path: &Path) -> Result<()>; fn create(&self, gadget_path: &Path) -> Result<()>;
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()>; fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()>;

View File

@@ -19,15 +19,6 @@ pub enum HidFunctionType {
} }
impl HidFunctionType { impl HidFunctionType {
pub fn endpoints(&self) -> u8 {
match self {
HidFunctionType::Keyboard => 1,
HidFunctionType::MouseRelative => 1,
HidFunctionType::MouseAbsolute => 1,
HidFunctionType::ConsumerControl => 1,
}
}
pub fn protocol(&self) -> u8 { pub fn protocol(&self) -> u8 {
match self { match self {
HidFunctionType::Keyboard => 1, HidFunctionType::Keyboard => 1,
@@ -130,10 +121,6 @@ impl GadgetFunction for HidFunction {
&self.name &self.name
} }
fn endpoints_required(&self) -> u8 {
self.func_type.endpoints()
}
fn create(&self, gadget_path: &Path) -> Result<()> { fn create(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path); let func_path = self.function_path(gadget_path);
create_dir(&func_path)?; create_dir(&func_path)?;
@@ -197,10 +184,6 @@ mod tests {
#[test] #[test]
fn test_hid_function_types() { fn test_hid_function_types() {
assert_eq!(HidFunctionType::Keyboard.endpoints(), 1);
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
assert_eq!(HidFunctionType::Keyboard.report_length(false), 8); assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
assert_eq!(HidFunctionType::Keyboard.report_length(true), 8); assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4); assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);

View File

@@ -7,10 +7,11 @@ use super::configfs::{
remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
DEFAULT_USB_VENDOR_ID, USB_BCD_USB, DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
}; };
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
use super::function::GadgetFunction; use super::function::GadgetFunction;
use super::hid::HidFunction; use super::hid::HidFunction;
use super::msd::MsdFunction; use super::msd::MsdFunction;
use super::network::NetworkFunction;
use crate::config::OtgNetworkConfig;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300; const REBIND_DELAY_MS: u64 = 300;
@@ -43,9 +44,9 @@ pub struct OtgGadgetManager {
gadget_path: PathBuf, gadget_path: PathBuf,
config_path: PathBuf, config_path: PathBuf,
descriptor: GadgetDescriptor, descriptor: GadgetDescriptor,
endpoint_allocator: EndpointAllocator,
hid_instance: u8, hid_instance: u8,
msd_instance: u8, msd_instance: u8,
network_instance: u8,
functions: Vec<Box<dyn GadgetFunction>>, functions: Vec<Box<dyn GadgetFunction>>,
bound_udc: Option<String>, bound_udc: Option<String>,
created_by_us: bool, created_by_us: bool,
@@ -53,18 +54,14 @@ pub struct OtgGadgetManager {
impl OtgGadgetManager { impl OtgGadgetManager {
pub fn new() -> Self { pub fn new() -> Self {
Self::with_config(DEFAULT_GADGET_NAME, DEFAULT_MAX_ENDPOINTS) Self::with_config(DEFAULT_GADGET_NAME)
} }
pub fn with_config(gadget_name: &str, max_endpoints: u8) -> Self { pub fn with_config(gadget_name: &str) -> Self {
Self::with_descriptor(gadget_name, max_endpoints, GadgetDescriptor::default()) Self::with_descriptor(gadget_name, GadgetDescriptor::default())
} }
pub fn with_descriptor( pub fn with_descriptor(gadget_name: &str, descriptor: GadgetDescriptor) -> Self {
gadget_name: &str,
max_endpoints: u8,
descriptor: GadgetDescriptor,
) -> Self {
let gadget_path = configfs_path().join(gadget_name); let gadget_path = configfs_path().join(gadget_name);
let config_path = gadget_path.join("configs/c.1"); let config_path = gadget_path.join("configs/c.1");
@@ -73,9 +70,9 @@ impl OtgGadgetManager {
gadget_path, gadget_path,
config_path, config_path,
descriptor, descriptor,
endpoint_allocator: EndpointAllocator::new(max_endpoints),
hid_instance: 0, hid_instance: 0,
msd_instance: 0, msd_instance: 0,
network_instance: 0,
functions: Vec::with_capacity(4), functions: Vec::with_capacity(4),
bound_udc: None, bound_udc: None,
created_by_us: false, created_by_us: false,
@@ -143,22 +140,16 @@ impl OtgGadgetManager {
Ok(func_clone) Ok(func_clone)
} }
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> { pub fn add_network(&mut self, config: &OtgNetworkConfig) -> Result<NetworkFunction> {
let endpoints = func.endpoints_required(); let func = NetworkFunction::new(self.network_instance, config)?;
let func_clone = func.clone();
if !self.endpoint_allocator.can_allocate(endpoints) { self.add_function(Box::new(func))?;
return Err(AppError::Internal(format!( self.network_instance += 1;
"Not enough endpoints for function {}: need {}, available {}", Ok(func_clone)
func.name(),
endpoints,
self.endpoint_allocator.available()
)));
} }
self.endpoint_allocator.allocate(endpoints)?; fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
self.functions.push(func); self.functions.push(func);
Ok(()) Ok(())
} }
@@ -224,30 +215,51 @@ impl OtgGadgetManager {
pub fn cleanup(&mut self) -> Result<()> { pub fn cleanup(&mut self) -> Result<()> {
if !self.gadget_exists() { if !self.gadget_exists() {
self.created_by_us = false;
return Ok(()); return Ok(());
} }
info!("Cleaning up OTG USB Gadget: {}", self.gadget_name); info!("Cleaning up OTG USB Gadget: {}", self.gadget_name);
let mut errors = Vec::new();
let _ = self.unbind(); if let Err(error) = self.unbind() {
errors.push(format!("unbind failed: {error}"));
}
for func in self.functions.iter().rev() { for func in self.functions.iter().rev() {
let _ = func.unlink(&self.config_path); if let Err(error) = func.unlink(&self.config_path) {
errors.push(format!("unlink {} failed: {error}", func.name()));
}
} }
let config_strings = self.config_path.join("strings/0x409"); let config_strings = self.config_path.join("strings/0x409");
let _ = remove_dir(&config_strings); if let Err(error) = remove_dir(&config_strings) {
let _ = remove_dir(&self.config_path); errors.push(error.to_string());
}
if let Err(error) = remove_dir(&self.config_path) {
errors.push(error.to_string());
}
for func in self.functions.iter().rev() { for func in self.functions.iter().rev() {
let _ = func.cleanup(&self.gadget_path); if let Err(error) = func.cleanup(&self.gadget_path) {
errors.push(format!("cleanup {} failed: {error}", func.name()));
}
} }
let gadget_strings = self.gadget_path.join("strings/0x409"); let gadget_strings = self.gadget_path.join("strings/0x409");
let _ = remove_dir(&gadget_strings); if let Err(error) = remove_dir(&gadget_strings) {
errors.push(error.to_string());
}
if let Err(e) = remove_dir(&self.gadget_path) { if let Err(error) = remove_dir(&self.gadget_path) {
warn!("Could not remove gadget directory: {}", e); errors.push(error.to_string());
}
if !errors.is_empty() {
return Err(AppError::Config(format!(
"OTG gadget cleanup incomplete: {}",
errors.join("; ")
)));
} }
self.created_by_us = false; self.created_by_us = false;
@@ -313,12 +325,21 @@ impl OtgGadgetManager {
} }
fn configuration_label(&self) -> &'static str { fn configuration_label(&self) -> &'static str {
if self let has_msd = self
.functions .functions
.iter() .iter()
.any(|func| func.name().starts_with("mass_storage.")) .any(|func| func.name().starts_with("mass_storage."));
{ let has_network = self.functions.iter().any(|func| {
["ncm.", "ecm.", "rndis."]
.iter()
.any(|prefix| func.name().starts_with(prefix))
});
if has_msd && has_network {
"Config 1: HID + MSD + NET"
} else if has_msd {
"Config 1: HID + MSD" "Config 1: HID + MSD"
} else if has_network {
"Config 1: HID + NET"
} else { } else {
"Config 1: HID" "Config 1: HID"
} }
@@ -428,14 +449,12 @@ mod tests {
} }
#[test] #[test]
fn test_endpoint_tracking() { fn test_function_selection_is_not_prevalidated() {
let mut manager = OtgGadgetManager::with_config("test", 8); let mut manager = OtgGadgetManager::with_config("test");
let _ = manager.add_keyboard(false); assert!(manager.add_keyboard(false).is_ok());
assert_eq!(manager.endpoint_allocator.used(), 1); assert!(manager.add_mouse_relative().is_ok());
assert!(manager.add_mouse_absolute().is_ok());
let _ = manager.add_mouse_relative(); assert_eq!(manager.functions.len(), 3);
let _ = manager.add_mouse_absolute();
assert_eq!(manager.endpoint_allocator.used(), 3);
} }
} }

View File

@@ -1,8 +1,9 @@
//! USB OTG composite gadget (HID + MSD). //! USB OTG composite gadget (HID + MSD + Ethernet).
#[cfg(unix)]
pub mod bridge;
#[cfg(unix)] #[cfg(unix)]
pub mod configfs; pub mod configfs;
pub mod endpoint;
#[cfg(unix)] #[cfg(unix)]
pub mod function; pub mod function;
#[cfg(unix)] #[cfg(unix)]
@@ -11,6 +12,8 @@ pub mod hid;
pub mod manager; pub mod manager;
#[cfg(unix)] #[cfg(unix)]
pub mod msd; pub mod msd;
#[cfg(unix)]
pub mod network;
pub mod report_desc; pub mod report_desc;
pub mod self_check; pub mod self_check;
#[cfg(unix)] #[cfg(unix)]
@@ -21,7 +24,9 @@ pub use manager::{wait_for_hid_devices, OtgGadgetManager};
#[cfg(unix)] #[cfg(unix)]
pub use msd::{MsdFunction, MsdLunConfig}; pub use msd::{MsdFunction, MsdLunConfig};
#[cfg(unix)] #[cfg(unix)]
pub use service::{HidDevicePaths, OtgService}; pub use network::NetworkFunction;
#[cfg(unix)]
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
/// List USB Device Controller names exposed by sysfs. /// List USB Device Controller names exposed by sysfs.
pub fn list_udc_devices() -> Vec<String> { pub fn list_udc_devices() -> Vec<String> {

View File

@@ -333,10 +333,6 @@ impl GadgetFunction for MsdFunction {
&self.name &self.name
} }
fn endpoints_required(&self) -> u8 {
2
}
fn create(&self, gadget_path: &Path) -> Result<()> { fn create(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path); let func_path = self.function_path(gadget_path);
create_dir(&func_path)?; create_dir(&func_path)?;
@@ -375,17 +371,18 @@ impl GadgetFunction for MsdFunction {
fn cleanup(&self, gadget_path: &Path) -> Result<()> { fn cleanup(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path); let func_path = self.function_path(gadget_path);
let mut errors = Vec::new();
let lun_paths = match self.existing_lun_paths(gadget_path) { let lun_paths = match self.existing_lun_paths(gadget_path) {
Ok(luns) => luns, Ok(luns) => luns,
Err(e) => { Err(e) => {
warn!("Could not enumerate MSD LUN directories: {}", e); errors.push(format!("could not enumerate MSD LUN directories: {e}"));
Vec::new() Vec::new()
} }
}; };
for (lun, lun_path) in lun_paths { for (lun, lun_path) in lun_paths {
if let Err(e) = self.disconnect_lun_path(&lun_path, lun) { if let Err(e) = self.disconnect_lun_path(&lun_path, lun) {
warn!("Could not disconnect LUN {} during cleanup: {}", lun, e); errors.push(format!("could not disconnect LUN {lun}: {e}"));
} }
// lun.0 is the mass-storage function's configfs default group. It // lun.0 is the mass-storage function's configfs default group. It
// cannot be removed directly and is released with the function. // cannot be removed directly and is released with the function.
@@ -393,12 +390,19 @@ impl GadgetFunction for MsdFunction {
continue; continue;
} }
if let Err(e) = remove_dir(&lun_path) { if let Err(e) = remove_dir(&lun_path) {
warn!("Could not remove LUN {} directory: {}", lun, e); errors.push(format!("could not remove LUN {lun} directory: {e}"));
} }
} }
if let Err(e) = remove_dir(&func_path) { if let Err(e) = remove_dir(&func_path) {
warn!("Could not remove MSD function directory: {}", e); errors.push(format!("could not remove MSD function directory: {e}"));
}
if !errors.is_empty() {
return Err(AppError::Config(format!(
"MSD cleanup incomplete: {}",
errors.join("; ")
)));
} }
debug!("Cleaned up MSD function {}", self.name()); debug!("Cleaned up MSD function {}", self.name());
@@ -431,7 +435,6 @@ mod tests {
fn test_msd_function_name() { fn test_msd_function_name() {
let msd = MsdFunction::new(0, 1).unwrap(); let msd = MsdFunction::new(0, 1).unwrap();
assert_eq!(msd.name(), "mass_storage.usb0"); assert_eq!(msd.name(), "mass_storage.usb0");
assert_eq!(msd.endpoints_required(), 2);
assert_eq!(msd.lun_capacity, 1); assert_eq!(msd.lun_capacity, 1);
let multi = MsdFunction::new(0, 8).unwrap(); let multi = MsdFunction::new(0, 8).unwrap();
@@ -498,7 +501,7 @@ mod tests {
} }
#[test] #[test]
fn cleanup_leaves_default_lun_for_configfs_function_removal() { fn cleanup_reports_when_non_configfs_cannot_release_default_lun() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let func_path = temp_dir.path().join("functions/mass_storage.usb0"); let func_path = temp_dir.path().join("functions/mass_storage.usb0");
for lun in 0..2 { for lun in 0..2 {
@@ -506,8 +509,9 @@ mod tests {
} }
let msd = MsdFunction::new(0, 1).unwrap(); let msd = MsdFunction::new(0, 1).unwrap();
msd.cleanup(temp_dir.path()).unwrap(); let error = msd.cleanup(temp_dir.path()).unwrap_err();
assert!(error.to_string().contains("MSD cleanup incomplete"));
assert!(func_path.join("lun.0").exists()); assert!(func_path.join("lun.0").exists());
assert!(!func_path.join("lun.1").exists()); assert!(!func_path.join("lun.1").exists());
} }

196
src/otg/network.rs Normal file
View File

@@ -0,0 +1,196 @@
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use tracing::debug;
use super::configfs::{
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
};
use super::function::GadgetFunction;
use crate::config::{OtgNetworkConfig, OtgNetworkDriverMode};
use crate::error::{AppError, Result};
#[derive(Debug, Clone)]
pub struct NetworkFunction {
name: String,
mode: OtgNetworkDriverMode,
host_mac: String,
device_mac: String,
}
impl NetworkFunction {
pub fn new(instance: u8, config: &OtgNetworkConfig) -> Result<Self> {
config.validate()?;
let (device_mac, host_mac) = resolved_mac_pair(config);
Ok(Self {
name: format!("{}.usb{}", config.driver_mode.function_name(), instance),
mode: config.driver_mode,
host_mac,
device_mac,
})
}
fn function_path(&self, gadget_path: &Path) -> PathBuf {
gadget_path.join("functions").join(&self.name)
}
pub fn interface_name(&self, gadget_path: &Path) -> Result<String> {
let path = self.function_path(gadget_path).join("ifname");
let value = fs::read_to_string(&path).map_err(|e| {
AppError::Internal(format!(
"Failed to read OTG network interface from {}: {}",
path.display(),
e
))
})?;
let value = value.trim();
if value.is_empty() || value.contains('%') {
return Err(AppError::Internal(format!(
"Kernel did not allocate an OTG network interface for {}",
self.name
)));
}
Ok(value.to_string())
}
pub fn mode(&self) -> OtgNetworkDriverMode {
self.mode
}
}
impl GadgetFunction for NetworkFunction {
fn name(&self) -> &str {
&self.name
}
fn create(&self, gadget_path: &Path) -> Result<()> {
let function_path = self.function_path(gadget_path);
create_dir(&function_path)?;
write_file(&function_path.join("dev_addr"), &self.device_mac)?;
write_file(&function_path.join("host_addr"), &self.host_mac)?;
// New kernels accept an unbound interface-name pattern; old kernels expose it read-only.
let _ = write_file(
&function_path.join("ifname"),
&format!("okvm-{}%d", self.mode.function_name()),
);
if self.mode == OtgNetworkDriverMode::Rndis {
write_file(&gadget_path.join("bDeviceClass"), "0xEF")?;
write_file(&gadget_path.join("bDeviceSubClass"), "0x02")?;
write_file(&gadget_path.join("bDeviceProtocol"), "0x01")?;
write_file(&gadget_path.join("os_desc/use"), "1")?;
write_file(&gadget_path.join("os_desc/b_vendor_code"), "0xcd")?;
write_bytes(&gadget_path.join("os_desc/qw_sign"), b"MSFT100")?;
write_file(
&function_path.join("os_desc/interface.rndis/compatible_id"),
"RNDIS",
)?;
write_file(
&function_path.join("os_desc/interface.rndis/sub_compatible_id"),
"5162001",
)?;
}
debug!(
"Created {} OTG network function {} (device {}, host {})",
self.mode.function_name(),
self.name,
self.device_mac,
self.host_mac
);
Ok(())
}
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()> {
let function_path = self.function_path(gadget_path);
let config_link = config_path.join(&self.name);
if !config_link.exists() {
create_symlink(&function_path, &config_link)?;
}
if self.mode == OtgNetworkDriverMode::Rndis {
let os_desc_link = gadget_path.join("os_desc/c.1");
if !os_desc_link.exists() {
create_symlink(config_path, &os_desc_link)?;
}
}
Ok(())
}
fn unlink(&self, config_path: &Path) -> Result<()> {
let mut errors = Vec::new();
if self.mode == OtgNetworkDriverMode::Rndis {
if let Some(gadget_path) = config_path.parent().and_then(Path::parent) {
if let Err(error) = remove_file(&gadget_path.join("os_desc/c.1")) {
errors.push(error.to_string());
}
}
}
if let Err(error) = remove_file(&config_path.join(&self.name)) {
errors.push(error.to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(AppError::Config(format!(
"Failed to unlink OTG network function {}: {}",
self.name,
errors.join("; ")
)))
}
}
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
remove_dir(&self.function_path(gadget_path))
}
}
pub(crate) fn resolved_mac_pair(config: &OtgNetworkConfig) -> (String, String) {
if !config.device_mac.is_empty() && !config.host_mac.is_empty() {
return (config.device_mac.clone(), config.host_mac.clone());
}
let identity = fs::read_to_string("/etc/machine-id")
.or_else(|_| fs::read_to_string("/etc/hostname"))
.unwrap_or_else(|_| "one-kvm".to_string());
let mut hasher = DefaultHasher::new();
identity.trim().hash(&mut hasher);
let value = hasher.finish().to_be_bytes();
let device = format!(
"02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
value[1], value[2], value[3], value[4], value[5]
);
let host = format!(
"02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
value[1],
value[2],
value[3],
value[4],
value[5] ^ 0x01
);
(
if config.device_mac.is_empty() {
device
} else {
config.device_mac.clone()
},
if config.host_mac.is_empty() {
host
} else {
config.host_mac.clone()
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_function_uses_selected_driver_name() {
let function = NetworkFunction::new(0, &OtgNetworkConfig::default()).unwrap();
assert_eq!(function.name(), "ncm.usb0");
}
}

View File

@@ -150,6 +150,7 @@ 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 = matches!(config.hid.backend, crate::config::HidBackend::Otg);
let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled;
let mut checks = Vec::new(); let mut checks = Vec::new();
let build_response = |checks: Vec<OtgSelfCheckItem>, let build_response = |checks: Vec<OtgSelfCheckItem>,
@@ -408,13 +409,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
&mut checks, &mut checks,
"one_kvm_gadget_exists", "one_kvm_gadget_exists",
false, false,
if hid_backend_is_otg { if gadget_expected {
OtgSelfCheckLevel::Error OtgSelfCheckLevel::Error
} else { } else {
OtgSelfCheckLevel::Warn OtgSelfCheckLevel::Warn
}, },
"Check one-kvm gadget presence", "Check one-kvm gadget presence",
Some("Enable OTG HID or MSD to let one-kvm gadget be created automatically"), Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"),
Some(one_kvm_path.display().to_string()), Some(one_kvm_path.display().to_string()),
); );
} }
@@ -484,6 +485,15 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
.filter(|name| name.starts_with("hid.usb")) .filter(|name| name.starts_with("hid.usb"))
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let network_functions = function_names
.iter()
.filter(|name| {
name.starts_with("ncm.usb")
|| name.starts_with("ecm.usb")
|| name.starts_with("rndis.usb")
})
.cloned()
.collect::<Vec<_>>();
if hid_functions.is_empty() { if hid_functions.is_empty() {
push_otg_check( push_otg_check(
&mut checks, &mut checks,
@@ -510,6 +520,103 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
); );
} }
if config.otg_network.enabled {
let network_function_ok = network_functions.len() == 1;
push_otg_check(
&mut checks,
"network_function_present",
network_function_ok,
if network_function_ok {
OtgSelfCheckLevel::Info
} else {
OtgSelfCheckLevel::Error
},
"Check USB Ethernet function creation",
Some("The configured NCM/ECM/RNDIS function must exist exactly once"),
Some(functions_path.display().to_string()),
);
if let Some(function_name) = network_functions.first() {
let ifname_path = functions_path.join(function_name).join("ifname");
let ifname = read_trimmed(&ifname_path).unwrap_or_default();
let netdev_ok = !ifname.is_empty()
&& !ifname.contains('%')
&& std::path::Path::new("/sys/class/net")
.join(&ifname)
.exists();
push_otg_check(
&mut checks,
"network_netdev_present",
netdev_ok,
if netdev_ok {
OtgSelfCheckLevel::Info
} else {
OtgSelfCheckLevel::Error
},
"Check USB Ethernet network device",
Some("Read the function ifname and verify the matching /sys/class/net entry"),
Some(ifname_path.display().to_string()),
);
if netdev_ok {
let master_path = std::path::Path::new("/sys/class/net")
.join(&ifname)
.join("master");
let bridge_ok = std::fs::canonicalize(&master_path)
.ok()
.and_then(|path| {
path.file_name()
.map(|name| name.to_string_lossy().to_string())
})
.is_some_and(|name| {
name == "okvm-br0" || name == config.otg_network.bridge_interface
});
push_otg_check(
&mut checks,
"network_bridge_port",
bridge_ok,
if bridge_ok {
OtgSelfCheckLevel::Info
} else {
OtgSelfCheckLevel::Error
},
"Check USB Ethernet bridge membership",
Some("The USB network interface must be a port of the selected bridge"),
Some(master_path.display().to_string()),
);
}
if function_name.starts_with("rndis.") {
let os_desc = one_kvm_path.join("os_desc/c.1");
let os_desc_ok = os_desc.exists()
&& read_trimmed(&one_kvm_path.join("os_desc/use")).as_deref() == Some("1")
&& read_trimmed(&one_kvm_path.join("os_desc/qw_sign")).as_deref()
== Some("MSFT100")
&& read_trimmed(
&functions_path
.join(function_name)
.join("os_desc/interface.rndis/compatible_id"),
)
.is_some_and(|value| value.starts_with("RNDIS"));
push_otg_check(
&mut checks,
"rndis_os_descriptor",
os_desc_ok,
if os_desc_ok {
OtgSelfCheckLevel::Info
} else {
OtgSelfCheckLevel::Error
},
"Check RNDIS Microsoft OS descriptor",
Some(
"RNDIS requires the OS descriptor configuration link and compatible ID",
),
Some(os_desc.display().to_string()),
);
}
}
}
let config_path = one_kvm_path.join("configs/c.1"); let config_path = one_kvm_path.join("configs/c.1");
if !config_path.exists() { if !config_path.exists() {
push_otg_check( push_otg_check(

View File

@@ -1,10 +1,15 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use typeshare::typeshare;
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; use super::msd::MsdFunction;
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions}; use crate::config::{
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -17,6 +22,23 @@ pub struct HidDevicePaths {
pub keyboard_leds_enabled: bool, pub keyboard_leds_enabled: bool,
} }
#[typeshare]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OtgRuntimeHealth {
#[default]
Healthy,
Applying,
Degraded,
}
#[typeshare]
#[derive(Debug, Clone, serde::Serialize)]
pub struct OtgNetworkStatus {
pub health: OtgRuntimeHealth,
pub error: Option<String>,
}
impl HidDevicePaths { impl HidDevicePaths {
pub fn existing_paths(&self) -> Vec<PathBuf> { pub fn existing_paths(&self) -> Vec<PathBuf> {
[ [
@@ -39,7 +61,7 @@ pub(crate) struct OtgDesiredState {
pub keyboard_leds: bool, pub keyboard_leds: bool,
pub msd_enabled: bool, pub msd_enabled: bool,
pub msd_lun_capacity: u8, pub msd_lun_capacity: u8,
pub max_endpoints: u8, pub network: OtgNetworkConfig,
} }
impl Default for OtgDesiredState { impl Default for OtgDesiredState {
@@ -51,13 +73,18 @@ impl Default for OtgDesiredState {
keyboard_leds: false, keyboard_leds: false,
msd_enabled: false, msd_enabled: false,
msd_lun_capacity: 1, msd_lun_capacity: 1,
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS, network: OtgNetworkConfig::default(),
} }
} }
} }
impl OtgDesiredState { impl OtgDesiredState {
pub(crate) fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result<Self> { pub(crate) fn from_config(
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
) -> Result<Self> {
network.validate()?;
let hid_functions = if hid.backend == HidBackend::Otg { let hid_functions = if hid.backend == HidBackend::Otg {
let functions = hid.constrained_otg_functions(); let functions = hid.constrained_otg_functions();
Some(functions) Some(functions)
@@ -65,18 +92,25 @@ impl OtgDesiredState {
None None
}; };
hid.validate_otg_endpoint_budget(msd.enabled)?; hid.validate_otg_functions()?;
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled;
let udc = if needs_udc {
hid.otg_udc
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(OtgGadgetManager::find_udc)
} else {
None
};
Ok(Self { Ok(Self {
udc: hid.resolved_otg_udc(), udc,
descriptor: GadgetDescriptor::from(&hid.otg_descriptor), descriptor: GadgetDescriptor::from(&hid.otg_descriptor),
hid_functions, hid_functions,
keyboard_leds: hid.effective_otg_keyboard_leds(), keyboard_leds: hid.effective_otg_keyboard_leds(),
msd_enabled: msd.enabled, msd_enabled: msd.enabled,
msd_lun_capacity: 1, msd_lun_capacity: 1,
max_endpoints: hid network: network.clone(),
.resolved_otg_endpoint_limit()
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
}) })
} }
@@ -84,19 +118,25 @@ impl OtgDesiredState {
pub fn hid_enabled(&self) -> bool { pub fn hid_enabled(&self) -> bool {
self.hid_functions.is_some() self.hid_functions.is_some()
} }
#[inline]
pub fn network_enabled(&self) -> bool {
self.network.enabled
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct OtgServiceState { struct OtgServiceState {
pub health: OtgRuntimeHealth,
pub gadget_active: bool, pub gadget_active: bool,
pub hid_enabled: bool, pub hid_enabled: bool,
pub msd_enabled: bool, pub msd_enabled: bool,
pub msd_lun_capacity: u8, pub msd_lun_capacity: u8,
pub network: OtgNetworkConfig,
pub configured_udc: Option<String>, pub configured_udc: Option<String>,
pub hid_paths: Option<HidDevicePaths>, pub hid_paths: Option<HidDevicePaths>,
pub hid_functions: Option<OtgHidFunctions>, pub hid_functions: Option<OtgHidFunctions>,
pub keyboard_leds_enabled: bool, pub keyboard_leds_enabled: bool,
pub max_endpoints: u8,
pub descriptor: Option<GadgetDescriptor>, pub descriptor: Option<GadgetDescriptor>,
pub error: Option<String>, pub error: Option<String>,
} }
@@ -104,15 +144,16 @@ struct OtgServiceState {
impl Default for OtgServiceState { impl Default for OtgServiceState {
fn default() -> Self { fn default() -> Self {
Self { Self {
health: OtgRuntimeHealth::Healthy,
gadget_active: false, gadget_active: false,
hid_enabled: false, hid_enabled: false,
msd_enabled: false, msd_enabled: false,
msd_lun_capacity: 1, msd_lun_capacity: 1,
network: OtgNetworkConfig::default(),
configured_udc: None, configured_udc: None,
hid_paths: None, hid_paths: None,
hid_functions: None, hid_functions: None,
keyboard_leds_enabled: false, keyboard_leds_enabled: false,
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
descriptor: None, descriptor: None,
error: None, error: None,
} }
@@ -123,7 +164,9 @@ pub struct OtgService {
manager: Mutex<Option<OtgGadgetManager>>, manager: Mutex<Option<OtgGadgetManager>>,
state: RwLock<OtgServiceState>, state: RwLock<OtgServiceState>,
msd_function: RwLock<Option<MsdFunction>>, msd_function: RwLock<Option<MsdFunction>>,
network_bridge: Mutex<Option<NetworkBridgeRuntime>>,
desired: RwLock<OtgDesiredState>, desired: RwLock<OtgDesiredState>,
recovery_checked: AtomicBool,
} }
impl OtgService { impl OtgService {
@@ -132,7 +175,9 @@ impl OtgService {
manager: Mutex::new(None), manager: Mutex::new(None),
state: RwLock::new(OtgServiceState::default()), state: RwLock::new(OtgServiceState::default()),
msd_function: RwLock::new(None), msd_function: RwLock::new(None),
network_bridge: Mutex::new(None),
desired: RwLock::new(OtgDesiredState::default()), desired: RwLock::new(OtgDesiredState::default()),
recovery_checked: AtomicBool::new(false),
} }
} }
@@ -157,19 +202,75 @@ impl OtgService {
self.desired.read().await.msd_lun_capacity self.desired.read().await.msd_lun_capacity
} }
pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> { pub async fn network_status(&self) -> OtgNetworkStatus {
let state = self.state.read().await;
OtgNetworkStatus {
health: state.health,
error: state.error.clone(),
}
}
pub async fn apply_config(
&self,
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
) -> Result<()> {
if !self.recovery_checked.load(Ordering::SeqCst) {
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
let message = format!("Failed to recover stale OTG network transaction: {error}");
self.mark_degraded(message.clone()).await;
return Err(AppError::Config(message));
}
self.recovery_checked.store(true, Ordering::SeqCst);
}
let previous = self.desired.read().await.clone();
let desired = self let desired = self
.desired_from_config_preserving_runtime(hid, msd) .desired_from_config_preserving_runtime(hid, msd, network)
.await?; .await?;
self.apply_desired_state(desired).await {
let mut state = self.state.write().await;
state.health = OtgRuntimeHealth::Applying;
state.error = None;
}
if let Err(error) = self.apply_desired_state(desired).await {
warn!("OTG apply failed, restoring previous desired state: {error}");
self.mark_degraded(error.to_string()).await;
return match self.apply_desired_state(previous).await {
Ok(()) => {
self.mark_healthy().await;
Err(error)
}
Err(rollback_error) => {
let message = format!("{error}; OTG runtime rollback failed: {rollback_error}");
self.mark_degraded(message.clone()).await;
Err(AppError::Config(message))
}
};
}
self.mark_healthy().await;
Ok(())
}
pub async fn mark_degraded(&self, error: String) {
let mut state = self.state.write().await;
state.health = OtgRuntimeHealth::Degraded;
state.error = Some(error);
}
async fn mark_healthy(&self) {
let mut state = self.state.write().await;
state.health = OtgRuntimeHealth::Healthy;
state.error = None;
} }
async fn desired_from_config_preserving_runtime( async fn desired_from_config_preserving_runtime(
&self, &self,
hid: &HidConfig, hid: &HidConfig,
msd: &MsdConfig, msd: &MsdConfig,
network: &OtgNetworkConfig,
) -> Result<OtgDesiredState> { ) -> Result<OtgDesiredState> {
let mut desired = OtgDesiredState::from_config(hid, msd)?; let mut desired = OtgDesiredState::from_config(hid, msd, network)?;
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity; desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
Ok(desired) Ok(desired)
} }
@@ -204,22 +305,24 @@ impl OtgService {
let desired = self.desired.read().await.clone(); let desired = self.desired.read().await.clone();
debug!( debug!(
"Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}", "Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}",
desired.hid_enabled(), desired.hid_enabled(),
desired.msd_enabled, desired.msd_enabled,
desired.network_enabled(),
desired.udc desired.udc
); );
{ {
let state = self.state.read().await; let state = self.state.read().await;
if state.gadget_active if state.health != OtgRuntimeHealth::Degraded
&& state.gadget_active
&& state.hid_enabled == desired.hid_enabled() && state.hid_enabled == desired.hid_enabled()
&& state.msd_enabled == desired.msd_enabled && state.msd_enabled == desired.msd_enabled
&& state.msd_lun_capacity == desired.msd_lun_capacity && state.msd_lun_capacity == desired.msd_lun_capacity
&& state.network == desired.network
&& state.configured_udc == desired.udc && state.configured_udc == desired.udc
&& state.hid_functions == desired.hid_functions && state.hid_functions == desired.hid_functions
&& state.keyboard_leds_enabled == desired.keyboard_leds && state.keyboard_leds_enabled == desired.keyboard_leds
&& state.max_endpoints == desired.max_endpoints
&& state.descriptor.as_ref() == Some(&desired.descriptor) && state.descriptor.as_ref() == Some(&desired.descriptor)
{ {
debug!("OTG gadget already matches desired state"); debug!("OTG gadget already matches desired state");
@@ -227,14 +330,24 @@ impl OtgService {
} }
} }
let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() };
if let Some(runtime) = network_runtime {
debug!("Restoring network before OTG gadget reconcile");
tokio::task::spawn_blocking(move || runtime.deactivate())
.await
.map_err(|e| AppError::Internal(format!("Bridge cleanup task failed: {e}")))??;
self.network_bridge.lock().await.take();
}
{ {
let mut manager = self.manager.lock().await; let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() { if let Some(m) = manager.as_mut() {
debug!("Cleaning up existing gadget before OTG reconcile"); debug!("Cleaning up existing gadget before OTG reconcile");
if let Err(e) = m.cleanup() { m.cleanup().map_err(|e| {
warn!("Error cleaning up existing gadget: {}", e); AppError::Internal(format!("Failed to clean up existing OTG gadget: {e}"))
} })?;
} }
manager.take();
} }
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
@@ -245,16 +358,16 @@ impl OtgService {
state.hid_enabled = false; state.hid_enabled = false;
state.msd_enabled = false; state.msd_enabled = false;
state.msd_lun_capacity = 1; state.msd_lun_capacity = 1;
state.network = OtgNetworkConfig::default();
state.configured_udc = None; state.configured_udc = None;
state.hid_paths = None; state.hid_paths = None;
state.hid_functions = None; state.hid_functions = None;
state.keyboard_leds_enabled = false; state.keyboard_leds_enabled = false;
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
state.descriptor = None; state.descriptor = None;
state.error = None; state.error = None;
} }
if !desired.hid_enabled() && !desired.msd_enabled { if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() {
info!("OTG desired state is empty, gadget removed"); info!("OTG desired state is empty, gadget removed");
return Ok(()); return Ok(());
} }
@@ -276,7 +389,6 @@ impl OtgService {
let mut manager = OtgGadgetManager::with_descriptor( let mut manager = OtgGadgetManager::with_descriptor(
super::configfs::DEFAULT_GADGET_NAME, super::configfs::DEFAULT_GADGET_NAME,
desired.max_endpoints,
desired.descriptor.clone(), desired.descriptor.clone(),
); );
@@ -352,19 +464,61 @@ impl OtgService {
None None
}; };
let network_func = if desired.network_enabled() {
Some(manager.add_network(&desired.network).map_err(|e| {
AppError::Internal(format!("Failed to add OTG network function: {e}"))
})?)
} else {
None
};
if let Err(e) = manager.setup() { if let Err(e) = manager.setup() {
let error = format!("Failed to setup gadget: {}", e); let error = format!("Failed to setup gadget: {}", e);
self.state.write().await.error = Some(error.clone()); self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(cleanup_manager_or_combine(&mut manager, error));
} }
if let Err(e) = manager.bind(&udc) { if let Err(e) = manager.bind(&udc) {
let error = format!("Failed to bind gadget to UDC {}: {}", udc, e); let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
self.state.write().await.error = Some(error.clone()); self.state.write().await.error = Some(error.clone());
let _ = manager.cleanup(); return Err(cleanup_manager_or_combine(&mut manager, error));
return Err(AppError::Internal(error));
} }
let network_usb_interface = match network_func.as_ref() {
Some(function) => match function.interface_name(manager.gadget_path()) {
Ok(interface) => Some(interface),
Err(error) => {
let message = format!("Failed to resolve OTG network interface: {error}");
self.state.write().await.error = Some(message.clone());
return Err(cleanup_manager_or_combine(&mut manager, message));
}
},
None => None,
};
let network_runtime = if let Some(ref usb_interface) = network_usb_interface {
let requested = desired.network.bridge_interface.clone();
let usb_interface = usb_interface.clone();
let activation = tokio::task::spawn_blocking(move || {
NetworkBridgeRuntime::activate(&requested, &usb_interface)
})
.await;
match activation {
Err(error) => {
let message = format!("Bridge activation task failed: {error}");
self.state.write().await.error = Some(message.clone());
return Err(cleanup_manager_or_combine(&mut manager, message));
}
Ok(Ok(runtime)) => Some(runtime),
Ok(Err(error)) => {
let message = format!("Failed to activate OTG network bridge: {error}");
self.state.write().await.error = Some(message.clone());
return Err(cleanup_manager_or_combine(&mut manager, message));
}
}
} else {
None
};
if let Some(ref paths) = hid_paths { if let Some(ref paths) = hid_paths {
let device_paths = paths.existing_paths(); let device_paths = paths.existing_paths();
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await { if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
@@ -374,6 +528,7 @@ impl OtgService {
*self.manager.lock().await = Some(manager); *self.manager.lock().await = Some(manager);
*self.msd_function.write().await = msd_func; *self.msd_function.write().await = msd_func;
*self.network_bridge.lock().await = network_runtime.clone();
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
@@ -381,11 +536,11 @@ impl OtgService {
state.hid_enabled = desired.hid_enabled(); state.hid_enabled = desired.hid_enabled();
state.msd_enabled = desired.msd_enabled; state.msd_enabled = desired.msd_enabled;
state.msd_lun_capacity = desired.msd_lun_capacity; state.msd_lun_capacity = desired.msd_lun_capacity;
state.network = desired.network.clone();
state.configured_udc = Some(udc); state.configured_udc = Some(udc);
state.hid_paths = hid_paths; state.hid_paths = hid_paths;
state.hid_functions = desired.hid_functions; state.hid_functions = desired.hid_functions;
state.keyboard_leds_enabled = desired.keyboard_leds; state.keyboard_leds_enabled = desired.keyboard_leds;
state.max_endpoints = desired.max_endpoints;
state.descriptor = Some(desired.descriptor); state.descriptor = Some(desired.descriptor);
state.error = None; state.error = None;
} }
@@ -402,12 +557,21 @@ impl OtgService {
*desired = OtgDesiredState::default(); *desired = OtgDesiredState::default();
} }
let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() };
if let Some(runtime) = network_runtime {
tokio::task::spawn_blocking(move || runtime.deactivate())
.await
.map_err(|e| AppError::Internal(format!("Bridge cleanup task failed: {e}")))??;
self.network_bridge.lock().await.take();
}
let mut manager = self.manager.lock().await; let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() { if let Some(m) = manager.as_mut() {
if let Err(e) = m.cleanup() { m.cleanup().map_err(|e| {
warn!("Error cleaning up gadget during shutdown: {}", e); AppError::Internal(format!("Failed to clean up gadget during shutdown: {e}"))
} })?;
} }
manager.take();
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
{ {
@@ -420,6 +584,15 @@ impl OtgService {
} }
} }
fn cleanup_manager_or_combine(manager: &mut OtgGadgetManager, primary: String) -> AppError {
match manager.cleanup() {
Ok(()) => AppError::Internal(primary),
Err(cleanup_error) => AppError::Config(format!(
"{primary}; gadget rollback failed: {cleanup_error}"
)),
}
}
impl Default for OtgService { impl Default for OtgService {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -465,7 +638,11 @@ mod tests {
service.desired.write().await.msd_lun_capacity = 8; service.desired.write().await.msd_lun_capacity = 8;
let desired = service let desired = service
.desired_from_config_preserving_runtime(&HidConfig::default(), &MsdConfig::default()) .desired_from_config_preserving_runtime(
&HidConfig::default(),
&MsdConfig::default(),
&OtgNetworkConfig::default(),
)
.await .await
.unwrap(); .unwrap();
@@ -479,4 +656,28 @@ mod tests {
multi.msd_lun_capacity = 8; multi.msd_lun_capacity = 8;
assert_ne!(single, multi); assert_ne!(single, multi);
} }
#[test]
fn onecloud_full_composite_is_not_rejected_before_configfs() {
let hid = HidConfig {
backend: HidBackend::Otg,
otg_udc: Some("c9040000.usb".to_string()),
..HidConfig::default()
};
let msd = MsdConfig {
enabled: true,
..MsdConfig::default()
};
let network = OtgNetworkConfig {
enabled: true,
..OtgNetworkConfig::default()
};
let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap();
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));
assert!(desired.msd_enabled);
assert!(desired.network_enabled());
}
} }

View File

@@ -47,21 +47,24 @@ fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> boo
|| old_config.otg_descriptor != new_config.otg_descriptor || old_config.otg_descriptor != new_config.otg_descriptor
|| old_config.constrained_otg_functions() != new_config.constrained_otg_functions() || old_config.constrained_otg_functions() != new_config.constrained_otg_functions()
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
|| old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit()
} }
async fn reconcile_otg_from_store(state: &Arc<AppState>) -> Result<()> { async fn reconcile_otg_config(
state: &Arc<AppState>,
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
) -> Result<()> {
#[cfg(not(unix))] #[cfg(not(unix))]
{ {
let _ = state; let _ = (state, hid, msd, network);
Ok(()) Ok(())
} }
#[cfg(unix)] #[cfg(unix)]
{ {
let config = state.config.get();
state state
.otg_service .otg_service
.apply_config(&config.hid, &config.msd) .apply_config(hid, msd, network)
.await .await
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e))) .map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
} }
@@ -167,11 +170,11 @@ pub async fn apply_hid_config(
state: &Arc<AppState>, state: &Arc<AppState>,
old_config: &HidConfig, old_config: &HidConfig,
new_config: &HidConfig, new_config: &HidConfig,
msd_config: &MsdConfig,
network_config: &OtgNetworkConfig,
options: ConfigApplyOptions, options: ConfigApplyOptions,
) -> Result<()> { ) -> Result<()> {
let current_config = state.config.get(); new_config.validate_otg_functions()?;
let current_msd_enabled = current_config.msd.enabled && new_config.backend == HidBackend::Otg;
new_config.validate_otg_endpoint_budget(current_msd_enabled)?;
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let old_hid_functions = old_config.constrained_otg_functions(); let old_hid_functions = old_config.constrained_otg_functions();
@@ -179,8 +182,6 @@ pub async fn apply_hid_config(
let hid_functions_changed = old_hid_functions != new_hid_functions; let hid_functions_changed = old_hid_functions != new_hid_functions;
let keyboard_leds_changed = let keyboard_leds_changed =
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
let endpoint_budget_changed =
old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit();
let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse; let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse;
if old_config.backend == new_config.backend if old_config.backend == new_config.backend
@@ -191,7 +192,6 @@ pub async fn apply_hid_config(
&& !descriptor_changed && !descriptor_changed
&& !hid_functions_changed && !hid_functions_changed
&& !keyboard_leds_changed && !keyboard_leds_changed
&& !endpoint_budget_changed
&& !options.force && !options.force
{ {
tracing::info!("HID config unchanged, skipping reload"); tracing::info!("HID config unchanged, skipping reload");
@@ -214,7 +214,7 @@ pub async fn apply_hid_config(
} }
if otg_config_changed { if otg_config_changed {
reconcile_otg_from_store(state).await?; reconcile_otg_config(state, new_config, msd_config, network_config).await?;
} }
if !transitioning_away_from_otg { if !transitioning_away_from_otg {
@@ -238,14 +238,12 @@ pub async fn apply_msd_config(
state: &Arc<AppState>, state: &Arc<AppState>,
old_config: &MsdConfig, old_config: &MsdConfig,
new_config: &MsdConfig, new_config: &MsdConfig,
hid_config: &HidConfig,
network_config: &OtgNetworkConfig,
options: ConfigApplyOptions, options: ConfigApplyOptions,
) -> Result<()> { ) -> Result<()> {
let current_config = state.config.get(); let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
let hid_backend_is_otg = current_config.hid.backend == HidBackend::Otg;
let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg; let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg;
current_config
.hid
.validate_otg_endpoint_budget(effective_new_msd_enabled)?;
tracing::info!("MSD config sent, checking if reload needed..."); tracing::info!("MSD config sent, checking if reload needed...");
tracing::debug!("Old MSD config: {:?}", old_config); tracing::debug!("Old MSD config: {:?}", old_config);
@@ -284,13 +282,13 @@ pub async fn apply_msd_config(
if new_msd_enabled { if new_msd_enabled {
tracing::info!("(Re)initializing MSD..."); tracing::info!("(Re)initializing MSD...");
reconcile_otg_from_store(state).await?; reconcile_otg_config(state, hid_config, new_config, network_config).await?;
let mut msd_guard = state.msd.write().await; let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() { if let Some(msd) = msd_guard.as_mut() {
if let Err(e) = msd.shutdown().await { msd.shutdown()
tracing::warn!("MSD shutdown failed: {}", e); .await
} .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?;
} }
*msd_guard = None; *msd_guard = None;
drop(msd_guard); drop(msd_guard);
@@ -311,18 +309,17 @@ pub async fn apply_msd_config(
let mut msd_guard = state.msd.write().await; let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() { if let Some(msd) = msd_guard.as_mut() {
if let Err(e) = msd.shutdown().await { msd.shutdown()
tracing::warn!("MSD shutdown failed: {}", e); .await
} .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?;
} }
*msd_guard = None; *msd_guard = None;
tracing::info!("MSD shutdown complete"); tracing::info!("MSD shutdown complete");
reconcile_otg_from_store(state).await?; reconcile_otg_config(state, hid_config, new_config, network_config).await?;
} }
let current_config = state.config.get(); if hid_config.backend == HidBackend::Otg
if current_config.hid.backend == HidBackend::Otg
&& (options.force || old_msd_enabled != new_msd_enabled) && (options.force || old_msd_enabled != new_msd_enabled)
{ {
state state
@@ -335,6 +332,55 @@ pub async fn apply_msd_config(
Ok(()) Ok(())
} }
#[cfg(unix)]
pub async fn apply_otg_config(
state: &Arc<AppState>,
old_config: &AppConfig,
new_config: &AppConfig,
) -> Result<()> {
let transitioning_away_from_otg =
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
if transitioning_away_from_otg {
apply_hid_config(
state,
&old_config.hid,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
ConfigApplyOptions::default(),
)
.await?;
} else {
reconcile_otg_config(
state,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
)
.await?;
apply_hid_config(
state,
&old_config.hid,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
ConfigApplyOptions::default(),
)
.await?;
}
apply_msd_config(
state,
&old_config.msd,
&new_config.msd,
&new_config.hid,
&new_config.otg_network,
ConfigApplyOptions::default(),
)
.await
}
pub async fn apply_atx_config( pub async fn apply_atx_config(
state: &Arc<AppState>, state: &Arc<AppState>,
_old_config: &AtxConfig, _old_config: &AtxConfig,

View File

@@ -1,12 +1,12 @@
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use std::sync::Arc; use std::sync::Arc;
use crate::config::{HidBackend, HidConfig}; use crate::config::HidConfig;
use crate::error::Result; use crate::error::Result;
use crate::state::AppState; use crate::state::AppState;
use super::apply::{apply_hid_config, try_apply_lock, ConfigApplyOptions}; use super::otg::update_otg_config_inner;
use super::types::HidConfigUpdate; use super::types::{HidConfigUpdate, OtgConfigUpdate};
pub async fn get_hid_config(State(state): State<Arc<AppState>>) -> Json<HidConfig> { pub async fn get_hid_config(State(state): State<Arc<AppState>>) -> Json<HidConfig> {
Json(state.config.get().hid.clone()) Json(state.config.get().hid.clone())
@@ -16,54 +16,13 @@ pub async fn update_hid_config(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<HidConfigUpdate>, Json(req): Json<HidConfigUpdate>,
) -> Result<Json<HidConfig>> { ) -> Result<Json<HidConfig>> {
req.validate()?; let response = update_otg_config_inner(
let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
let old_hid_config = state.config.get().hid.clone();
let mut staged_hid_config = old_hid_config.clone();
req.apply_to(&mut staged_hid_config);
let descriptor_update = req
.ch9329_descriptor
.as_ref()
.map(|_| staged_hid_config.ch9329_descriptor.clone());
if descriptor_update.is_some() {
staged_hid_config.ch9329_descriptor = old_hid_config.ch9329_descriptor.clone();
}
state
.config
.update(|config| {
config.hid = staged_hid_config.clone();
config.enforce_invariants();
})
.await?;
let new_hid_config = state.config.get().hid.clone();
apply_hid_config(
&state, &state,
&old_hid_config, OtgConfigUpdate {
&new_hid_config, hid: Some(req),
ConfigApplyOptions::forced(), ..Default::default()
},
) )
.await?; .await?;
Ok(Json(response.hid))
if let Some(descriptor) = descriptor_update {
if new_hid_config.backend != HidBackend::Ch9329 {
return Ok(Json(new_hid_config));
}
let actual = state.hid.apply_ch9329_descriptor(&descriptor).await?;
state
.config
.update(|config| {
config.hid.ch9329_descriptor = actual.descriptor.clone();
config.enforce_invariants();
})
.await?;
return Ok(Json(state.config.get().hid.clone()));
}
Ok(Json(new_hid_config))
} }

View File

@@ -7,6 +7,10 @@ mod auth;
mod hid; mod hid;
#[cfg(unix)] #[cfg(unix)]
mod msd; mod msd;
#[cfg(unix)]
mod otg;
#[cfg(unix)]
mod otg_network;
mod redfish; mod redfish;
mod rtsp; mod rtsp;
mod rustdesk; mod rustdesk;
@@ -21,6 +25,10 @@ pub use auth::{get_auth_config, update_auth_config};
pub use hid::{get_hid_config, update_hid_config}; pub use hid::{get_hid_config, update_hid_config};
#[cfg(unix)] #[cfg(unix)]
pub use msd::{get_msd_config, update_msd_config}; pub use msd::{get_msd_config, update_msd_config};
#[cfg(unix)]
pub use otg::update_otg_config;
#[cfg(unix)]
pub use otg_network::{get_otg_network_config, get_otg_network_status, update_otg_network_config};
pub use redfish::{get_redfish_config, update_redfish_config}; pub use redfish::{get_redfish_config, update_redfish_config};
pub use rtsp::{ pub use rtsp::{
get_rtsp_config, get_rtsp_status, start_rtsp_service, stop_rtsp_service, update_rtsp_config, get_rtsp_config, get_rtsp_status, start_rtsp_service, stop_rtsp_service, update_rtsp_config,

View File

@@ -5,8 +5,8 @@ use crate::config::MsdConfig;
use crate::error::Result; use crate::error::Result;
use crate::state::AppState; use crate::state::AppState;
use super::apply::{apply_msd_config, try_apply_lock, ConfigApplyOptions}; use super::otg::update_otg_config_inner;
use super::types::MsdConfigUpdate; use super::types::{MsdConfigUpdate, OtgConfigUpdate};
pub async fn get_msd_config(State(state): State<Arc<AppState>>) -> Json<MsdConfig> { pub async fn get_msd_config(State(state): State<Arc<AppState>>) -> Json<MsdConfig> {
Json(state.config.get().msd.clone()) Json(state.config.get().msd.clone())
@@ -16,28 +16,13 @@ pub async fn update_msd_config(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<MsdConfigUpdate>, Json(req): Json<MsdConfigUpdate>,
) -> Result<Json<MsdConfig>> { ) -> Result<Json<MsdConfig>> {
req.validate()?; let response = update_otg_config_inner(
let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
let old_msd_config = state.config.get().msd.clone();
state
.config
.update(|config| {
req.apply_to(&mut config.msd);
config.enforce_invariants();
})
.await?;
let new_msd_config = state.config.get().msd.clone();
apply_msd_config(
&state, &state,
&old_msd_config, OtgConfigUpdate {
&new_msd_config, msd: Some(req),
ConfigApplyOptions::forced(), ..Default::default()
},
) )
.await?; .await?;
Ok(Json(response.msd))
Ok(Json(new_msd_config))
} }

View File

@@ -0,0 +1,166 @@
use std::sync::Arc;
use axum::{extract::State, Json};
use serde::Serialize;
use typeshare::typeshare;
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgNetworkConfig};
use crate::error::{AppError, Result};
use crate::otg::OtgNetworkStatus;
use crate::state::AppState;
use super::apply::{apply_otg_config, try_apply_lock};
use super::types::OtgConfigUpdate;
#[typeshare]
#[derive(Debug, Serialize)]
pub struct OtgConfigResponse {
pub hid: HidConfig,
pub msd: MsdConfig,
pub network: OtgNetworkConfig,
pub status: OtgNetworkStatus,
}
pub async fn update_otg_config(
State(state): State<Arc<AppState>>,
Json(request): Json<OtgConfigUpdate>,
) -> Result<Json<OtgConfigResponse>> {
update_otg_config_inner(&state, request).await.map(Json)
}
pub(super) async fn update_otg_config_inner(
state: &Arc<AppState>,
request: OtgConfigUpdate,
) -> Result<OtgConfigResponse> {
let _guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
if let Some(ref update) = request.hid {
update.validate()?;
}
if let Some(ref update) = request.msd {
update.validate()?;
}
let old_config = state.config.get();
let mut staged_config = (*old_config).clone();
let requested_ch9329_descriptor = request.hid.as_ref().and_then(|update| {
update.ch9329_descriptor.as_ref().map(|_| {
let mut hid = staged_config.hid.clone();
update.apply_to(&mut hid);
hid.ch9329_descriptor
})
});
if let Some(ref update) = request.hid {
update.apply_to(&mut staged_config.hid);
}
if requested_ch9329_descriptor.is_some() {
staged_config.hid.ch9329_descriptor = old_config.hid.ch9329_descriptor.clone();
}
if let Some(ref update) = request.msd {
update.apply_to(&mut staged_config.msd);
}
if let Some(ref update) = request.network {
update.apply_to(&mut staged_config.otg_network);
}
staged_config.enforce_invariants();
if staged_config.otg_network.enabled
&& (staged_config.otg_network.device_mac.is_empty()
|| staged_config.otg_network.host_mac.is_empty())
{
let (device_mac, host_mac) =
crate::otg::network::resolved_mac_pair(&staged_config.otg_network);
staged_config.otg_network.device_mac = device_mac;
staged_config.otg_network.host_mac = host_mac;
}
staged_config.hid.validate_otg_functions()?;
staged_config.otg_network.validate()?;
if let Err(error) = apply_otg_config(state, &old_config, &staged_config).await {
return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await);
}
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
if staged_config.hid.backend == HidBackend::Ch9329 {
match state.hid.apply_ch9329_descriptor(descriptor).await {
Ok(actual) => {
staged_config.hid.ch9329_descriptor = actual.descriptor;
true
}
Err(error) => {
return Err(rollback_after_failure(
state,
&staged_config,
&old_config,
error,
true,
)
.await);
}
}
} else {
false
}
} else {
false
};
if let Err(error) = state
.config
.update(|config| {
config.hid = staged_config.hid.clone();
config.msd = staged_config.msd.clone();
config.otg_network = staged_config.otg_network.clone();
config.enforce_invariants();
})
.await
{
return Err(rollback_after_failure(
state,
&staged_config,
&old_config,
AppError::Config(format!("Failed to persist OTG config after apply: {error}")),
descriptor_was_applied,
)
.await);
}
Ok(OtgConfigResponse {
hid: staged_config.hid,
msd: staged_config.msd,
network: staged_config.otg_network,
status: state.otg_service.network_status().await,
})
}
async fn rollback_after_failure(
state: &Arc<AppState>,
failed_config: &crate::config::AppConfig,
old_config: &crate::config::AppConfig,
primary_error: AppError,
restore_descriptor: bool,
) -> AppError {
let mut rollback_errors = Vec::new();
if let Err(error) = apply_otg_config(state, failed_config, old_config).await {
rollback_errors.push(format!("runtime rollback failed: {error}"));
}
if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 {
if let Err(error) = state
.hid
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
.await
{
rollback_errors.push(format!("CH9329 descriptor rollback failed: {error}"));
}
}
if rollback_errors.is_empty() {
return primary_error;
}
let message = format!("{primary_error}; {}", rollback_errors.join("; "));
state.otg_service.mark_degraded(message.clone()).await;
AppError::Config(message)
}

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use axum::{extract::State, Json};
use crate::config::OtgNetworkConfig;
use crate::error::Result;
use crate::otg::OtgNetworkStatus;
use crate::state::AppState;
use super::otg::update_otg_config_inner;
use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate};
pub async fn get_otg_network_config(State(state): State<Arc<AppState>>) -> Json<OtgNetworkConfig> {
Json(state.config.get().otg_network.clone())
}
pub async fn update_otg_network_config(
State(state): State<Arc<AppState>>,
Json(request): Json<OtgNetworkConfigUpdate>,
) -> Result<Json<OtgNetworkConfig>> {
let response = update_otg_config_inner(
&state,
OtgConfigUpdate {
network: Some(request),
..Default::default()
},
)
.await?;
Ok(Json(response.network))
}
pub async fn get_otg_network_status(State(state): State<Arc<AppState>>) -> Json<OtgNetworkStatus> {
Json(state.otg_service.network_status().await)
}

View File

@@ -353,12 +353,20 @@ pub struct HidConfigUpdate {
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>, pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
pub otg_profile: Option<OtgHidProfile>, pub otg_profile: Option<OtgHidProfile>,
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
pub otg_functions: Option<OtgHidFunctionsUpdate>, pub otg_functions: Option<OtgHidFunctionsUpdate>,
pub otg_keyboard_leds: Option<bool>, pub otg_keyboard_leds: Option<bool>,
pub mouse_absolute: Option<bool>, pub mouse_absolute: Option<bool>,
} }
#[typeshare]
#[cfg(unix)]
#[derive(Debug, Deserialize, Default)]
pub struct OtgConfigUpdate {
pub hid: Option<HidConfigUpdate>,
pub msd: Option<MsdConfigUpdate>,
pub network: Option<OtgNetworkConfigUpdate>,
}
impl HidConfigUpdate { impl HidConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> { pub fn validate(&self) -> crate::error::Result<()> {
if let Some(baudrate) = self.ch9329_baudrate { if let Some(baudrate) = self.ch9329_baudrate {
@@ -403,9 +411,6 @@ impl HidConfigUpdate {
if let Some(profile) = self.otg_profile.clone() { if let Some(profile) = self.otg_profile.clone() {
config.otg_profile = profile; config.otg_profile = profile;
} }
if let Some(budget) = self.otg_endpoint_budget {
config.otg_endpoint_budget = budget;
}
if let Some(ref functions) = self.otg_functions { if let Some(ref functions) = self.otg_functions {
functions.apply_to(&mut config.otg_functions); functions.apply_to(&mut config.otg_functions);
} }
@@ -418,6 +423,38 @@ impl HidConfigUpdate {
} }
} }
#[typeshare]
#[cfg(unix)]
#[derive(Debug, Deserialize)]
pub struct OtgNetworkConfigUpdate {
pub enabled: Option<bool>,
pub driver_mode: Option<OtgNetworkDriverMode>,
pub bridge_interface: Option<String>,
pub host_mac: Option<String>,
pub device_mac: Option<String>,
}
#[cfg(unix)]
impl OtgNetworkConfigUpdate {
pub fn apply_to(&self, config: &mut OtgNetworkConfig) {
if let Some(enabled) = self.enabled {
config.enabled = enabled;
}
if let Some(driver_mode) = self.driver_mode {
config.driver_mode = driver_mode;
}
if let Some(ref interface) = self.bridge_interface {
config.bridge_interface = interface.trim().to_string();
}
if let Some(ref mac) = self.host_mac {
config.host_mac = mac.trim().to_ascii_lowercase();
}
if let Some(ref mac) = self.device_mac {
config.device_mac = mac.trim().to_ascii_lowercase();
}
}
}
#[typeshare] #[typeshare]
#[cfg(unix)] #[cfg(unix)]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View File

@@ -17,6 +17,12 @@ pub async fn list_usb_devices() -> Json<Vec<usb_reset::UsbDeviceInfo>> {
Json(usb_reset::list_usb_devices()) Json(usb_reset::list_usb_devices())
} }
#[cfg(unix)]
pub async fn list_network_interfaces() -> Result<Json<Vec<crate::otg::bridge::NetworkInterfaceInfo>>>
{
crate::otg::bridge::list_network_interfaces().map(Json)
}
#[cfg(unix)] #[cfg(unix)]
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UsbResetRequest { pub struct UsbResetRequest {

View File

@@ -35,7 +35,6 @@ pub struct SetupRequest {
pub hid_ch9329_baudrate: Option<u32>, pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: Option<String>, pub hid_otg_udc: Option<String>,
pub hid_otg_profile: Option<String>, pub hid_otg_profile: Option<String>,
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
pub hid_otg_keyboard_leds: Option<bool>, pub hid_otg_keyboard_leds: Option<bool>,
pub msd_enabled: Option<bool>, pub msd_enabled: Option<bool>,
// Extension settings // Extension settings
@@ -123,9 +122,6 @@ pub async fn setup_init(
config.hid.otg_profile = parsed; config.hid.otg_profile = parsed;
} }
} }
if let Some(budget) = req.hid_otg_endpoint_budget {
config.hid.otg_endpoint_budget = budget;
}
if let Some(enabled) = req.hid_otg_keyboard_leds { if let Some(enabled) = req.hid_otg_keyboard_leds {
config.hid.otg_keyboard_leds = enabled; config.hid.otg_keyboard_leds = enabled;
} }
@@ -151,7 +147,7 @@ pub async fn setup_init(
{ {
if let Err(e) = state if let Err(e) = state
.otg_service .otg_service
.apply_config(&new_config.hid, &new_config.msd) .apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network)
.await .await
{ {
tracing::warn!("Failed to apply OTG config during setup: {}", e); tracing::warn!("Failed to apply OTG config during setup: {}", e);

View File

@@ -246,6 +246,19 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
.route("/config/msd", get(handlers::config::get_msd_config)) .route("/config/msd", get(handlers::config::get_msd_config))
.route("/config/msd", patch(handlers::config::update_msd_config)) .route("/config/msd", patch(handlers::config::update_msd_config))
.route("/config/otg", patch(handlers::config::update_otg_config))
.route(
"/config/otg-network",
get(handlers::config::get_otg_network_config),
)
.route(
"/config/otg-network",
patch(handlers::config::update_otg_network_config),
)
.route(
"/otg/network/status",
get(handlers::config::get_otg_network_status),
)
.route("/msd/status", get(handlers::msd_status)) .route("/msd/status", get(handlers::msd_status))
.route("/msd/images", get(handlers::msd_images_list)) .route("/msd/images", get(handlers::msd_images_list))
.route("/msd/images/download", post(handlers::msd_image_download)) .route("/msd/images/download", post(handlers::msd_image_download))
@@ -277,6 +290,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
) )
.route("/msd/drive/mkdir/{*path}", post(handlers::msd_drive_mkdir)) .route("/msd/drive/mkdir/{*path}", post(handlers::msd_drive_mkdir))
.route("/devices/usb", get(handlers::devices::list_usb_devices)) .route("/devices/usb", get(handlers::devices::list_usb_devices))
.route(
"/devices/network",
get(handlers::devices::list_network_interfaces),
)
.route( .route(
"/devices/usb/reset", "/devices/usb/reset",
post(handlers::devices::reset_usb_device), post(handlers::devices::reset_usb_device),

View File

@@ -10,6 +10,12 @@ import type {
HidConfigUpdate, HidConfigUpdate,
MsdConfig, MsdConfig,
MsdConfigUpdate, MsdConfigUpdate,
NetworkInterfaceInfo,
OtgNetworkConfig,
OtgNetworkConfigUpdate,
OtgNetworkStatus,
OtgConfigUpdate,
OtgConfigResponse,
AtxConfig, AtxConfig,
AtxConfigUpdate, AtxConfigUpdate,
AtxDevices, AtxDevices,
@@ -86,6 +92,28 @@ export const msdConfigApi = {
}), }),
} }
export const otgNetworkApi = {
get: () => request<OtgNetworkConfig>('/config/otg-network'),
update: (config: OtgNetworkConfigUpdate) =>
request<OtgNetworkConfig>('/config/otg-network', {
method: 'PATCH',
body: JSON.stringify(config),
}),
status: () => request<OtgNetworkStatus>('/otg/network/status'),
interfaces: () => request<NetworkInterfaceInfo[]>('/devices/network'),
}
export const otgConfigApi = {
update: (config: OtgConfigUpdate) =>
request<OtgConfigResponse>('/config/otg', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
export interface WolHistoryEntry { export interface WolHistoryEntry {
mac_address: string mac_address: string
updated_at: number updated_at: number

View File

@@ -123,7 +123,6 @@ export const systemApi = {
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
hid_otg_udc?: string hid_otg_udc?: string
hid_otg_profile?: string hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean msd_enabled?: boolean
encoder_backend?: string encoder_backend?: string
@@ -804,6 +803,8 @@ export {
streamConfigApi, streamConfigApi,
hidConfigApi, hidConfigApi,
msdConfigApi, msdConfigApi,
otgConfigApi,
otgNetworkApi,
atxConfigApi, atxConfigApi,
audioConfigApi, audioConfigApi,
extensionsApi, extensionsApi,

View File

@@ -263,7 +263,6 @@ export default {
progress: 'Step {current} of {total}', progress: 'Step {current} of {total}',
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.', ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.', otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.', videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
stepExtensions: 'Extensions', stepExtensions: 'Extensions',
@@ -510,7 +509,7 @@ export default {
accountSubtitle: 'Manage credentials and session policy', accountSubtitle: 'Manage credentials and session policy',
networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate', networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate',
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers', videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
hidSubtitle: 'Configure keyboard and mouse backend with USB gadget descriptors', hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions',
msdSubtitle: 'Manage Mass Storage Device image directory', msdSubtitle: 'Manage Mass Storage Device image directory',
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN', atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
environmentSubtitle: 'System runtime environment and USB device maintenance', environmentSubtitle: 'System runtime environment and USB device maintenance',
@@ -712,10 +711,12 @@ export default {
supportedFormats: 'Supported Codecs', supportedFormats: 'Supported Codecs',
encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.', encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.',
hidSettings: 'HID Settings', hidSettings: 'HID Settings',
hidSettingsDesc: 'Configure keyboard and mouse control', hidSettingsDesc: 'Configure backend, device, descriptors, and functions in order',
hidBackend: 'HID Backend', hidBackend: 'HID Backend',
serialDevice: 'Serial Device', serialDevice: 'Serial Device',
baudRate: 'Baud Rate', baudRate: 'Baud Rate',
otgUdc: 'OTG Device Controller',
otgUdcDesc: 'Select the UDC used by the USB gadget, or leave blank for automatic selection',
ch9329Options: 'CH9329 Options', ch9329Options: 'CH9329 Options',
ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip', ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip',
ch9329HybridMouse: 'Linux Absolute Mouse Compatibility', ch9329HybridMouse: 'Linux Absolute Mouse Compatibility',
@@ -728,14 +729,8 @@ export default {
ch9329DescriptorReadRequired: 'Read the CH9329 descriptor successfully before saving', ch9329DescriptorReadRequired: 'Read the CH9329 descriptor successfully before saving',
ch9329DescriptorWarning: 'Saving writes CH9329 parameters; changes may not show until the device is power-cycled or reconnected', ch9329DescriptorWarning: 'Saving writes CH9329 parameters; changes may not show until the device is power-cycled or reconnected',
ch9329StringLengthWarning: 'CH9329 strings are limited to 23 bytes', ch9329StringLengthWarning: 'CH9329 strings are limited to 23 bytes',
otgHidProfile: 'OTG HID Functions', otgHidProfile: 'OTG Functions',
otgHidProfileDesc: 'Select which HID functions are exposed to the host', otgHidProfileDesc: 'Select which USB functions are exposed to the host',
otgEndpointBudget: 'Max Endpoints',
otgEndpointBudgetUnlimited: 'Unlimited',
otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.',
otgEndpointUsage: 'Endpoint usage: {used} / {limit}',
otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited',
otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.',
otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device', otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgKeyboardLeds: 'Keyboard LED Status', otgKeyboardLeds: 'Keyboard LED Status',
@@ -748,9 +743,14 @@ export default {
otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause', otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host', otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device', otgProfileWarning: 'Saving reconnects the USB device; unsupported combinations show an error and restore the previous configuration',
otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.',
otgFunctionMinWarning: 'Enable at least one HID function before saving', otgFunctionMinWarning: 'Enable at least one HID function before saving',
otgNetwork: 'USB Virtual Ethernet',
otgRuntimeDegraded: 'OTG runtime is degraded',
otgNetworkDesc: 'Bridge the USB link to an existing uplink',
otgNetworkDriver: 'Host Driver Mode',
otgNetworkInterface: 'Bridge Interface',
otgNetworkNone: 'None',
otgDescriptor: 'USB Device Descriptor', otgDescriptor: 'USB Device Descriptor',
otgDescriptorDesc: 'Configure USB device identification', otgDescriptorDesc: 'Configure USB device identification',
vendorId: 'Vendor ID (VID)', vendorId: 'Vendor ID (VID)',

View File

@@ -263,7 +263,6 @@ export default {
progress: '步骤 {current} / {total}', progress: '步骤 {current} / {total}',
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。', ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。', otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。', videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。', videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
stepExtensions: '扩展设置', stepExtensions: '扩展设置',
@@ -509,7 +508,7 @@ export default {
accountSubtitle: '管理登录凭据与会话策略', accountSubtitle: '管理登录凭据与会话策略',
networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书', networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书',
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器', videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
hidSubtitle: '配置键盘鼠标后端与 USB Gadget 描述符', hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能',
msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录', msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录',
atxSubtitle: '配置远程电源控制硬件与网络唤醒', atxSubtitle: '配置远程电源控制硬件与网络唤醒',
environmentSubtitle: '系统级运行环境与 USB 设备维护', environmentSubtitle: '系统级运行环境与 USB 设备维护',
@@ -711,10 +710,12 @@ export default {
supportedFormats: '支持的编码格式', supportedFormats: '支持的编码格式',
encoderHint: '硬件编码器延迟和 CPU 占用比软件编码低,画质预设更好', encoderHint: '硬件编码器延迟和 CPU 占用比软件编码低,画质预设更好',
hidSettings: 'HID 设置', hidSettings: 'HID 设置',
hidSettingsDesc: '配置键盘和鼠标控制', hidSettingsDesc: '按后端、设备、描述符和功能顺序配置',
hidBackend: 'HID 后端', hidBackend: 'HID 后端',
serialDevice: '串口设备', serialDevice: '串口设备',
baudRate: '波特率', baudRate: '波特率',
otgUdc: 'OTG 设备控制器',
otgUdcDesc: '选择用于 USB Gadget 的 UDC留空时自动选择',
ch9329Options: 'CH9329 选项', ch9329Options: 'CH9329 选项',
ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性', ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性',
ch9329HybridMouse: 'Linux 绝对鼠标兼容模式', ch9329HybridMouse: 'Linux 绝对鼠标兼容模式',
@@ -727,14 +728,8 @@ export default {
ch9329DescriptorReadRequired: '需要先成功读取 CH9329 描述符才能保存', ch9329DescriptorReadRequired: '需要先成功读取 CH9329 描述符才能保存',
ch9329DescriptorWarning: '保存会写入 CH9329 参数;需要重新上电或重新插拔后才会变化', ch9329DescriptorWarning: '保存会写入 CH9329 参数;需要重新上电或重新插拔后才会变化',
ch9329StringLengthWarning: 'CH9329 字符串最长为 23 字节', ch9329StringLengthWarning: 'CH9329 字符串最长为 23 字节',
otgHidProfile: 'OTG HID 功能', otgHidProfile: 'OTG 功能',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', otgHidProfileDesc: '选择对目标主机暴露的 USB 功能',
otgEndpointBudget: '最大端点数量',
otgEndpointBudgetUnlimited: '无限制',
otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量OTG 功能将无法使用。',
otgEndpointUsage: '当前端点占用:{used} / {limit}',
otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限',
otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。',
otgFunctionKeyboard: '键盘', otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备', otgFunctionKeyboardDesc: '标准 HID 键盘设备',
otgKeyboardLeds: '键盘状态灯', otgKeyboardLeds: '键盘状态灯',
@@ -747,9 +742,14 @@ export default {
otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键', otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键',
otgFunctionMsd: '虚拟媒体MSD', otgFunctionMsd: '虚拟媒体MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储', otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接', otgProfileWarning: '保存后 USB 设备重新连接;若控制器不支持当前组合,将显示失败并恢复原配置',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存', otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
otgNetwork: 'USB 虚拟网卡',
otgRuntimeDegraded: 'OTG 运行时处于降级状态',
otgNetworkDesc: '桥接到现有上联网卡',
otgNetworkDriver: '目标机驱动模式',
otgNetworkInterface: '桥接网卡',
otgNetworkNone: '无',
otgDescriptor: 'USB 设备描述符', otgDescriptor: 'USB 设备描述符',
otgDescriptorDesc: '配置 USB 设备标识信息', otgDescriptorDesc: '配置 USB 设备标识信息',
vendorId: '厂商 ID (VID)', vendorId: '厂商 ID (VID)',

View File

@@ -85,7 +85,6 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
hid_otg_udc?: string hid_otg_udc?: string
hid_otg_profile?: string hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean msd_enabled?: boolean
encoder_backend?: string encoder_backend?: string

View File

@@ -6,6 +6,7 @@ import {
audioConfigApi, audioConfigApi,
hidConfigApi, hidConfigApi,
msdConfigApi, msdConfigApi,
otgConfigApi,
rtspConfigApi, rtspConfigApi,
rustdeskConfigApi, rustdeskConfigApi,
streamConfigApi, streamConfigApi,
@@ -24,6 +25,8 @@ import type {
HidConfigUpdate, HidConfigUpdate,
MsdConfig, MsdConfig,
MsdConfigUpdate, MsdConfigUpdate,
OtgConfigResponse,
OtgConfigUpdate,
StreamConfigResponse, StreamConfigResponse,
StreamConfigUpdate, StreamConfigUpdate,
VideoConfig, VideoConfig,
@@ -525,6 +528,13 @@ export const useConfigStore = defineStore('config', () => {
return response return response
} }
async function updateOtg(update: OtgConfigUpdate): Promise<OtgConfigResponse> {
const response = await otgConfigApi.update(update)
hid.value = response.hid
msd.value = response.msd
return response
}
async function updateStream(update: StreamConfigUpdate) { async function updateStream(update: StreamConfigUpdate) {
const response = await streamConfigApi.update(update) const response = await streamConfigApi.update(update)
stream.value = response stream.value = response
@@ -642,6 +652,7 @@ export const useConfigStore = defineStore('config', () => {
updateAudio, updateAudio,
updateHid, updateHid,
updateMsd, updateMsd,
updateOtg,
updateStream, updateStream,
updateWeb, updateWeb,
updateAtx, updateAtx,

View File

@@ -40,13 +40,6 @@ export enum OtgHidProfile {
Custom = "custom", Custom = "custom",
} }
export enum OtgEndpointBudget {
Auto = "auto",
Five = "five",
Six = "six",
Unlimited = "unlimited",
}
export interface OtgHidFunctions { export interface OtgHidFunctions {
keyboard: boolean; keyboard: boolean;
mouse_relative: boolean; mouse_relative: boolean;
@@ -67,7 +60,6 @@ export interface HidConfig {
otg_udc?: string; otg_udc?: string;
otg_descriptor?: OtgDescriptorConfig; otg_descriptor?: OtgDescriptorConfig;
otg_profile?: OtgHidProfile; otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctions; otg_functions?: OtgHidFunctions;
otg_keyboard_leds?: boolean; otg_keyboard_leds?: boolean;
ch9329_port: string; ch9329_port: string;
@@ -77,6 +69,22 @@ export interface HidConfig {
mouse_absolute: boolean; mouse_absolute: boolean;
} }
export enum OtgNetworkDriverMode {
Ncm = "ncm",
Ecm = "ecm",
Rndis = "rndis",
}
export interface OtgNetworkConfig {
enabled: boolean;
driver_mode: OtgNetworkDriverMode;
/** Empty means select the connected NetworkManager Ethernet interface. */
bridge_interface: string;
/** Empty values are resolved from the machine identity at runtime. */
host_mac: string;
device_mac: string;
}
export interface MsdConfig { export interface MsdConfig {
enabled: boolean; enabled: boolean;
msd_dir: string; msd_dir: string;
@@ -286,6 +294,7 @@ export interface AppConfig {
auth: AuthConfig; auth: AuthConfig;
video: VideoConfig; video: VideoConfig;
hid: HidConfig; hid: HidConfig;
otg_network: OtgNetworkConfig;
msd: MsdConfig; msd: MsdConfig;
atx: AtxConfig; atx: AtxConfig;
audio: AudioConfig; audio: AudioConfig;
@@ -537,7 +546,6 @@ export interface HidConfigUpdate {
otg_udc?: string; otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate; otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile; otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctionsUpdate; otg_functions?: OtgHidFunctionsUpdate;
otg_keyboard_leds?: boolean; otg_keyboard_leds?: boolean;
mouse_absolute?: boolean; mouse_absolute?: boolean;
@@ -548,6 +556,49 @@ export interface MsdConfigUpdate {
msd_dir?: string; msd_dir?: string;
} }
export interface NetworkInterfaceInfo {
name: string;
interface_type: string;
state: string;
connection: string;
addresses: string[];
has_default_route: boolean;
bridge_supported: boolean;
reason?: string;
}
export enum OtgRuntimeHealth {
Healthy = "healthy",
Applying = "applying",
Degraded = "degraded",
}
export interface OtgNetworkStatus {
health: OtgRuntimeHealth;
error?: string;
}
export interface OtgConfigResponse {
hid: HidConfig;
msd: MsdConfig;
network: OtgNetworkConfig;
status: OtgNetworkStatus;
}
export interface OtgNetworkConfigUpdate {
enabled?: boolean;
driver_mode?: OtgNetworkDriverMode;
bridge_interface?: string;
host_mac?: string;
device_mac?: string;
}
export interface OtgConfigUpdate {
hid?: HidConfigUpdate;
msd?: MsdConfigUpdate;
network?: OtgNetworkConfigUpdate;
}
export interface RtspConfigResponse { export interface RtspConfigResponse {
enabled: boolean; enabled: boolean;
bind: string; bind: string;

View File

@@ -10,6 +10,7 @@ import { useAuthStore } from '@/stores/auth'
import { import {
authApi, authApi,
configApi, configApi,
otgNetworkApi,
hidApi, hidApi,
streamApi, streamApi,
atxConfigApi, atxConfigApi,
@@ -42,11 +43,12 @@ import type {
AtxDriverType, AtxDriverType,
ActiveLevel, ActiveLevel,
AtxDevices, AtxDevices,
OtgEndpointBudget,
OtgHidProfile, OtgHidProfile,
OtgHidFunctions, OtgHidFunctions,
Ch9329DescriptorConfig, Ch9329DescriptorConfig,
Ch9329DescriptorState, Ch9329DescriptorState,
NetworkInterfaceInfo,
OtgNetworkStatus,
} from '@/types/generated' } from '@/types/generated'
import { FrpProxyType, FrpcConfigMode } from '@/types/generated' import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
import { formatFpsLabel, toConfigFps } from '@/lib/fps' import { formatFpsLabel, toConfigFps } from '@/lib/fps'
@@ -94,7 +96,6 @@ import {
EyeOff, EyeOff,
Save, Save,
Check, Check,
HardDrive,
Power, Power,
Server, Server,
Menu, Menu,
@@ -125,19 +126,18 @@ const authStore = useAuthStore()
const featureVisibility = useFeatureVisibility() const featureVisibility = useFeatureVisibility()
const isWindows = computed(() => systemStore.platform?.mode === 'windows') const isWindows = computed(() => systemStore.platform?.mode === 'windows')
const msdAvailable = computed(() => systemStore.platform?.msd.available ?? systemStore.capabilities?.msd.available ?? false)
const activeSection = ref<SettingsSectionId>('appearance') const activeSection = ref<SettingsSectionId>('appearance')
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
const loading = ref(false) const loading = ref(false)
const saved = ref(false) const saved = ref(false)
const saveError = ref('')
const SETTINGS_SECTION_IDS = [ const SETTINGS_SECTION_IDS = [
'appearance', 'appearance',
'account', 'account',
'network', 'network',
'video', 'video',
'hid', 'hid',
'msd',
'atx', 'atx',
'environment', 'environment',
'ext-ttyd', 'ext-ttyd',
@@ -150,19 +150,19 @@ const SETTINGS_SECTION_ID_SET = new Set<string>(SETTINGS_SECTION_IDS)
const navGroups = computed(() => [ const navGroups = computed(() => [
{ {
title: t('settings.system'), title: t('settings.software'),
items: [ items: [
{ id: 'appearance', label: t('settings.appearance'), icon: Sun }, { id: 'appearance', label: t('settings.appearance'), icon: Sun },
{ id: 'account', label: t('settings.account'), icon: User }, { id: 'account', label: t('settings.account'), icon: User },
{ id: 'network', label: t('settings.network'), icon: Globe }, { id: 'network', label: t('settings.network'), icon: Globe },
{ id: 'about', label: t('settings.about'), icon: Info },
] ]
}, },
{ {
title: t('settings.hardware'), title: t('settings.system'),
items: [ items: [
{ id: 'video', label: t('settings.video'), icon: Monitor, status: config.value.video_device ? t('settings.configured') : null }, { id: 'video', label: t('settings.video'), icon: Monitor },
{ id: 'hid', label: t('settings.hid'), icon: Keyboard, status: config.value.hid_backend.toUpperCase() }, { id: 'hid', label: t('settings.hid'), icon: Keyboard },
...(msdAvailable.value ? [{ id: 'msd', label: t('settings.msd'), icon: HardDrive }] : []),
{ id: 'atx', label: t('settings.atx'), icon: Power }, { id: 'atx', label: t('settings.atx'), icon: Power },
{ id: 'environment', label: t('settings.environment'), icon: Server }, { id: 'environment', label: t('settings.environment'), icon: Server },
] ]
@@ -174,12 +174,6 @@ const navGroups = computed(() => [
{ id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare }, { id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare },
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink }, { id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
] ]
},
{
title: t('settings.other'),
items: [
{ id: 'about', label: t('settings.about'), icon: Info },
]
} }
]) ])
@@ -225,6 +219,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
if (value === 'access-control') return 'account' if (value === 'access-control') return 'account'
if (value === 'ext-frpc') return 'ext-remote-access' if (value === 'ext-frpc') return 'ext-remote-access'
if (value === 'redfish') return 'third-party-access' if (value === 'redfish') return 'third-party-access'
if (value === 'msd') return 'hid'
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access' if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
return isSettingsSectionId(value) ? value : null return isSettingsSectionId(value) ? value : null
} }
@@ -253,7 +248,6 @@ async function loadSectionData(section: SettingsSectionId) {
]) ])
return return
case 'hid': case 'hid':
case 'msd':
await Promise.all([ await Promise.all([
loadConfig(), loadConfig(),
loadDevices(), loadDevices(),
@@ -591,7 +585,6 @@ const config = ref({
hid_serial_baudrate: 9600, hid_serial_baudrate: 9600,
hid_otg_udc: '', hid_otg_udc: '',
hid_otg_profile: 'custom' as OtgHidProfile, hid_otg_profile: 'custom' as OtgHidProfile,
hid_otg_endpoint_budget: 'six' as OtgEndpointBudget,
hid_otg_functions: { hid_otg_functions: {
keyboard: true, keyboard: true,
mouse_relative: true, mouse_relative: true,
@@ -602,6 +595,9 @@ const config = ref({
hid_ch9329_hybrid_mouse: false, hid_ch9329_hybrid_mouse: false,
msd_enabled: false, msd_enabled: false,
msd_dir: '', msd_dir: '',
otg_network_enabled: false,
otg_network_driver: 'ncm' as 'ncm' | 'ecm' | 'rndis',
otg_network_interface: '',
encoder_backend: 'auto', encoder_backend: 'auto',
stun_server: '', stun_server: '',
turn_server: '', turn_server: '',
@@ -609,6 +605,20 @@ const config = ref({
turn_password: '', turn_password: '',
}) })
const otgNetworkInterfaces = ref<NetworkInterfaceInfo[]>([])
const otgNetworkInterfacesLoaded = ref(false)
const otgNetworkStatus = ref<OtgNetworkStatus | null>(null)
function syncOtgNetworkInterface() {
const firstInterface = otgNetworkInterfaces.value[0]?.name || ''
const selectedInterfaceExists = otgNetworkInterfaces.value.some(
item => item.name === config.value.otg_network_interface,
)
if (!selectedInterfaceExists) {
config.value.otg_network_interface = firstInterface
}
}
type OtgSelfCheckLevel = 'info' | 'warn' | 'error' type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped' type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
@@ -909,78 +919,12 @@ function usbSpeedLabel(speed?: string): string {
return map[speed] || `${speed} Mbps` return map[speed] || `${speed} Mbps`
} }
function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget {
return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget
}
function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget {
if (!budget || budget === 'auto') {
return defaultOtgEndpointBudgetForUdc(udc)
}
return budget
}
function endpointLimitForBudget(budget: OtgEndpointBudget): number | null {
if (budget === 'unlimited') return null
return budget === 'five' ? 5 : 6
}
const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions })) const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions }))
const otgEndpointLimit = computed(() =>
endpointLimitForBudget(config.value.hid_otg_endpoint_budget)
)
const otgRequiredEndpoints = computed(() => {
if (config.value.hid_backend !== 'otg') return 0
const functions = effectiveOtgFunctions.value
let endpoints = 0
if (functions.keyboard) {
endpoints += 1
if (config.value.hid_otg_keyboard_leds) endpoints += 1
}
if (functions.mouse_relative) endpoints += 1
if (functions.mouse_absolute) endpoints += 1
if (functions.consumer) endpoints += 1
if (config.value.msd_enabled) endpoints += 2
return endpoints
})
const isOtgEndpointBudgetValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true
const limit = otgEndpointLimit.value
return limit === null || otgRequiredEndpoints.value <= limit
})
const otgEndpointUsageText = computed(() => {
const limit = otgEndpointLimit.value
if (limit === null) {
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
}
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
})
const showOtgEndpointBudgetHint = computed(() =>
config.value.hid_backend === 'otg'
)
const isKeyboardLedToggleDisabled = computed(() => const isKeyboardLedToggleDisabled = computed(() =>
config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard
) )
function describeEndpointBudget(budget: OtgEndpointBudget): string {
switch (budget) {
case 'five':
return '5'
case 'six':
return '6'
case 'unlimited':
return t('settings.otgEndpointBudgetUnlimited')
default:
return '6'
}
}
const isHidFunctionSelectionValid = computed(() => { const isHidFunctionSelectionValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true if (config.value.hid_backend !== 'otg') return true
const f = config.value.hid_otg_functions const f = config.value.hid_otg_functions
@@ -1117,16 +1061,9 @@ const isCh9329DescriptorDirty = computed(() => {
const isHidSettingsValid = computed(() => const isHidSettingsValid = computed(() =>
isHidFunctionSelectionValid.value isHidFunctionSelectionValid.value
&& isOtgEndpointBudgetValid.value
&& isCh9329DescriptorValid.value && isCh9329DescriptorValid.value
) )
watch(() => config.value.msd_enabled, (enabled) => {
if (!enabled && activeSection.value === 'msd') {
activeSection.value = 'hid'
}
})
watch(bindMode, (mode) => { watch(bindMode, (mode) => {
if (mode === 'custom' && bindAddressList.value.length === 0) { if (mode === 'custom' && bindAddressList.value.length === 0) {
bindAddressList.value = [''] bindAddressList.value = ['']
@@ -1201,8 +1138,6 @@ const selectedBackendFormats = computed(() => {
return backend?.supported_formats || [] return backend?.supported_formats || []
}) })
const isCh9329Backend = computed(() => config.value.hid_backend === 'ch9329')
const selectedDevice = computed(() => { const selectedDevice = computed(() => {
return devices.value.video.find(d => d.path === config.value.video_device) return devices.value.video.find(d => d.path === config.value.video_device)
}) })
@@ -1408,6 +1343,7 @@ async function changePassword() {
async function saveConfig() { async function saveConfig() {
loading.value = true loading.value = true
saved.value = false saved.value = false
saveError.value = ''
try { try {
if (activeSection.value === 'video') { if (activeSection.value === 'video') {
@@ -1436,6 +1372,7 @@ async function saveConfig() {
ch9329_port: config.value.hid_serial_device || undefined, ch9329_port: config.value.hid_serial_device || undefined,
ch9329_baudrate: config.value.hid_serial_baudrate, ch9329_baudrate: config.value.hid_serial_baudrate,
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse, ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
otg_udc: config.value.hid_otg_udc,
} }
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) { if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
hidUpdate.ch9329_descriptor = { hidUpdate.ch9329_descriptor = {
@@ -1455,22 +1392,23 @@ async function saveConfig() {
serial_number: otgSerialNumber.value || undefined, serial_number: otgSerialNumber.value || undefined,
} }
hidUpdate.otg_profile = 'custom' hidUpdate.otg_profile = 'custom'
hidUpdate.otg_endpoint_budget = config.value.hid_otg_endpoint_budget
hidUpdate.otg_functions = { ...config.value.hid_otg_functions } hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
} }
await configStore.updateHid(hidUpdate) const otgEnabled = config.value.hid_backend === 'otg'
if (config.value.hid_backend === 'otg') { const response = await configStore.updateOtg({
await configStore.updateMsd({ enabled: config.value.msd_enabled }) hid: hidUpdate,
} else { msd: {
await configStore.updateMsd({ enabled: false }) enabled: otgEnabled && config.value.msd_enabled,
}
}
if (activeSection.value === 'msd') {
await configStore.updateMsd({
msd_dir: config.value.msd_dir || undefined, msd_dir: config.value.msd_dir || undefined,
},
network: {
enabled: otgEnabled && config.value.otg_network_enabled,
driver_mode: config.value.otg_network_driver as any,
bridge_interface: config.value.otg_network_interface,
},
}) })
otgNetworkStatus.value = response.status
} }
if (activeSection.value !== 'hid') { if (activeSection.value !== 'hid') {
@@ -1478,7 +1416,12 @@ async function saveConfig() {
} }
saved.value = true saved.value = true
setTimeout(() => (saved.value = false), 2000) setTimeout(() => (saved.value = false), 2000)
} catch { } catch (error) {
saveError.value = error instanceof Error ? error.message : t('api.operationFailedDesc')
if (activeSection.value === 'hid') {
await loadConfig().catch(() => undefined)
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -1486,11 +1429,18 @@ async function saveConfig() {
async function loadConfig() { async function loadConfig() {
try { try {
const [video, stream, hid, msd] = await Promise.all([ const [video, stream, hid, msd, otgNetwork] = await Promise.all([
configStore.refreshVideo(), configStore.refreshVideo(),
configStore.refreshStream(), configStore.refreshStream(),
configStore.refreshHid(), configStore.refreshHid(),
configStore.refreshMsd(), configStore.refreshMsd(),
otgNetworkApi.get().catch(() => ({
enabled: false,
driver_mode: 'ncm' as const,
bridge_interface: '',
host_mac: '',
device_mac: '',
})),
]) ])
config.value = { config.value = {
@@ -1504,7 +1454,6 @@ async function loadConfig() {
hid_serial_baudrate: hid.ch9329_baudrate || 9600, hid_serial_baudrate: hid.ch9329_baudrate || 9600,
hid_otg_udc: hid.otg_udc || '', hid_otg_udc: hid.otg_udc || '',
hid_otg_profile: 'custom' as OtgHidProfile, hid_otg_profile: 'custom' as OtgHidProfile,
hid_otg_endpoint_budget: normalizeOtgEndpointBudget(hid.otg_endpoint_budget, hid.otg_udc || ''),
hid_otg_functions: { hid_otg_functions: {
keyboard: hid.otg_functions?.keyboard ?? true, keyboard: hid.otg_functions?.keyboard ?? true,
mouse_relative: hid.otg_functions?.mouse_relative ?? true, mouse_relative: hid.otg_functions?.mouse_relative ?? true,
@@ -1515,12 +1464,18 @@ async function loadConfig() {
hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false, hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false,
msd_enabled: msd.enabled || false, msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '', msd_dir: msd.msd_dir || '',
otg_network_enabled: otgNetwork.enabled,
otg_network_driver: otgNetwork.driver_mode,
otg_network_interface: otgNetwork.bridge_interface,
encoder_backend: stream.encoder || 'auto', encoder_backend: stream.encoder || 'auto',
stun_server: stream.stun_server || '', stun_server: stream.stun_server || '',
turn_server: stream.turn_server || '', turn_server: stream.turn_server || '',
turn_username: stream.turn_username || '', turn_username: stream.turn_username || '',
turn_password: stream.turn_password || '', turn_password: stream.turn_password || '',
} }
if (otgNetworkInterfacesLoaded.value) {
syncOtgNetworkInterface()
}
if (hid.otg_descriptor) { if (hid.otg_descriptor) {
otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b' otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b'
@@ -1539,14 +1494,21 @@ async function loadConfig() {
} else { } else {
clearCh9329DescriptorState() clearCh9329DescriptorState()
} }
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
} catch { } catch {
} }
} }
async function loadDevices() { async function loadDevices() {
try { try {
devices.value = await configApi.listDevices() const [deviceConfig, networkInterfaces] = await Promise.all([
configApi.listDevices(),
otgNetworkApi.interfaces().catch(() => []),
])
devices.value = deviceConfig
otgNetworkInterfaces.value = networkInterfaces
otgNetworkInterfacesLoaded.value = true
syncOtgNetworkInterface()
} catch { } catch {
} }
} }
@@ -2701,7 +2663,6 @@ watch(isWindows, () => {
> >
<component :is="item.icon" class="h-4 w-4" /> <component :is="item.icon" class="h-4 w-4" />
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-xs', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
</button> </button>
</div> </div>
</nav> </nav>
@@ -2738,7 +2699,6 @@ watch(isWindows, () => {
> >
<component :is="item.icon" class="h-4 w-4 shrink-0" /> <component :is="item.icon" class="h-4 w-4 shrink-0" />
<span class="truncate">{{ item.label }}</span> <span class="truncate">{{ item.label }}</span>
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-[10px] px-1.5 py-0 h-4', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
</button> </button>
</div> </div>
</nav> </nav>
@@ -3080,24 +3040,16 @@ watch(isWindows, () => {
<option :value="115200">115200</option> <option :value="115200">115200</option>
</select> </select>
</div> </div>
<div v-if="config.hid_backend === 'otg'" class="space-y-2">
<Label for="otg-udc">{{ t('settings.otgUdc') }}</Label>
<select id="otg-udc" v-model="config.hid_otg_udc" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.autoRecommended') }}</option>
<option v-for="udc in devices.udc" :key="udc.name" :value="udc.name">{{ udc.name }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ t('settings.otgUdcDesc') }}</p>
</div>
<template v-if="config.hid_backend === 'ch9329'"> <template v-if="config.hid_backend === 'ch9329'">
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.ch9329Options') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329OptionsDesc') }}</p>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.ch9329HybridMouse') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.ch9329HybridMouseDesc') }}</p>
</div>
<Switch v-model="config.hid_ch9329_hybrid_mouse" />
</div>
</div>
</div>
<Separator class="my-4" /> <Separator class="my-4" />
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@@ -3182,88 +3134,26 @@ watch(isWindows, () => {
{{ t('settings.ch9329DescriptorWarning') }} {{ t('settings.ch9329DescriptorWarning') }}
</p> </p>
</div> </div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.ch9329Options') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329OptionsDesc') }}</p>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.ch9329HybridMouse') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.ch9329HybridMouseDesc') }}</p>
</div>
<Switch v-model="config.hid_ch9329_hybrid_mouse" />
</div>
</div>
</div>
</template> </template>
<!-- OTG Descriptor Settings --> <!-- OTG Descriptor Settings -->
<template v-if="config.hid_backend === 'otg'"> <template v-if="config.hid_backend === 'otg'">
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div>
<div class="space-y-2">
<Label for="otg-endpoint-budget">{{ t('settings.otgEndpointBudget') }}</Label>
<select id="otg-endpoint-budget" v-model="config.hid_otg_endpoint_budget" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="five">5</option>
<option value="six">6</option>
<option value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ otgEndpointUsageText }}</p>
</div>
<div class="space-y-3">
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
<p v-if="showOtgEndpointBudgetHint" class="text-xs text-muted-foreground">
{{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}
</p>
</div>
<Separator class="my-4" /> <Separator class="my-4" />
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@@ -3323,7 +3213,118 @@ watch(isWindows, () => {
{{ t('settings.descriptorWarning') }} {{ t('settings.descriptorWarning') }}
</p> </p>
</div> </div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div>
<div class="space-y-3">
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
<template v-if="config.msd_enabled">
<Separator />
<div class="space-y-2">
<Label for="msd-dir">{{ t('settings.msdDir') }}</Label>
<Input id="msd-dir" v-model="config.msd_dir" placeholder="/etc/one-kvm/msd" />
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirDesc') }}</p>
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirHint') }}</p>
</div>
</template> </template>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgNetwork') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgNetworkDesc') }}</p>
</div>
<Switch v-model="config.otg_network_enabled" />
</div>
<template v-if="config.otg_network_enabled">
<Separator />
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="otg-network-driver">{{ t('settings.otgNetworkDriver') }}</Label>
<select id="otg-network-driver" v-model="config.otg_network_driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="ncm">NCM</option>
<option value="ecm">ECM</option>
<option value="rndis">RNDIS / Windows</option>
</select>
</div>
<div class="space-y-2">
<Label for="otg-network-interface">{{ t('settings.otgNetworkInterface') }}</Label>
<select id="otg-network-interface" v-model="config.otg_network_interface" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="otgNetworkInterfaces.length === 0">
<option v-if="otgNetworkInterfaces.length === 0" value="">{{ t('settings.otgNetworkNone') }}</option>
<option
v-for="item in otgNetworkInterfaces"
:key="item.name"
:value="item.name"
>
{{ item.name }} · {{ item.interface_type }}
</option>
</select>
</div>
</div>
</template>
<p v-if="otgNetworkStatus?.health === 'degraded'" class="text-xs text-destructive">
{{ t('settings.otgRuntimeDegraded') }}: {{ otgNetworkStatus.error || t('common.error') }}
</p>
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
</div>
</template>
</CardContent> </CardContent>
</Card> </Card>
@@ -3918,42 +3919,6 @@ watch(isWindows, () => {
</Card> </Card>
</div> </div>
<!-- MSD Section -->
<div v-show="activeSection === 'msd' && config.msd_enabled" class="space-y-6">
<Card>
<CardHeader>
<CardTitle>{{ t('settings.msdSettings') }}</CardTitle>
<CardDescription>{{ t('settings.msdDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="isCh9329Backend" class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<p class="font-medium">{{ t('settings.msdCh9329Warning') }}</p>
<p class="text-xs text-amber-900/80">{{ t('settings.msdCh9329WarningDesc') }}</p>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label for="msd-dir">{{ t('settings.msdDir') }}</Label>
<Input id="msd-dir" v-model="config.msd_dir" placeholder="/etc/one-kvm/msd" :disabled="isCh9329Backend" />
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirDesc') }}</p>
</div>
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirHint') }}</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ t('settings.msdStatus') }}</p>
<p class="text-xs text-muted-foreground">
{{ config.msd_enabled ? t('settings.willBeEnabledAfterSave') : t('settings.disabled') }}
</p>
</div>
<Badge :variant="config.msd_enabled ? 'default' : 'secondary'">
{{ config.msd_enabled ? t('common.enabled') : t('common.disabled') }}
</Badge>
</div>
</CardContent>
</Card>
</div>
<!-- ATX Section --> <!-- ATX Section -->
<div v-show="activeSection === 'atx'" class="space-y-6"> <div v-show="activeSection === 'atx'" class="space-y-6">
<Card> <Card>
@@ -5311,7 +5276,7 @@ watch(isWindows, () => {
</div> </div>
<!-- Save Button (sticky) --> <!-- Save Button (sticky) -->
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" 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="['video', 'hid'].includes(activeSection)" 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="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0"> <p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" /> <AlertTriangle class="h-3.5 w-3.5 shrink-0" />
@@ -5325,6 +5290,7 @@ watch(isWindows, () => {
<AlertTriangle class="h-3.5 w-3.5 shrink-0" /> <AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span> <span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
</p> </p>
<p v-if="saveError" class="text-xs text-destructive">{{ saveError }}</p>
<p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p> <p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p>
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidSettingsValid)" @click="saveConfig"> <Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidSettingsValid)" @click="saveConfig">
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }} <Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}

View File

@@ -78,7 +78,6 @@ const ch9329Baudrate = ref(9600)
const otgUdc = ref('') const otgUdc = ref('')
const hidOtgProfile = ref('full_no_consumer') const hidOtgProfile = ref('full_no_consumer')
const otgMsdEnabled = ref(true) const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true) const otgKeyboardLeds = ref(true)
const ttydEnabled = ref(false) const ttydEnabled = ref(false)
@@ -203,46 +202,11 @@ const availableFps = computed(() => {
return resolution?.fps || [] return resolution?.fps || []
}) })
function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' {
return /musb/i.test(udc || '') ? 'five' : 'six'
}
function endpointLimitForBudget(budget: 'five' | 'six' | 'unlimited'): number | null {
if (budget === 'unlimited') return null
return budget === 'five' ? 5 : 6
}
const otgRequiredEndpoints = computed(() => {
if (hidBackend.value !== 'otg') return 0
const functions = {
keyboard: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_keyboard',
mouseRelative: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_mouse_relative',
mouseAbsolute: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer',
consumer: hidOtgProfile.value === 'full',
}
let endpoints = 0
if (functions.keyboard) {
endpoints += 1
if (otgKeyboardLeds.value) endpoints += 1
}
if (functions.mouseRelative) endpoints += 1
if (functions.mouseAbsolute) endpoints += 1
if (functions.consumer) endpoints += 1
if (otgMsdEnabled.value) endpoints += 2
return endpoints
})
const isOtgEndpointBudgetValid = computed(() => {
const limit = endpointLimitForBudget(otgEndpointBudget.value)
return limit === null || otgRequiredEndpoints.value <= limit
})
function applyOtgDefaults() { function applyOtgDefaults() {
if (hidBackend.value !== 'otg') return if (hidBackend.value !== 'otg') return
otgEndpointBudget.value = defaultOtgEndpointBudgetForUdc(otgUdc.value)
hidOtgProfile.value = 'full_no_consumer' hidOtgProfile.value = 'full_no_consumer'
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five' otgKeyboardLeds.value = true
} }
const baudRates = [9600, 19200, 38400, 57600, 115200] const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -477,13 +441,6 @@ function validateStep3(): boolean {
error.value = t('setup.selectUdc') error.value = t('setup.selectUdc')
return false return false
} }
if (hidBackend.value === 'otg' && !isOtgEndpointBudgetValid.value) {
error.value = t('settings.otgEndpointExceeded', {
used: otgRequiredEndpoints.value,
limit: otgEndpointBudget.value === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget.value === 'five' ? '5' : '6',
})
return false
}
return true return true
} }
@@ -543,7 +500,6 @@ async function handleSetup() {
if (hidBackend.value === 'otg' && otgUdc.value) { if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value setupData.hid_otg_profile = hidOtgProfile.value
setupData.hid_otg_endpoint_budget = otgEndpointBudget.value
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
setupData.msd_enabled = otgMsdEnabled.value setupData.msd_enabled = otgMsdEnabled.value
} }