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 std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
@@ -9,12 +8,10 @@ use tokio::time::sleep;
use tracing::{debug, info};
use super::traits::AtxKeyBackend;
use super::types::AtxKeyConfig;
use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::{AppError, Result};
const USB_RELAY_MAX_CHANNEL: u8 = 8;
const USB_RELAY_REPORT_LEN: usize = 9;
const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806;
const LCUS_RELAY_COMMAND_LEN: usize = 4;
pub struct HidrawLinuxRelayBackend {
config: AtxKeyConfig,
@@ -34,13 +31,13 @@ impl HidrawLinuxRelayBackend {
fn validate_config(&self) -> Result<()> {
if self.config.pin == 0 {
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!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
"LCUS HID relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
Ok(())
@@ -49,20 +46,19 @@ impl HidrawLinuxRelayBackend {
fn send_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"USB relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
"LCUS HID relay channel {} exceeds max {}",
self.config.pin, LCUS_RELAY_MAX_CHANNEL
))
})?;
if channel == 0 {
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!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
"LCUS HID relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
@@ -70,44 +66,22 @@ impl HidrawLinuxRelayBackend {
let mut guard = self.handle.lock().unwrap();
let device = guard
.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) {
debug!(
"USB relay feature report failed ({}), falling back to hidraw write",
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
.flush()
.map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?;
}
device
.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("LCUS HID relay write failed: {}", e)))?;
device
.flush()
.map_err(|e| AppError::Internal(format!("LCUS HID relay flush failed: {}", e)))?;
Ok(())
}
pub fn build_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] {
let mut cmd = [0x00; USB_RELAY_REPORT_LEN];
cmd[1] = if on { 0xFF } else { 0xFD };
cmd[2] = channel;
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(())
}
pub fn build_command(channel: u8, on: bool) -> [u8; LCUS_RELAY_COMMAND_LEN] {
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
[0xA0, channel, state, checksum]
}
}
@@ -117,7 +91,7 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
self.validate_config()?;
info!(
"Initializing USB relay ATX backend on {} channel {}",
"Initializing LCUS HID relay ATX backend on {} channel {}",
self.config.device, self.config.pin
);
@@ -125,14 +99,14 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
.read(true)
.write(true)
.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.send_command(false)?;
self.initialized.store(true, Ordering::Relaxed);
debug!(
"USB relay channel {} configured successfully",
"LCUS HID relay channel {} configured successfully",
self.config.pin
);
Ok(())
@@ -140,7 +114,9 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
async fn pulse(&self, duration: Duration) -> Result<()> {
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)?;
@@ -177,14 +153,14 @@ mod tests {
use super::HidrawLinuxRelayBackend;
#[test]
fn usb_relay_command_format() {
fn lcus_hid_relay_command_format() {
assert_eq!(
HidrawLinuxRelayBackend::build_command(1, true),
[0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
[0xA0, 0x01, 0x01, 0xA2]
);
assert_eq!(
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 types::{
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};
#[cfg(any(unix, test))]
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
let upper = uevent.to_ascii_uppercase();
upper.contains("000016C0:000005DF")
|| upper.contains("00005131:00002007")
|| upper.contains("16C0:05DF")
upper.contains("00005131:00002007")
|| upper.contains("5131:2007")
|| upper.contains("PRODUCT=16C0/5DF")
|| upper.contains("PRODUCT=5131/2007")
|| upper.contains("USBRELAY")
|| upper.contains("USB RELAY")
}
#[cfg(unix)]
@@ -94,14 +89,14 @@ mod tests {
}
#[test]
fn test_hidraw_uevent_detects_usb_relay_id() {
assert!(hidraw_uevent_is_usb_relay(
fn test_hidraw_uevent_rejects_non_lcus_usb_relay_id() {
assert!(!hidraw_uevent_is_usb_relay(
"HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n"
));
}
#[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(
"HID_ID=0003:00005131:00002007\n"
));

View File

@@ -7,7 +7,7 @@ use tokio::time::sleep;
use tracing::{debug, info};
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};
pub struct SerialRelayBackend {
@@ -44,15 +44,23 @@ impl SerialRelayBackend {
fn send_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"Serial relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
"LCUS serial relay channel {} exceeds max {}",
self.config.pin, LCUS_RELAY_MAX_CHANNEL
))
})?;
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 checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
let cmd = [0xA0, channel, state, checksum];
let cmd = Self::build_command(channel, on);
let serial_handle = self
.serial_handle
@@ -60,16 +68,22 @@ impl SerialRelayBackend {
.unwrap()
.as_ref()
.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();
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()
.map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?;
.map_err(|e| AppError::Internal(format!("LCUS serial relay flush failed: {}", e)))?;
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]
@@ -78,7 +92,7 @@ impl AtxKeyBackend for SerialRelayBackend {
validate_serial_config(&self.config)?;
info!(
"Initializing Serial relay ATX backend on {} channel {}",
"Initializing LCUS serial relay ATX backend on {} channel {}",
self.config.device, self.config.pin
);
@@ -92,7 +106,7 @@ impl AtxKeyBackend for SerialRelayBackend {
self.initialized.store(true, Ordering::Relaxed);
debug!(
"Serial relay channel {} configured successfully",
"LCUS serial relay channel {} configured successfully",
self.config.pin
);
Ok(())
@@ -101,12 +115,12 @@ impl AtxKeyBackend for SerialRelayBackend {
async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() {
return Err(AppError::Internal(
"Serial relay not initialized".to_string(),
"LCUS serial relay not initialized".to_string(),
));
}
info!(
"Pulse serial relay on {} pin {}",
"Pulse LCUS serial relay on {} channel {}",
self.config.device, self.config.pin
);
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 {
fn drop(&mut self) {
if self.is_initialized() {

View File

@@ -3,7 +3,7 @@ use serialport::SerialPort;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::types::AtxKeyConfig;
use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::Result;
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(),
));
}
if config.pin > u8::MAX as u32 {
if config.pin > LCUS_RELAY_MAX_CHANNEL as u32 {
return Err(crate::error::AppError::Config(format!(
"Serial ATX channel must be <= {}",
u8::MAX
"LCUS serial relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
if config.baud_rate == 0 {

View File

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

View File

@@ -608,19 +608,39 @@ impl AtxConfigUpdate {
}
}
crate::atx::AtxDriverType::UsbRelay => {
Self::validate_device_path(&config.device, "ATX USB relay")?;
Self::validate_output_channel(&config.power, "power", 1, 8)?;
Self::validate_output_channel(&config.reset, "reset", 1, 8)?;
Self::validate_device_path(&config.device, "ATX LCUS HID relay")?;
Self::validate_output_channel(
&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 => {
Self::validate_device_path(&config.device, "ATX serial")?;
Self::validate_device_path(&config.device, "ATX LCUS serial relay")?;
if config.baud_rate == 0 {
return Err(AppError::BadRequest(
"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(&config.reset, "reset", 1, u8::MAX as u32)?;
Self::validate_output_channel(
&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 => {}
};
@@ -1295,6 +1315,27 @@ mod tests {
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]
fn test_atx_validate_with_current_rejects_enabled_none_driver() {
let mut current = AtxConfig::default();