fix: 修复 LCUS HID 继电器协议错误 #263

This commit is contained in:
mofeng-git
2026-07-07 00:29:33 +08:00
parent e60152d38b
commit 4806a5e0a9
6 changed files with 135 additions and 90 deletions

View File

@@ -1,7 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::Write; use std::io::Write;
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
@@ -9,12 +8,10 @@ use tokio::time::sleep;
use tracing::{debug, info}; use tracing::{debug, info};
use super::traits::AtxKeyBackend; use super::traits::AtxKeyBackend;
use super::types::AtxKeyConfig; use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
const USB_RELAY_MAX_CHANNEL: u8 = 8; const LCUS_RELAY_COMMAND_LEN: usize = 4;
const USB_RELAY_REPORT_LEN: usize = 9;
const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806;
pub struct HidrawLinuxRelayBackend { pub struct HidrawLinuxRelayBackend {
config: AtxKeyConfig, config: AtxKeyConfig,
@@ -34,13 +31,13 @@ impl HidrawLinuxRelayBackend {
fn validate_config(&self) -> Result<()> { fn validate_config(&self) -> Result<()> {
if self.config.pin == 0 { if self.config.pin == 0 {
return Err(AppError::Config( return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(), "LCUS HID relay channel must be 1-based (>= 1)".to_string(),
)); ));
} }
if self.config.pin > USB_RELAY_MAX_CHANNEL as u32 { if self.config.pin > LCUS_RELAY_MAX_CHANNEL as u32 {
return Err(AppError::Config(format!( return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}", "LCUS HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL LCUS_RELAY_MAX_CHANNEL
))); )));
} }
Ok(()) Ok(())
@@ -49,20 +46,19 @@ impl HidrawLinuxRelayBackend {
fn send_command(&self, on: bool) -> Result<()> { fn send_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| { let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!( AppError::Config(format!(
"USB relay channel {} exceeds max {}", "LCUS HID relay channel {} exceeds max {}",
self.config.pin, self.config.pin, LCUS_RELAY_MAX_CHANNEL
u8::MAX
)) ))
})?; })?;
if channel == 0 { if channel == 0 {
return Err(AppError::Config( return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(), "LCUS HID relay channel must be 1-based (>= 1)".to_string(),
)); ));
} }
if channel > USB_RELAY_MAX_CHANNEL { if channel > LCUS_RELAY_MAX_CHANNEL {
return Err(AppError::Config(format!( return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}", "LCUS HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL LCUS_RELAY_MAX_CHANNEL
))); )));
} }
@@ -70,44 +66,22 @@ impl HidrawLinuxRelayBackend {
let mut guard = self.handle.lock().unwrap(); let mut guard = self.handle.lock().unwrap();
let device = guard let device = guard
.as_mut() .as_mut()
.ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?; .ok_or_else(|| AppError::Internal("LCUS HID relay not initialized".to_string()))?;
if let Err(feature_err) = Self::send_feature_report(device, &cmd) { device
debug!( .write_all(&cmd)
"USB relay feature report failed ({}), falling back to hidraw write", .map_err(|e| AppError::Internal(format!("LCUS HID relay write failed: {}", e)))?;
feature_err
);
device.write_all(&cmd).map_err(|write_err| {
AppError::Internal(format!(
"USB relay feature report failed: {}; raw write failed: {}",
feature_err, write_err
))
})?;
device device
.flush() .flush()
.map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("LCUS HID relay flush failed: {}", e)))?;
}
Ok(()) Ok(())
} }
pub fn build_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] { pub fn build_command(channel: u8, on: bool) -> [u8; LCUS_RELAY_COMMAND_LEN] {
let mut cmd = [0x00; USB_RELAY_REPORT_LEN]; let state = if on { 1 } else { 0 };
cmd[1] = if on { 0xFF } else { 0xFD }; let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
cmd[2] = channel; [0xA0, channel, state, checksum]
cmd
}
fn send_feature_report(
device: &File,
report: &[u8; USB_RELAY_REPORT_LEN],
) -> std::io::Result<()> {
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9 as _, report.as_ptr()) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
} }
} }
@@ -117,7 +91,7 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
self.validate_config()?; self.validate_config()?;
info!( info!(
"Initializing USB relay ATX backend on {} channel {}", "Initializing LCUS HID relay ATX backend on {} channel {}",
self.config.device, self.config.pin self.config.device, self.config.pin
); );
@@ -125,14 +99,14 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
.read(true) .read(true)
.write(true) .write(true)
.open(&self.config.device) .open(&self.config.device)
.map_err(|e| AppError::Internal(format!("USB relay device open failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("LCUS HID relay device open failed: {}", e)))?;
*self.handle.lock().unwrap() = Some(device); *self.handle.lock().unwrap() = Some(device);
self.send_command(false)?; self.send_command(false)?;
self.initialized.store(true, Ordering::Relaxed); self.initialized.store(true, Ordering::Relaxed);
debug!( debug!(
"USB relay channel {} configured successfully", "LCUS HID relay channel {} configured successfully",
self.config.pin self.config.pin
); );
Ok(()) Ok(())
@@ -140,7 +114,9 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
async fn pulse(&self, duration: Duration) -> Result<()> { async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() { if !self.is_initialized() {
return Err(AppError::Internal("USB relay not initialized".to_string())); return Err(AppError::Internal(
"LCUS HID relay not initialized".to_string(),
));
} }
self.send_command(true)?; self.send_command(true)?;
@@ -177,14 +153,14 @@ mod tests {
use super::HidrawLinuxRelayBackend; use super::HidrawLinuxRelayBackend;
#[test] #[test]
fn usb_relay_command_format() { fn lcus_hid_relay_command_format() {
assert_eq!( assert_eq!(
HidrawLinuxRelayBackend::build_command(1, true), HidrawLinuxRelayBackend::build_command(1, true),
[0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] [0xA0, 0x01, 0x01, 0xA2]
); );
assert_eq!( assert_eq!(
HidrawLinuxRelayBackend::build_command(1, false), HidrawLinuxRelayBackend::build_command(1, false),
[0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] [0xA0, 0x01, 0x00, 0xA1]
); );
} }
} }

View File

@@ -25,21 +25,16 @@ pub use controller::{AtxController, AtxControllerConfig};
pub use executor::timing; pub use executor::timing;
pub use types::{ pub use types::{
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig, ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig,
AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL,
}; };
pub use wol::{list_wol_history, record_wol_history, send_wol}; pub use wol::{list_wol_history, record_wol_history, send_wol};
#[cfg(any(unix, test))] #[cfg(any(unix, test))]
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool { fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
let upper = uevent.to_ascii_uppercase(); let upper = uevent.to_ascii_uppercase();
upper.contains("000016C0:000005DF") upper.contains("00005131:00002007")
|| upper.contains("00005131:00002007")
|| upper.contains("16C0:05DF")
|| upper.contains("5131:2007") || upper.contains("5131:2007")
|| upper.contains("PRODUCT=16C0/5DF")
|| upper.contains("PRODUCT=5131/2007") || upper.contains("PRODUCT=5131/2007")
|| upper.contains("USBRELAY")
|| upper.contains("USB RELAY")
} }
#[cfg(unix)] #[cfg(unix)]
@@ -94,14 +89,14 @@ mod tests {
} }
#[test] #[test]
fn test_hidraw_uevent_detects_usb_relay_id() { fn test_hidraw_uevent_rejects_non_lcus_usb_relay_id() {
assert!(hidraw_uevent_is_usb_relay( assert!(!hidraw_uevent_is_usb_relay(
"HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n" "HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n"
)); ));
} }
#[test] #[test]
fn test_hidraw_uevent_detects_5131_usb_relay_id() { fn test_hidraw_uevent_detects_lcus_hid_relay_id() {
assert!(hidraw_uevent_is_usb_relay( assert!(hidraw_uevent_is_usb_relay(
"HID_ID=0003:00005131:00002007\n" "HID_ID=0003:00005131:00002007\n"
)); ));

View File

@@ -7,7 +7,7 @@ use tokio::time::sleep;
use tracing::{debug, info}; use tracing::{debug, info};
use super::traits::{validate_serial_config, AtxKeyBackend, SharedSerialHandle}; use super::traits::{validate_serial_config, AtxKeyBackend, SharedSerialHandle};
use super::types::AtxKeyConfig; use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
pub struct SerialRelayBackend { pub struct SerialRelayBackend {
@@ -44,15 +44,23 @@ impl SerialRelayBackend {
fn send_command(&self, on: bool) -> Result<()> { fn send_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| { let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!( AppError::Config(format!(
"Serial relay channel {} exceeds max {}", "LCUS serial relay channel {} exceeds max {}",
self.config.pin, self.config.pin, LCUS_RELAY_MAX_CHANNEL
u8::MAX
)) ))
})?; })?;
if channel == 0 {
return Err(AppError::Config(
"LCUS serial relay channel must be 1-based (>= 1)".to_string(),
));
}
if channel > LCUS_RELAY_MAX_CHANNEL {
return Err(AppError::Config(format!(
"LCUS serial relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
let state = if on { 1 } else { 0 }; let cmd = Self::build_command(channel, on);
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
let cmd = [0xA0, channel, state, checksum];
let serial_handle = self let serial_handle = self
.serial_handle .serial_handle
@@ -60,16 +68,22 @@ impl SerialRelayBackend {
.unwrap() .unwrap()
.as_ref() .as_ref()
.cloned() .cloned()
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?; .ok_or_else(|| AppError::Internal("LCUS serial relay not initialized".to_string()))?;
let mut port = serial_handle.lock().unwrap(); let mut port = serial_handle.lock().unwrap();
port.write_all(&cmd) port.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("LCUS serial relay write failed: {}", e)))?;
port.flush() port.flush()
.map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("LCUS serial relay flush failed: {}", e)))?;
Ok(()) Ok(())
} }
pub fn build_command(channel: u8, on: bool) -> [u8; 4] {
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
[0xA0, channel, state, checksum]
}
} }
#[async_trait] #[async_trait]
@@ -78,7 +92,7 @@ impl AtxKeyBackend for SerialRelayBackend {
validate_serial_config(&self.config)?; validate_serial_config(&self.config)?;
info!( info!(
"Initializing Serial relay ATX backend on {} channel {}", "Initializing LCUS serial relay ATX backend on {} channel {}",
self.config.device, self.config.pin self.config.device, self.config.pin
); );
@@ -92,7 +106,7 @@ impl AtxKeyBackend for SerialRelayBackend {
self.initialized.store(true, Ordering::Relaxed); self.initialized.store(true, Ordering::Relaxed);
debug!( debug!(
"Serial relay channel {} configured successfully", "LCUS serial relay channel {} configured successfully",
self.config.pin self.config.pin
); );
Ok(()) Ok(())
@@ -101,12 +115,12 @@ impl AtxKeyBackend for SerialRelayBackend {
async fn pulse(&self, duration: Duration) -> Result<()> { async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() { if !self.is_initialized() {
return Err(AppError::Internal( return Err(AppError::Internal(
"Serial relay not initialized".to_string(), "LCUS serial relay not initialized".to_string(),
)); ));
} }
info!( info!(
"Pulse serial relay on {} pin {}", "Pulse LCUS serial relay on {} channel {}",
self.config.device, self.config.pin self.config.device, self.config.pin
); );
self.send_command(true)?; self.send_command(true)?;
@@ -131,6 +145,23 @@ impl AtxKeyBackend for SerialRelayBackend {
} }
} }
#[cfg(test)]
mod tests {
use super::SerialRelayBackend;
#[test]
fn lcus_serial_relay_command_format() {
assert_eq!(
SerialRelayBackend::build_command(1, true),
[0xA0, 0x01, 0x01, 0xA2]
);
assert_eq!(
SerialRelayBackend::build_command(1, false),
[0xA0, 0x01, 0x00, 0xA1]
);
}
}
impl Drop for SerialRelayBackend { impl Drop for SerialRelayBackend {
fn drop(&mut self) { fn drop(&mut self) {
if self.is_initialized() { if self.is_initialized() {

View File

@@ -3,7 +3,7 @@ use serialport::SerialPort;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use super::types::AtxKeyConfig; use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::Result; use crate::error::Result;
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>; pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
@@ -36,10 +36,10 @@ pub fn validate_serial_config(config: &AtxKeyConfig) -> Result<()> {
"Serial ATX channel must be 1-based (>= 1)".to_string(), "Serial ATX channel must be 1-based (>= 1)".to_string(),
)); ));
} }
if config.pin > u8::MAX as u32 { if config.pin > LCUS_RELAY_MAX_CHANNEL as u32 {
return Err(crate::error::AppError::Config(format!( return Err(crate::error::AppError::Config(format!(
"Serial ATX channel must be <= {}", "LCUS serial relay channel must be <= {}",
u8::MAX LCUS_RELAY_MAX_CHANNEL
))); )));
} }
if config.baud_rate == 0 { if config.baud_rate == 0 {

View File

@@ -5,6 +5,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typeshare::typeshare; use typeshare::typeshare;
pub const LCUS_RELAY_MAX_CHANNEL: u8 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum PowerStatus { pub enum PowerStatus {

View File

@@ -608,19 +608,39 @@ impl AtxConfigUpdate {
} }
} }
crate::atx::AtxDriverType::UsbRelay => { crate::atx::AtxDriverType::UsbRelay => {
Self::validate_device_path(&config.device, "ATX USB relay")?; Self::validate_device_path(&config.device, "ATX LCUS HID relay")?;
Self::validate_output_channel(&config.power, "power", 1, 8)?; Self::validate_output_channel(
Self::validate_output_channel(&config.reset, "reset", 1, 8)?; &config.power,
"power",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
Self::validate_output_channel(
&config.reset,
"reset",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
} }
crate::atx::AtxDriverType::Serial => { crate::atx::AtxDriverType::Serial => {
Self::validate_device_path(&config.device, "ATX serial")?; Self::validate_device_path(&config.device, "ATX LCUS serial relay")?;
if config.baud_rate == 0 { if config.baud_rate == 0 {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"ATX baud_rate must be greater than 0".to_string(), "ATX baud_rate must be greater than 0".to_string(),
)); ));
} }
Self::validate_output_channel(&config.power, "power", 1, u8::MAX as u32)?; Self::validate_output_channel(
Self::validate_output_channel(&config.reset, "reset", 1, u8::MAX as u32)?; &config.power,
"power",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
Self::validate_output_channel(
&config.reset,
"reset",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
} }
crate::atx::AtxDriverType::None => {} crate::atx::AtxDriverType::None => {}
}; };
@@ -1295,6 +1315,27 @@ mod tests {
assert!(update.validate_with_current(&current).is_err()); assert!(update.validate_with_current(&current).is_err());
} }
#[test]
fn test_atx_validate_with_current_rejects_lcus_serial_channel_over_8() {
let mut current = AtxConfig::default();
current.enabled = true;
current.driver = crate::atx::AtxDriverType::Serial;
current.device = "/dev/null".to_string();
current.power.enabled = true;
current.power.pin = 1;
current.baud_rate = 9600;
let mut update = empty_atx_update();
update.power = Some(AtxOutputBindingUpdate {
enabled: None,
device: None,
pin: Some(9),
active_level: None,
});
assert!(update.validate_with_current(&current).is_err());
}
#[test] #[test]
fn test_atx_validate_with_current_rejects_enabled_none_driver() { fn test_atx_validate_with_current_rejects_enabled_none_driver() {
let mut current = AtxConfig::default(); let mut current = AtxConfig::default();