feat(infrastructure): implement ModbusRelayController with timeout handling
Add real Modbus TCP communication through ModbusRelayController: - T025a: Connection setup with Arc<Mutex<Context>> and configurable timeout - T025b: read_coils_with_timeout() helper wrapping tokio::time::timeout - T025c: write_single_coil_with_timeout() with nested Result handling - T025d: RelayController::read_relay_state() using timeout helper - T025e: RelayController::write_relay_state() with state conversion - Additional: Complete RelayController trait with all required methods - Domain support: RelayId::to_modbus_address(), RelayState conversion helpers Implements hexagonal architecture with infrastructure layer properly depending on domain types. Includes structured logging at key operations. TDD phase: green (implementation following test stubs from T023-T024) Ref: T025a-T025e (specs/001-modbus-relay-control/tasks.md)
This commit is contained in:
@@ -17,9 +17,15 @@ pub enum ControllerError {
|
||||
/// Attempted to access a relay with an invalid ID (valid range: 1-8).
|
||||
#[error("Invalid relay ID: {0}")]
|
||||
InvalidRelayId(u8),
|
||||
/// Invalid input parameters provided to controller operation.
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, ControllerError>;
|
||||
/// Result type alias for relay controller operations.
|
||||
///
|
||||
/// Convenience type that uses `ControllerError` as the error type.
|
||||
pub type Result<T> = std::result::Result<T, ControllerError>;
|
||||
|
||||
/// Abstraction for controlling Modbus-connected relays.
|
||||
///
|
||||
@@ -69,10 +75,10 @@ pub trait RelayController: Send + Sync {
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ControllerError` if:
|
||||
/// - Connection to Modbus device fails
|
||||
/// - Operation times out
|
||||
/// - Modbus protocol exception occurs
|
||||
/// - States vector length is invalid
|
||||
/// - States vector does not contain exactly 8 elements (`InvalidInput`)
|
||||
/// - Connection to Modbus device fails (`ConnectionError`)
|
||||
/// - Operation times out (`Timeout`)
|
||||
/// - Modbus protocol exception occurs (`ModbusException`)
|
||||
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<()>;
|
||||
|
||||
/// Checks if the connection to the Modbus device is active.
|
||||
@@ -94,3 +100,53 @@ pub trait RelayController: Send + Sync {
|
||||
/// - Modbus protocol exception occurs
|
||||
async fn get_firmware_version(&self) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_connection_error_display() {
|
||||
let error = ControllerError::ConnectionError("Failed to connect".to_string());
|
||||
assert_eq!(error.to_string(), "Connection error: Failed to connect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_timeout_display() {
|
||||
let error = ControllerError::Timeout(5);
|
||||
assert_eq!(error.to_string(), "Timeout after 5 seconds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_modbus_exception_display() {
|
||||
let error = ControllerError::ModbusException("Illegal function".to_string());
|
||||
assert_eq!(error.to_string(), "Modbus exception: Illegal function");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_invalid_relay_id_display() {
|
||||
let error = ControllerError::InvalidRelayId(9);
|
||||
assert_eq!(error.to_string(), "Invalid relay ID: 9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_invalid_input_display() {
|
||||
let error = ControllerError::InvalidInput("Expected 8 states, got 7".to_string());
|
||||
assert_eq!(error.to_string(), "Invalid input: Expected 8 states, got 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_is_error_trait() {
|
||||
// Verify ControllerError implements std::error::Error trait
|
||||
fn assert_error<T: std::error::Error>() {}
|
||||
assert_error::<ControllerError>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_error_debug_format() {
|
||||
let error = ControllerError::InvalidInput("test".to_string());
|
||||
let debug_str = format!("{error:?}");
|
||||
assert!(debug_str.contains("InvalidInput"));
|
||||
assert!(debug_str.contains("test"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ impl RelayId {
|
||||
pub const fn as_u8(&self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Converts user-facing ID (1-8) to Modbus address (0-7)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use sta::domain::relay::types::RelayId;
|
||||
/// let relay_1 = RelayId::new(1).unwrap();
|
||||
/// assert_eq!(relay_1.to_modbus_address(), 0);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn to_modbus_address(self) -> u16 {
|
||||
u16::from(self.0 - 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RelayId {
|
||||
@@ -81,4 +95,32 @@ mod tests {
|
||||
let relay_id = RelayId(5);
|
||||
assert_eq!(relay_id.as_u8(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_modbus_address_relay_1_maps_to_0() {
|
||||
// Test: RelayId(1) → Modbus address 0
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
assert_eq!(relay_id.to_modbus_address(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_modbus_address_relay_8_maps_to_7() {
|
||||
// Test: RelayId(8) → Modbus address 7
|
||||
let relay_id = RelayId::new(8).unwrap();
|
||||
assert_eq!(relay_id.to_modbus_address(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_modbus_address_all_relays_map_correctly() {
|
||||
// Test: All relay IDs (1-8) map to correct Modbus addresses (0-7)
|
||||
for id in 1..=8 {
|
||||
let relay_id = RelayId::new(id).unwrap();
|
||||
assert_eq!(
|
||||
relay_id.to_modbus_address(),
|
||||
u16::from(id - 1),
|
||||
"RelayId({id}) should map to Modbus address {}",
|
||||
id - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,31 @@ pub enum RelayState {
|
||||
Off,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
/// Toggles the relay state (On → Off, Off → On).
|
||||
///
|
||||
/// Returns the opposite state without modifying the original value.
|
||||
#[must_use]
|
||||
pub const fn toggle(self) -> Self {
|
||||
match self {
|
||||
Self::On => Self::Off,
|
||||
Self::Off => Self::On,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RelayState> for bool {
|
||||
fn from(state: RelayState) -> Self {
|
||||
state == RelayState::On
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for RelayState {
|
||||
fn from(value: bool) -> Self {
|
||||
if value { Self::On } else { Self::Off }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -51,4 +76,82 @@ mod tests {
|
||||
let result: Result<RelayState, _> = serde_json::from_str(r#""invalid""#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_on_returns_off() {
|
||||
// Test: RelayState::On.toggle() → Off
|
||||
assert_eq!(RelayState::On.toggle(), RelayState::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_off_returns_on() {
|
||||
// Test: RelayState::Off.toggle() → On
|
||||
assert_eq!(RelayState::Off.toggle(), RelayState::On);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_idempotency() {
|
||||
// Test: state.toggle().toggle() == state
|
||||
assert_eq!(RelayState::On.toggle().toggle(), RelayState::On);
|
||||
assert_eq!(RelayState::Off.toggle().toggle(), RelayState::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_bool_true_returns_on() {
|
||||
// Test: bool::from(true) → RelayState::On
|
||||
assert_eq!(RelayState::from(true), RelayState::On);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_bool_false_returns_off() {
|
||||
// Test: bool::from(false) → RelayState::Off
|
||||
assert_eq!(RelayState::from(false), RelayState::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_bool_on_returns_true() {
|
||||
// Test: RelayState::On.into() → true
|
||||
let b: bool = RelayState::On.into();
|
||||
assert!(b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_bool_off_returns_false() {
|
||||
// Test: RelayState::Off.into() → false
|
||||
let b: bool = RelayState::Off.into();
|
||||
assert!(!b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_bool_to_relay_state() {
|
||||
// Test: Roundtrip conversion maintains state
|
||||
// RelayState::from(bool::from(state)) == state
|
||||
assert_eq!(RelayState::from(bool::from(RelayState::On)), RelayState::On);
|
||||
assert_eq!(
|
||||
RelayState::from(bool::from(RelayState::Off)),
|
||||
RelayState::Off
|
||||
);
|
||||
|
||||
// Also verify the inverse: state == RelayState::from(bool::from(state))
|
||||
for &state in &[RelayState::On, RelayState::Off] {
|
||||
assert_eq!(
|
||||
RelayState::from(bool::from(state)),
|
||||
state,
|
||||
"Roundtrip failed for state {state:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_relay_state_to_bool() {
|
||||
// Test: Inverse roundtrip for all bool values
|
||||
// bool::from(RelayState::from(bool)) == bool
|
||||
for &bool_val in &[true, false] {
|
||||
assert_eq!(
|
||||
bool::from(RelayState::from(bool_val)),
|
||||
bool_val,
|
||||
"Roundtrip failed for bool {bool_val}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user