fix(atx): 完善串口继电器配置校验与前端防冲突

This commit is contained in:
mofeng-git
2026-02-20 15:36:08 +08:00
parent 6e2c6dea1c
commit 016c0d5dbb
6 changed files with 501 additions and 49 deletions

View File

@@ -73,6 +73,8 @@ impl AtxKeyExecutor {
return Ok(());
}
self.validate_runtime_config()?;
match self.config.driver {
AtxDriverType::Gpio => self.init_gpio().await?,
AtxDriverType::UsbRelay => self.init_usb_relay().await?,
@@ -84,6 +86,39 @@ impl AtxKeyExecutor {
Ok(())
}
fn validate_runtime_config(&self) -> Result<()> {
match self.config.driver {
AtxDriverType::Serial => {
if self.config.pin == 0 {
return Err(AppError::Config(
"Serial ATX channel must be 1-based (>= 1)".to_string(),
));
}
if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!(
"Serial ATX channel must be <= {}",
u8::MAX
)));
}
if self.config.baud_rate == 0 {
return Err(AppError::Config(
"Serial ATX baud_rate must be greater than 0".to_string(),
));
}
}
AtxDriverType::UsbRelay => {
if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!(
"USB relay channel must be <= {}",
u8::MAX
)));
}
}
AtxDriverType::Gpio | AtxDriverType::None => {}
}
Ok(())
}
/// Initialize GPIO backend
async fn init_gpio(&mut self) -> Result<()> {
info!(
@@ -146,11 +181,7 @@ impl AtxKeyExecutor {
self.config.device, self.config.pin
);
let baud_rate = if self.config.baud_rate > 0 {
self.config.baud_rate
} else {
9600
};
let baud_rate = self.config.baud_rate;
let port = serialport::new(&self.config.device, baud_rate)
.timeout(Duration::from_millis(100))
@@ -235,7 +266,13 @@ impl AtxKeyExecutor {
/// Send USB relay command using cached handle
fn send_usb_relay_command(&self, on: bool) -> Result<()> {
let channel = self.config.pin as u8;
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"USB relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
))
})?;
// Standard HID relay command format
let cmd = if on {
@@ -273,9 +310,18 @@ impl AtxKeyExecutor {
/// Send Serial relay command using cached handle
fn send_serial_relay_command(&self, on: bool) -> Result<()> {
// user config pin should be 1 for most LCUS modules (A0 01 01 A2)
// if user set 0, it will send A0 00 01 A1 which might not work
let channel = self.config.pin as u8;
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"Serial relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
))
})?;
if channel == 0 {
return Err(AppError::Config(
"Serial relay channel must be 1-based (>= 1)".to_string(),
));
}
// LCUS-Type Protocol
// Frame: [StopByte(A0), Channel, State, Checksum]
@@ -408,4 +454,46 @@ mod tests {
assert_eq!(timing::LONG_PRESS.as_millis(), 5000);
assert_eq!(timing::RESET_PRESS.as_millis(), 500);
}
#[tokio::test]
async fn test_executor_init_rejects_serial_channel_zero() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_serial_channel_overflow() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 256,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_zero_serial_baud_rate() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: ActiveLevel::High,
baud_rate: 0,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
}

View File

@@ -1,32 +1,39 @@
//! ATX 配置 Handler
//! ATX configuration handlers
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::config::AtxConfig;
use crate::error::Result;
use crate::atx::AtxDriverType;
use crate::config::{AtxConfig, HidBackend, HidConfig};
use crate::error::{AppError, Result};
use crate::state::AppState;
use super::apply::apply_atx_config;
use super::types::AtxConfigUpdate;
/// 获取 ATX 配置
/// Get ATX configuration
pub async fn get_atx_config(State(state): State<Arc<AppState>>) -> Json<AtxConfig> {
Json(state.config.get().atx.clone())
}
/// 更新 ATX 配置
/// Update ATX configuration
pub async fn update_atx_config(
State(state): State<Arc<AppState>>,
Json(req): Json<AtxConfigUpdate>,
) -> Result<Json<AtxConfig>> {
// 1. 验证请求
req.validate()?;
// 1. Read current configuration snapshot
let current_config = state.config.get();
let old_atx_config = current_config.atx.clone();
// 2. 获取旧配置
let old_atx_config = state.config.get().atx.clone();
// 2. Validate request, including merged effective serial parameter checks
req.validate_with_current(&old_atx_config)?;
// 3. 应用更新到配置存储
// 3. Ensure ATX serial devices do not conflict with HID CH9329 serial device
let mut merged_atx_config = old_atx_config.clone();
req.apply_to(&mut merged_atx_config);
validate_serial_device_conflict(&merged_atx_config, &current_config.hid)?;
// 4. Persist update into config store
state
.config
.update(|config| {
@@ -34,13 +41,65 @@ pub async fn update_atx_config(
})
.await?;
// 4. 获取新配置
// 5. Load new config
let new_atx_config = state.config.get().atx.clone();
// 5. 应用到子系统(热重载)
// 6. Apply to subsystem (hot reload)
if let Err(e) = apply_atx_config(&state, &old_atx_config, &new_atx_config).await {
tracing::error!("Failed to apply ATX config: {}", e);
}
Ok(Json(new_atx_config))
}
fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
if hid.backend != HidBackend::Ch9329 {
return Ok(());
}
let reserved = hid.ch9329_port.trim();
if reserved.is_empty() {
return Ok(());
}
for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] {
if key.driver == AtxDriverType::Serial && key.device.trim() == reserved {
return Err(AppError::BadRequest(format!(
"ATX {} serial device '{}' conflicts with HID CH9329 serial device",
name, reserved
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
let mut atx = AtxConfig::default();
atx.power.driver = AtxDriverType::Serial;
atx.power.device = "/dev/ttyUSB0".to_string();
let mut hid = HidConfig::default();
hid.backend = HidBackend::Ch9329;
hid.ch9329_port = "/dev/ttyUSB0".to_string();
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
}
#[test]
fn test_validate_serial_device_conflict_allows_non_ch9329_backend() {
let mut atx = AtxConfig::default();
atx.power.driver = AtxDriverType::Serial;
atx.power.device = "/dev/ttyUSB0".to_string();
let mut hid = HidConfig::default();
hid.backend = HidBackend::None;
hid.ch9329_port = "/dev/ttyUSB0".to_string();
assert!(validate_serial_device_conflict(&atx, &hid).is_ok());
}
}

View File

@@ -93,21 +93,21 @@ impl VideoConfigUpdate {
// ===== Stream Config =====
/// Stream 配置响应(包含 has_turn_password 字段)
/// Stream configuration response (includes has_turn_password)
#[typeshare]
#[derive(Debug, serde::Serialize)]
pub struct StreamConfigResponse {
pub mode: StreamMode,
pub encoder: EncoderType,
pub bitrate_preset: BitratePreset,
/// 是否有公共 ICE 服务器可用(编译时确定)
/// Whether public ICE servers are available (compile-time decision)
pub has_public_ice_servers: bool,
/// 当前是否正在使用公共 ICE 服务器STUN/TURN 都为空时)
/// Whether public ICE servers are currently in use (when STUN/TURN are unset)
pub using_public_ice_servers: bool,
pub stun_server: Option<String>,
pub turn_server: Option<String>,
pub turn_username: Option<String>,
/// 指示是否已设置 TURN 密码(实际密码不返回)
/// Indicates whether TURN password has been configured (password is not returned)
pub has_turn_password: bool,
}
@@ -397,6 +397,7 @@ impl MsdConfigUpdate {
pub struct AtxKeyConfigUpdate {
pub driver: Option<crate::atx::AtxDriverType>,
pub device: Option<String>,
pub baud_rate: Option<u32>,
pub pin: Option<u32>,
pub active_level: Option<crate::atx::ActiveLevel>,
}
@@ -439,6 +440,37 @@ impl AtxConfigUpdate {
Ok(())
}
pub fn validate_with_current(&self, current: &AtxConfig) -> crate::error::Result<()> {
self.validate()?;
// Validate with full context after applying the patch payload.
let mut merged = current.clone();
self.apply_to(&mut merged);
Self::validate_effective_key_config(&merged.power, "power")?;
Self::validate_effective_key_config(&merged.reset, "reset")?;
Self::validate_shared_serial_baud_rate(&merged)?;
Ok(())
}
fn validate_shared_serial_baud_rate(config: &AtxConfig) -> crate::error::Result<()> {
let power = &config.power;
let reset = &config.reset;
let same_serial_device = power.driver == crate::atx::AtxDriverType::Serial
&& reset.driver == crate::atx::AtxDriverType::Serial
&& !power.device.trim().is_empty()
&& power.device.trim() == reset.device.trim();
if same_serial_device && power.baud_rate != reset.baud_rate {
return Err(AppError::BadRequest(
"ATX power/reset sharing the same serial relay device must use one baud_rate"
.to_string(),
));
}
Ok(())
}
fn validate_key_config(key: &AtxKeyConfigUpdate, name: &str) -> crate::error::Result<()> {
if let Some(ref device) = key.device {
if !device.is_empty() && !std::path::Path::new(device).exists() {
@@ -448,6 +480,88 @@ impl AtxConfigUpdate {
)));
}
}
if let Some(baud_rate) = key.baud_rate {
if baud_rate == 0 {
return Err(AppError::BadRequest(format!(
"{} baud_rate must be greater than 0",
name
)));
}
}
if let Some(driver) = key.driver {
match driver {
crate::atx::AtxDriverType::Serial => {
if let Some(pin) = key.pin {
if pin == 0 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be 1-based (>= 1)",
name
)));
}
if pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be <= {}",
name,
u8::MAX
)));
}
}
}
crate::atx::AtxDriverType::UsbRelay => {
if let Some(pin) = key.pin {
if pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}",
name,
u8::MAX
)));
}
}
}
crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {}
}
}
Ok(())
}
fn validate_effective_key_config(
key: &crate::atx::AtxKeyConfig,
name: &str,
) -> crate::error::Result<()> {
match key.driver {
crate::atx::AtxDriverType::Serial => {
if key.pin == 0 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be 1-based (>= 1)",
name
)));
}
if key.pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be <= {}",
name,
u8::MAX
)));
}
if key.baud_rate == 0 {
return Err(AppError::BadRequest(format!(
"{} baud_rate must be greater than 0",
name
)));
}
}
crate::atx::AtxDriverType::UsbRelay => {
if key.pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}",
name,
u8::MAX
)));
}
}
crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {}
}
Ok(())
}
@@ -476,6 +590,9 @@ impl AtxConfigUpdate {
if let Some(ref device) = update.device {
config.device = device.clone();
}
if let Some(baud_rate) = update.baud_rate {
config.baud_rate = baud_rate;
}
if let Some(pin) = update.pin {
config.pin = pin;
}
@@ -784,3 +901,83 @@ impl WebConfigUpdate {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atx_apply_key_update_applies_baud_rate() {
let mut config = AtxConfig::default();
let update = AtxConfigUpdate {
enabled: None,
power: Some(AtxKeyConfigUpdate {
driver: Some(crate::atx::AtxDriverType::Serial),
device: Some("/dev/null".to_string()),
baud_rate: Some(19200),
pin: Some(1),
active_level: None,
}),
reset: None,
led: None,
wol_interface: None,
};
update.apply_to(&mut config);
assert_eq!(config.power.baud_rate, 19200);
}
#[test]
fn test_atx_validate_with_current_rejects_serial_pin_zero() {
let mut current = AtxConfig::default();
current.power.driver = crate::atx::AtxDriverType::Serial;
current.power.device = "/dev/null".to_string();
current.power.pin = 1;
current.power.baud_rate = 9600;
let update = AtxConfigUpdate {
enabled: None,
power: Some(AtxKeyConfigUpdate {
driver: None,
device: None,
baud_rate: None,
pin: Some(0),
active_level: None,
}),
reset: None,
led: None,
wol_interface: None,
};
assert!(update.validate_with_current(&current).is_err());
}
#[test]
fn test_atx_validate_with_current_rejects_shared_serial_baud_mismatch() {
let mut current = AtxConfig::default();
current.power.driver = crate::atx::AtxDriverType::Serial;
current.power.device = "/dev/ttyUSB0".to_string();
current.power.pin = 1;
current.power.baud_rate = 9600;
current.reset.driver = crate::atx::AtxDriverType::Serial;
current.reset.device = "/dev/ttyUSB0".to_string();
current.reset.pin = 2;
current.reset.baud_rate = 9600;
let update = AtxConfigUpdate {
enabled: None,
power: None,
reset: Some(AtxKeyConfigUpdate {
driver: None,
device: None,
baud_rate: Some(115200),
pin: None,
active_level: None,
}),
led: None,
wol_interface: None,
};
assert!(update.validate_with_current(&current).is_err());
}
}

View File

@@ -584,10 +584,12 @@ export default {
atxDriver: 'Driver Type',
atxDriverNone: 'Disabled',
atxDriverGpio: 'GPIO',
atxDriverUsbRelay: 'USB Relay',
atxDriverUsbRelay: 'USB LCUS HID Relay',
atxDriverSerial: 'USB LCUS Serial Relay',
atxDevice: 'Device',
atxPin: 'GPIO Pin',
atxChannel: 'Relay Channel',
atxSharedSerialBaudHint: 'When Power and Reset share one serial relay device, baud rate is controlled by the first config',
atxActiveLevel: 'Active Level',
atxLevelHigh: 'Active High',
atxLevelLow: 'Active Low',

View File

@@ -584,10 +584,12 @@ export default {
atxDriver: '驱动类型',
atxDriverNone: '禁用',
atxDriverGpio: 'GPIO',
atxDriverUsbRelay: 'USB 继电器',
atxDriverUsbRelay: 'USB LCUS HID继电器',
atxDriverSerial: 'USB LCUS 串口继电器',
atxDevice: '设备',
atxPin: 'GPIO 引脚',
atxChannel: '继电器通道',
atxSharedSerialBaudHint: 'Power 与 Reset 使用同一串口继电器时,波特率由第一个配置统一控制',
atxActiveLevel: '有效电平',
atxLevelHigh: '高电平有效',
atxLevelLow: '低电平有效',

View File

@@ -321,7 +321,7 @@ const config = ref({
turn_password: '',
})
// 跟踪服务器是否已配置 TURN 密码
// Tracks whether TURN password is configured on the server
const hasTurnPassword = ref(false)
const configLoaded = ref(false)
const devicesLoaded = ref(false)
@@ -623,6 +623,22 @@ const atxDevices = ref<AtxDevices>({
serial_ports: [],
})
const ch9329ReservedSerialDevice = computed(() => {
if (config.value.hid_backend !== 'ch9329') return ''
return config.value.hid_serial_device.trim()
})
const isSharedAtxSerialRelay = computed(() => {
const power = atxConfig.value.power
const reset = atxConfig.value.reset
return (
power.driver === 'serial'
&& reset.driver === 'serial'
&& !!power.device.trim()
&& power.device === reset.device
)
})
// Encoder backend
const availableBackends = ref<EncoderBackendInfo[]>([])
@@ -816,16 +832,16 @@ async function changePassword() {
}
}
// Save config - 使用域分离 API
// Save config using domain-separated APIs
async function saveConfig() {
loading.value = true
saved.value = false
try {
// 根据当前激活的 section 只保存相关配置
// Save only config related to the active section
const savePromises: Promise<unknown>[] = []
// Video 配置(包括编码器和 WebRTC/STUN/TURN 设置)
// Video config (including encoder and WebRTC/STUN/TURN settings)
if (activeSection.value === 'video') {
savePromises.push(
configStore.updateVideo({
@@ -836,7 +852,7 @@ async function saveConfig() {
fps: config.value.video_fps,
})
)
// 同时保存 Stream/Encoder STUN/TURN 配置
// Save Stream/Encoder and STUN/TURN config together
savePromises.push(
configStore.updateStream({
encoder: config.value.encoder_backend as any,
@@ -848,7 +864,7 @@ async function saveConfig() {
)
}
// HID 配置
// HID config
if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value) {
return
@@ -875,7 +891,7 @@ async function saveConfig() {
ch9329_port: config.value.hid_serial_device || undefined,
ch9329_baudrate: config.value.hid_serial_baudrate,
}
// 如果是 OTG 后端,添加描述符配置
// Add descriptor config for OTG backend
if (config.value.hid_backend === 'otg') {
hidUpdate.otg_descriptor = {
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
@@ -898,7 +914,7 @@ async function saveConfig() {
)
}
// MSD 配置
// MSD config
if (activeSection.value === 'msd') {
savePromises.push(
configStore.updateMsd({
@@ -917,10 +933,10 @@ async function saveConfig() {
}
}
// Load config - 使用域分离 API
// Load config using domain-separated APIs
async function loadConfig() {
try {
// 并行加载所有域配置
// Load all domain configs in parallel
const [video, stream, hid, msd] = await Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
@@ -952,13 +968,13 @@ async function loadConfig() {
stun_server: stream.stun_server || '',
turn_server: stream.turn_server || '',
turn_username: stream.turn_username || '',
turn_password: '', // 密码不从服务器返回,仅用于设置
turn_password: '', // Password is never returned from server; set-only field
}
// 设置是否已配置 TURN 密码
// Track whether TURN password is configured
hasTurnPassword.value = stream.has_turn_password || false
// 加载 OTG 描述符配置
// Load OTG descriptor config
if (hid.otg_descriptor) {
otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b'
otgProductIdHex.value = hid.otg_descriptor.product_id?.toString(16).padStart(4, '0') || '0104'
@@ -1154,6 +1170,8 @@ async function loadAtxConfig() {
led: { ...config.led },
wol_interface: config.wol_interface || '',
}
clearAtxSerialDeviceConflicts()
syncSharedAtxSerialBaudRate()
} catch (e) {
console.error('Failed to load ATX config:', e)
}
@@ -1171,6 +1189,7 @@ async function saveAtxConfig() {
loading.value = true
saved.value = false
try {
syncSharedAtxSerialBaudRate()
await configStore.updateAtx({
enabled: atxConfig.value.enabled,
power: {
@@ -1185,7 +1204,9 @@ async function saveAtxConfig() {
device: atxConfig.value.reset.device || undefined,
pin: atxConfig.value.reset.pin,
active_level: atxConfig.value.reset.active_level,
baud_rate: atxConfig.value.reset.baud_rate,
baud_rate: isSharedAtxSerialRelay.value
? atxConfig.value.power.baud_rate
: atxConfig.value.reset.baud_rate,
},
led: {
enabled: atxConfig.value.led.enabled,
@@ -1215,6 +1236,55 @@ function getAtxDevicesForDriver(driver: string): string[] {
return []
}
function isAtxSerialDeviceReserved(device: string): boolean {
const reserved = ch9329ReservedSerialDevice.value
return !!reserved && device === reserved
}
function formatAtxDeviceLabel(driver: string, device: string): string {
if (driver === 'serial' && isAtxSerialDeviceReserved(device)) {
return `${device} (CH9329 in use)`
}
return device
}
function clearAtxSerialDeviceConflicts() {
const reserved = ch9329ReservedSerialDevice.value
if (!reserved) return
if (atxConfig.value.power.driver === 'serial' && atxConfig.value.power.device === reserved) {
atxConfig.value.power.device = ''
}
if (atxConfig.value.reset.driver === 'serial' && atxConfig.value.reset.device === reserved) {
atxConfig.value.reset.device = ''
}
}
function syncSharedAtxSerialBaudRate() {
if (!isSharedAtxSerialRelay.value) return
atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate
}
watch(
() => [config.value.hid_backend, config.value.hid_serial_device],
() => {
clearAtxSerialDeviceConflicts()
},
)
watch(
() => [
atxConfig.value.power.driver,
atxConfig.value.power.device,
atxConfig.value.power.baud_rate,
atxConfig.value.reset.driver,
atxConfig.value.reset.device,
],
() => {
syncSharedAtxSerialBaudRate()
},
)
// RustDesk management functions
async function loadRustdeskConfig() {
rustdeskLoading.value = true
@@ -2482,21 +2552,34 @@ watch(() => config.value.hid_backend, async () => {
<option value="none">{{ t('settings.atxDriverNone') }}</option>
<option value="gpio">{{ t('settings.atxDriverGpio') }}</option>
<option value="usbrelay">{{ t('settings.atxDriverUsbRelay') }}</option>
<option value="serial">Serial (LCUS)</option>
<option value="serial">{{ t('settings.atxDriverSerial') }}</option>
</select>
</div>
<div class="space-y-2">
<Label for="power-device">{{ t('settings.atxDevice') }}</Label>
<select id="power-device" v-model="atxConfig.power.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="atxConfig.power.driver === 'none'">
<option value="">{{ t('settings.selectDevice') }}</option>
<option v-for="dev in getAtxDevicesForDriver(atxConfig.power.driver)" :key="dev" :value="dev">{{ dev }}</option>
<option
v-for="dev in getAtxDevicesForDriver(atxConfig.power.driver)"
:key="dev"
:value="dev"
:disabled="atxConfig.power.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
>
{{ formatAtxDeviceLabel(atxConfig.power.driver, dev) }}
</option>
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="power-pin">{{ ['usbrelay', 'serial'].includes(atxConfig.power.driver) ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
<Input id="power-pin" type="number" v-model.number="atxConfig.power.pin" min="0" :disabled="atxConfig.power.driver === 'none'" />
<Input
id="power-pin"
type="number"
v-model.number="atxConfig.power.pin"
:min="atxConfig.power.driver === 'serial' ? 1 : 0"
:disabled="atxConfig.power.driver === 'none'"
/>
</div>
<div v-if="atxConfig.power.driver === 'gpio'" class="space-y-2">
<Label for="power-level">{{ t('settings.atxActiveLevel') }}</Label>
@@ -2533,21 +2616,34 @@ watch(() => config.value.hid_backend, async () => {
<option value="none">{{ t('settings.atxDriverNone') }}</option>
<option value="gpio">{{ t('settings.atxDriverGpio') }}</option>
<option value="usbrelay">{{ t('settings.atxDriverUsbRelay') }}</option>
<option value="serial">Serial (LCUS)</option>
<option value="serial">{{ t('settings.atxDriverSerial') }}</option>
</select>
</div>
<div class="space-y-2">
<Label for="reset-device">{{ t('settings.atxDevice') }}</Label>
<select id="reset-device" v-model="atxConfig.reset.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="atxConfig.reset.driver === 'none'">
<option value="">{{ t('settings.selectDevice') }}</option>
<option v-for="dev in getAtxDevicesForDriver(atxConfig.reset.driver)" :key="dev" :value="dev">{{ dev }}</option>
<option
v-for="dev in getAtxDevicesForDriver(atxConfig.reset.driver)"
:key="dev"
:value="dev"
:disabled="atxConfig.reset.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
>
{{ formatAtxDeviceLabel(atxConfig.reset.driver, dev) }}
</option>
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="reset-pin">{{ ['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
<Input id="reset-pin" type="number" v-model.number="atxConfig.reset.pin" min="0" :disabled="atxConfig.reset.driver === 'none'" />
<Input
id="reset-pin"
type="number"
v-model.number="atxConfig.reset.pin"
:min="atxConfig.reset.driver === 'serial' ? 1 : 0"
:disabled="atxConfig.reset.driver === 'none'"
/>
</div>
<div v-if="atxConfig.reset.driver === 'gpio'" class="space-y-2">
<Label for="reset-level">{{ t('settings.atxActiveLevel') }}</Label>
@@ -2558,13 +2654,21 @@ watch(() => config.value.hid_backend, async () => {
</div>
<div v-if="atxConfig.reset.driver === 'serial'" class="space-y-2">
<Label for="reset-baudrate">{{ t('settings.baudRate') }}</Label>
<select id="reset-baudrate" v-model.number="atxConfig.reset.baud_rate" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<select
id="reset-baudrate"
v-model.number="atxConfig.reset.baud_rate"
class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
:disabled="isSharedAtxSerialRelay"
>
<option :value="9600">9600</option>
<option :value="19200">19200</option>
<option :value="38400">38400</option>
<option :value="57600">57600</option>
<option :value="115200">115200</option>
</select>
<p v-if="isSharedAtxSerialRelay" class="text-xs text-muted-foreground">
{{ t('settings.atxSharedSerialBaudHint') }}
</p>
</div>
</div>
</CardContent>
@@ -3174,7 +3278,7 @@ watch(() => config.value.hid_backend, async () => {
</div>
</div>
<!-- Device Password (直接显示) -->
<!-- Device Password (shown directly) -->
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.devicePassword') }}</Label>
<div class="sm:col-span-3 flex items-center gap-2">