diff --git a/src/atx/executor.rs b/src/atx/executor.rs index ffe85973..80ddab3d 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,42 @@ impl AtxKeyExecutor { Ok(()) } + /// Pulse Serial relay + async fn pulse_serial(&self, duration: Duration) -> Result<()> { + // 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<()> { + let channel = self.config.pin as u8; + + // LCUS-Type Protocol + // Checksum = A0 + channel + state + let state = if on { 1 } else { 0 }; + let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state); + + 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 +309,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 +334,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 +362,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 +376,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 dd7c90b4..f35eae52 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -72,12 +72,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 } @@ -92,6 +95,7 @@ mod tests { // Just verify the function runs without error assert!(devices.gpio_chips.len() >= 0); assert!(devices.usb_relays.len() >= 0); + assert!(devices.serial_ports.len() >= 0); } #[test] diff --git a/src/atx/types.rs b/src/atx/types.rs index cea0e176..e0cc0848 100644 --- a/src/atx/types.rs +++ b/src/atx/types.rs @@ -33,6 +33,8 @@ pub enum AtxDriverType { Gpio, /// USB HID relay module UsbRelay, + /// Serial/COM port relay (LCUS type) + Serial, /// Disabled / Not configured None, } @@ -78,6 +80,8 @@ pub struct AtxKeyConfig { pub pin: u32, /// Active level (only applicable to GPIO, ignored for USB Relay) pub active_level: ActiveLevel, + /// Baud rate for serial relay (start with 9600) + pub baud_rate: u32, } impl Default for AtxKeyConfig { @@ -87,6 +91,7 @@ impl Default for AtxKeyConfig { device: String::new(), pin: 0, active_level: ActiveLevel::High, + baud_rate: 9600, } } } @@ -185,6 +190,8 @@ pub struct AtxDevices { pub gpio_chips: Vec, /// Available USB HID relay devices (/dev/hidraw*) pub usb_relays: Vec, + /// Available Serial ports (/dev/ttyUSB*) + pub serial_ports: Vec, } impl Default for AtxDevices { @@ -192,6 +199,7 @@ impl Default for AtxDevices { Self { gpio_chips: Vec::new(), usb_relays: Vec::new(), + serial_ports: Vec::new(), } } } diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index b2fd535a..2fb4ab02 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) */ @@ -387,6 +391,7 @@ export interface AppConfig { export interface AtxKeyConfigUpdate { driver?: AtxDriverType; device?: string; + baud_rate?: number; pin?: number; active_level?: ActiveLevel; } @@ -415,6 +420,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[]; diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index c18c67db..b42eb114 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -350,12 +350,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, @@ -370,6 +372,7 @@ const atxConfig = ref({ const atxDevices = ref({ gpio_chips: [], usb_relays: [], + serial_ports: [], }) // Encoder backend @@ -927,13 +930,15 @@ async function saveAtxConfig() { driver: atxConfig.value.power.driver, 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 + active_level: atxConfig.value.reset.active_level, }, led: { enabled: atxConfig.value.led.enabled, @@ -955,6 +960,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 } @@ -1906,6 +1913,7 @@ onMounted(async () => { +
@@ -1918,7 +1926,7 @@ onMounted(async () => {
- +
@@ -1928,6 +1936,16 @@ onMounted(async () => {
+
+ + +
@@ -1946,6 +1964,7 @@ onMounted(async () => { +
@@ -1958,7 +1977,7 @@ onMounted(async () => {
- +
@@ -1968,6 +1987,16 @@ onMounted(async () => {
+
+ + +