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:
2026-01-10 16:04:42 +01:00
parent 1842ca25e3
commit ed1485cc16
7 changed files with 1088 additions and 41 deletions

View File

@@ -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"));
}
}

View File

@@ -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
);
}
}
}

View File

@@ -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}"
);
}
}
}