From 251a1e00c4c1c4dfa05b57887305f0c0fef4a899 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 20 Feb 2026 14:11:00 +0800 Subject: [PATCH] feat(atx): merge serial relay support from #223 --- src/atx/executor.rs | 109 ++++++++++++++++++++++++++++++ src/atx/mod.rs | 5 ++ src/atx/types.rs | 33 ++++++++- web/src/components/AtxPopover.vue | 30 +++++--- web/src/types/generated.ts | 8 ++- web/src/views/SettingsView.vue | 33 ++++++++- 6 files changed, 202 insertions(+), 16 deletions(-) diff --git a/src/atx/executor.rs b/src/atx/executor.rs index ffe85973..1324fc4e 100644 --- a/src/atx/executor.rs +++ b/src/atx/executor.rs @@ -4,6 +4,7 @@ //! Each executor handles one button (power or reset) with its own hardware binding. use gpio_cdev::{Chip, LineHandle, LineRequestFlags}; +use serialport::SerialPort; use std::fs::{File, OpenOptions}; use std::io::Write; use std::sync::atomic::{AtomicBool, Ordering}; @@ -38,6 +39,8 @@ pub struct AtxKeyExecutor { gpio_handle: Mutex>, /// Cached USB relay file handle to avoid repeated open/close syscalls usb_relay_handle: Mutex>, + /// Cached Serial port handle + serial_handle: Mutex>>, initialized: AtomicBool, } @@ -48,6 +51,7 @@ impl AtxKeyExecutor { config, gpio_handle: Mutex::new(None), usb_relay_handle: Mutex::new(None), + serial_handle: Mutex::new(None), initialized: AtomicBool::new(false), } } @@ -72,6 +76,7 @@ impl AtxKeyExecutor { match self.config.driver { AtxDriverType::Gpio => self.init_gpio().await?, AtxDriverType::UsbRelay => self.init_usb_relay().await?, + AtxDriverType::Serial => self.init_serial().await?, AtxDriverType::None => {} } @@ -134,6 +139,36 @@ impl AtxKeyExecutor { Ok(()) } + /// Initialize Serial relay backend + async fn init_serial(&self) -> Result<()> { + info!( + "Initializing Serial relay ATX executor on {} channel {}", + self.config.device, self.config.pin + ); + + let baud_rate = if self.config.baud_rate > 0 { + self.config.baud_rate + } else { + 9600 + }; + + let port = serialport::new(&self.config.device, baud_rate) + .timeout(Duration::from_millis(100)) + .open() + .map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?; + + *self.serial_handle.lock().unwrap() = Some(port); + + // Ensure relay is off initially + self.send_serial_relay_command(false)?; + + debug!( + "Serial relay channel {} configured successfully", + self.config.pin + ); + Ok(()) + } + /// Pulse the button for the specified duration pub async fn pulse(&self, duration: Duration) -> Result<()> { if !self.is_configured() { @@ -147,6 +182,7 @@ impl AtxKeyExecutor { match self.config.driver { AtxDriverType::Gpio => self.pulse_gpio(duration).await, AtxDriverType::UsbRelay => self.pulse_usb_relay(duration).await, + AtxDriverType::Serial => self.pulse_serial(duration).await, AtxDriverType::None => Ok(()), } } @@ -220,6 +256,52 @@ impl AtxKeyExecutor { Ok(()) } + /// Pulse Serial relay + async fn pulse_serial(&self, duration: Duration) -> Result<()> { + info!( + "Pulse serial relay on {} pin {}", + self.config.device, self.config.pin + ); + // Turn relay on + self.send_serial_relay_command(true)?; + + // Wait for duration + sleep(duration).await; + + // Turn relay off + self.send_serial_relay_command(false)?; + + Ok(()) + } + + /// 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; + + // LCUS-Type Protocol + // Frame: [StopByte(A0), Channel, State, Checksum] + // Checksum = A0 + channel + state + let state = if on { 1 } else { 0 }; + let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state); + + // Example for Channel 1: + // ON: A0 01 01 A2 + // OFF: A0 01 00 A1 + let cmd = [0xA0, channel, state, checksum]; + + let mut guard = self.serial_handle.lock().unwrap(); + let port = guard + .as_mut() + .ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?; + + port.write_all(&cmd) + .map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?; + + Ok(()) + } + /// Shutdown the executor pub async fn shutdown(&mut self) -> Result<()> { if !self.is_initialized() { @@ -237,6 +319,12 @@ impl AtxKeyExecutor { // Release USB relay handle *self.usb_relay_handle.lock().unwrap() = None; } + AtxDriverType::Serial => { + // Ensure relay is off before closing handle + let _ = self.send_serial_relay_command(false); + // Release Serial relay handle + *self.serial_handle.lock().unwrap() = None; + } AtxDriverType::None => {} } @@ -256,6 +344,12 @@ impl Drop for AtxKeyExecutor { let _ = self.send_usb_relay_command(false); } *self.usb_relay_handle.lock().unwrap() = None; + + // Ensure Serial relay is off and handle released + if self.config.driver == AtxDriverType::Serial && self.is_initialized() { + let _ = self.send_serial_relay_command(false); + } + *self.serial_handle.lock().unwrap() = None; } } @@ -278,6 +372,7 @@ mod tests { device: "/dev/gpiochip0".to_string(), pin: 5, active_level: ActiveLevel::High, + baud_rate: 9600, }; let executor = AtxKeyExecutor::new(config); assert!(executor.is_configured()); @@ -291,6 +386,20 @@ mod tests { device: "/dev/hidraw0".to_string(), pin: 0, active_level: ActiveLevel::High, // Ignored for USB relay + baud_rate: 9600, + }; + let executor = AtxKeyExecutor::new(config); + assert!(executor.is_configured()); + } + + #[test] + fn test_executor_with_serial_config() { + let config = AtxKeyConfig { + driver: AtxDriverType::Serial, + device: "/dev/ttyUSB0".to_string(), + pin: 1, + active_level: ActiveLevel::High, // Ignored + baud_rate: 9600, }; let executor = AtxKeyExecutor::new(config); assert!(executor.is_configured()); diff --git a/src/atx/mod.rs b/src/atx/mod.rs index 1f28a509..0a6b1be4 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -28,12 +28,14 @@ //! device: "/dev/gpiochip0".to_string(), //! pin: 5, //! active_level: ActiveLevel::High, +//! baud_rate: 9600, //! }, //! reset: AtxKeyConfig { //! driver: AtxDriverType::UsbRelay, //! device: "/dev/hidraw0".to_string(), //! pin: 0, //! active_level: ActiveLevel::High, +//! baud_rate: 9600, //! }, //! led: Default::default(), //! }; @@ -72,12 +74,15 @@ pub fn discover_devices() -> AtxDevices { devices.gpio_chips.push(format!("/dev/{}", name_str)); } else if name_str.starts_with("hidraw") { devices.usb_relays.push(format!("/dev/{}", name_str)); + } else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") { + devices.serial_ports.push(format!("/dev/{}", name_str)); } } } devices.gpio_chips.sort(); devices.usb_relays.sort(); + devices.serial_ports.sort(); devices } diff --git a/src/atx/types.rs b/src/atx/types.rs index 4523bdc6..02ef96be 100644 --- a/src/atx/types.rs +++ b/src/atx/types.rs @@ -28,6 +28,8 @@ pub enum AtxDriverType { Gpio, /// USB HID relay module UsbRelay, + /// Serial/COM port relay (taobao LCUS type) + Serial, /// Disabled / Not configured #[default] None, @@ -48,7 +50,7 @@ pub enum ActiveLevel { /// Configuration for a single ATX key (power or reset) /// This is the "four-tuple" configuration: (driver, device, pin/channel, level) #[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct AtxKeyConfig { /// Driver type (GPIO or USB Relay) @@ -60,9 +62,24 @@ pub struct AtxKeyConfig { /// Pin or channel number: /// - For GPIO: GPIO pin number /// - For USB Relay: relay channel (0-based) + /// - For Serial Relay (LCUS): relay channel (1-based) pub pin: u32, /// Active level (only applicable to GPIO, ignored for USB Relay) pub active_level: ActiveLevel, + /// Baud rate for serial relay + pub baud_rate: u32, +} + +impl Default for AtxKeyConfig { + fn default() -> Self { + Self { + driver: AtxDriverType::None, + device: String::new(), + pin: 0, + active_level: ActiveLevel::High, + baud_rate: 9600, + } + } } impl AtxKeyConfig { @@ -130,12 +147,24 @@ pub enum AtxAction { /// Available ATX devices for discovery #[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtxDevices { /// Available GPIO chips (/dev/gpiochip*) pub gpio_chips: Vec, /// Available USB HID relay devices (/dev/hidraw*) pub usb_relays: Vec, + /// Available Serial ports (/dev/ttyUSB* or /dev/ttyACM*) + pub serial_ports: Vec, +} + +impl Default for AtxDevices { + fn default() -> Self { + Self { + gpio_chips: Vec::new(), + usb_relays: Vec::new(), + serial_ports: Vec::new(), + } + } } #[cfg(test)] diff --git a/web/src/components/AtxPopover.vue b/web/src/components/AtxPopover.vue index 2583cbc6..d2d1200c 100644 --- a/web/src/components/AtxPopover.vue +++ b/web/src/components/AtxPopover.vue @@ -34,7 +34,9 @@ const activeTab = ref('atx') // ATX state const powerState = ref<'on' | 'off' | 'unknown'>('unknown') -const confirmAction = ref<'short' | 'long' | 'reset' | null>(null) +// Decouple action data from dialog visibility to prevent race conditions +const pendingAction = ref<'short' | 'long' | 'reset' | null>(null) +const confirmDialogOpen = ref(false) // WOL state const wolMacAddress = ref('') @@ -58,15 +60,21 @@ const powerStateText = computed(() => { } }) +function requestAction(action: 'short' | 'long' | 'reset') { + pendingAction.value = action + confirmDialogOpen.value = true +} + function handleAction() { - if (confirmAction.value === 'short') emit('powerShort') - else if (confirmAction.value === 'long') emit('powerLong') - else if (confirmAction.value === 'reset') emit('reset') - confirmAction.value = null + console.log('[AtxPopover] Confirming action:', pendingAction.value) + if (pendingAction.value === 'short') emit('powerShort') + else if (pendingAction.value === 'long') emit('powerLong') + else if (pendingAction.value === 'reset') emit('reset') + confirmDialogOpen.value = false } const confirmTitle = computed(() => { - switch (confirmAction.value) { + switch (pendingAction.value) { case 'short': return t('atx.confirmShortTitle') case 'long': return t('atx.confirmLongTitle') case 'reset': return t('atx.confirmResetTitle') @@ -75,7 +83,7 @@ const confirmTitle = computed(() => { }) const confirmDescription = computed(() => { - switch (confirmAction.value) { + switch (pendingAction.value) { case 'short': return t('atx.confirmShortDesc') case 'long': return t('atx.confirmLongDesc') case 'reset': return t('atx.confirmResetDesc') @@ -178,7 +186,7 @@ watch( variant="outline" size="sm" class="w-full justify-start gap-2 h-8 text-xs" - @click="confirmAction = 'short'" + @click="requestAction('short')" > {{ t('atx.shortPress') }} @@ -188,7 +196,7 @@ watch( variant="outline" size="sm" class="w-full justify-start gap-2 h-8 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950" - @click="confirmAction = 'long'" + @click="requestAction('long')" > {{ t('atx.longPress') }} @@ -198,7 +206,7 @@ watch( variant="outline" size="sm" class="w-full justify-start gap-2 h-8 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950" - @click="confirmAction = 'reset'" + @click="requestAction('reset')" > {{ t('atx.reset') }} @@ -260,7 +268,7 @@ watch( - + {{ confirmTitle }} diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 5c94fece..364a26ae 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -118,6 +118,8 @@ export enum AtxDriverType { Gpio = "gpio", /** USB HID relay module */ UsbRelay = "usbrelay", + /** Serial/COM port relay (LCUS type) */ + Serial = "serial", /** Disabled / Not configured */ None = "none", } @@ -151,6 +153,8 @@ export interface AtxKeyConfig { pin: number; /** Active level (only applicable to GPIO, ignored for USB Relay) */ active_level: ActiveLevel; + /** Baud rate for serial relay (start with 9600) */ + baud_rate: number; } /** LED sensing configuration (optional) */ @@ -411,6 +415,7 @@ export interface AppConfig { export interface AtxKeyConfigUpdate { driver?: AtxDriverType; device?: string; + baud_rate?: number; pin?: number; active_level?: ActiveLevel; } @@ -439,6 +444,8 @@ export interface AtxConfigUpdate { /** Available ATX devices for discovery */ export interface AtxDevices { /** Available GPIO chips (/dev/gpiochip*) */ + /** Available Serial ports (/dev/ttyUSB*) */ + serial_ports: string[]; gpio_chips: string[]; /** Available USB HID relay devices (/dev/hidraw*) */ usb_relays: string[]; @@ -681,4 +688,3 @@ export interface WebConfigUpdate { bind_address?: string; https_enabled?: boolean; } - diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 8aa1c6c3..95b3f193 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -598,12 +598,14 @@ const atxConfig = ref({ device: '', pin: 0, active_level: 'high' as ActiveLevel, + baud_rate: 9600, }, reset: { driver: 'none' as AtxDriverType, device: '', pin: 0, active_level: 'high' as ActiveLevel, + baud_rate: 9600, }, led: { enabled: false, @@ -618,6 +620,7 @@ const atxConfig = ref({ const atxDevices = ref({ gpio_chips: [], usb_relays: [], + serial_ports: [], }) // Encoder backend @@ -1175,12 +1178,14 @@ async function saveAtxConfig() { device: atxConfig.value.power.device || undefined, pin: atxConfig.value.power.pin, active_level: atxConfig.value.power.active_level, + baud_rate: atxConfig.value.power.baud_rate, }, reset: { driver: atxConfig.value.reset.driver, 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, }, led: { enabled: atxConfig.value.led.enabled, @@ -1202,6 +1207,8 @@ async function saveAtxConfig() { function getAtxDevicesForDriver(driver: string): string[] { if (driver === 'gpio') { return atxDevices.value.gpio_chips + } else if (driver === 'serial') { + return atxDevices.value.serial_ports } else if (driver === 'usbrelay') { return atxDevices.value.usb_relays } @@ -2474,6 +2481,7 @@ watch(() => config.value.hid_backend, async () => { +
@@ -2486,7 +2494,7 @@ watch(() => config.value.hid_backend, async () => {
- +
@@ -2496,6 +2504,16 @@ watch(() => config.value.hid_backend, async () => {
+
+ + +
@@ -2514,6 +2532,7 @@ watch(() => config.value.hid_backend, async () => { +
@@ -2526,7 +2545,7 @@ watch(() => config.value.hid_backend, async () => {
- +
@@ -2536,6 +2555,16 @@ watch(() => config.value.hid_backend, async () => {
+
+ + +