feat(domain): implement RelayController trait and error handling

Add RelayController async trait (T030) defining interface for Modbus
relay operations with methods for read/write state, bulk operations,
connection checks, and firmware queries.

Implement ControllerError enum (T031) with variants for connection
failures, timeouts, Modbus exceptions, and invalid relay IDs.

Provide MockRelayController (T029) in-memory implementation using
Arc<Mutex<HashMap>> for thread-safe state storage with timeout
simulation for error handling tests.

Add RelayLabelRepository trait abstraction.

Ref: T029 T030 T031 (specs/001-modbus-relay-control/tasks.md)
This commit is contained in:
2026-01-10 12:56:22 +01:00
parent 3ae760f32e
commit 215e8d552a
5 changed files with 258 additions and 6 deletions

View File

@@ -1,7 +1,96 @@
use async_trait::async_trait;
use super::types::{RelayId, RelayState};
/// Errors that can occur during relay controller operations. /// Errors that can occur during relay controller operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ControllerError { pub enum ControllerError {
/// The provided relay ID is outside the valid range (1-8). /// Failed to establish or maintain connection to the Modbus device.
#[error("Connection error: {0}")]
ConnectionError(String),
/// Operation exceeded the specified timeout duration (in seconds).
#[error("Timeout after {0} seconds")]
Timeout(u64),
/// Modbus protocol exception occurred during communication.
#[error("Modbus exception: {0}")]
ModbusException(String),
/// Attempted to access a relay with an invalid ID (valid range: 1-8).
#[error("Invalid relay ID: {0}")] #[error("Invalid relay ID: {0}")]
InvalidRelayId(u8), InvalidRelayId(u8),
} }
type Result<T> = std::result::Result<T, ControllerError>;
/// Abstraction for controlling Modbus-connected relays.
///
/// This trait defines the interface for reading and writing relay states,
/// supporting both individual relay operations and bulk operations for all 8 relays.
/// Implementations must be thread-safe (`Send + Sync`) for use in async contexts.
#[async_trait]
pub trait RelayController: Send + Sync {
/// Reads the current state of a single relay.
///
/// # Errors
///
/// Returns `ControllerError` if:
/// - Connection to Modbus device fails
/// - Operation times out
/// - Modbus protocol exception occurs
/// - Relay ID is invalid
async fn read_relay_state(&self, id: RelayId) -> Result<RelayState>;
/// Writes a new state to a single relay.
///
/// # Errors
///
/// Returns `ControllerError` if:
/// - Connection to Modbus device fails
/// - Operation times out
/// - Modbus protocol exception occurs
/// - Relay ID is invalid
async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<()>;
/// Reads the states of all 8 relays.
///
/// Returns a vector of relay states ordered by relay ID (1-8).
///
/// # Errors
///
/// Returns `ControllerError` if:
/// - Connection to Modbus device fails
/// - Operation times out
/// - Modbus protocol exception occurs
async fn read_all_states(&self) -> Result<Vec<RelayState>>;
/// Writes states to all 8 relays in a single operation.
///
/// The states vector must contain exactly 8 elements, corresponding to relays 1-8.
///
/// # Errors
///
/// Returns `ControllerError` if:
/// - Connection to Modbus device fails
/// - Operation times out
/// - Modbus protocol exception occurs
/// - States vector length is invalid
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<()>;
/// Checks if the connection to the Modbus device is active.
///
/// # Errors
///
/// Returns `ControllerError` if the connection check fails.
async fn check_connection(&self) -> Result<()>;
/// Retrieves the firmware version of the Modbus device, if available.
///
/// Returns `None` if the device does not support firmware version reporting.
///
/// # Errors
///
/// Returns `ControllerError` if:
/// - Connection to Modbus device fails
/// - Operation times out
/// - Modbus protocol exception occurs
async fn get_firmware_version(&self) -> Result<Option<String>>;
}

View File

@@ -1,4 +1,6 @@
use super::types::RelayId; use async_trait::async_trait;
use super::types::{RelayId, RelayLabel};
/// Errors that can occur during repository operations. /// Errors that can occur during repository operations.
/// ///
@@ -13,3 +15,39 @@ pub enum RepositoryError {
#[error("Relay not found: {0}")] #[error("Relay not found: {0}")]
NotFound(RelayId), NotFound(RelayId),
} }
/// Repository trait for persisting and retrieving relay labels.
///
/// This trait abstracts data persistence operations for relay labels,
/// enabling different storage implementations (e.g., `SQLite`, `PostgreSQL`, in-memory).
/// Implementations must be thread-safe (`Send + Sync`) for use in async contexts.
#[async_trait]
pub trait RelayLabelRepository: Send + Sync {
/// Retrieves the label for a specific relay.
///
/// Returns `None` if no label has been set for the relay.
///
/// # Errors
///
/// Returns `RepositoryError::DatabaseError` if the database operation fails.
async fn get_label(&self, id: RelayId) -> Result<Option<RelayLabel>, RepositoryError>;
/// Saves or updates the label for a specific relay.
///
/// If a label already exists for the relay, it will be overwritten.
///
/// # Errors
///
/// Returns `RepositoryError::DatabaseError` if the database operation fails.
async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError>;
/// Retrieves all relay labels from the repository.
///
/// Returns a vector of tuples containing relay IDs and their corresponding labels.
/// Relays without labels are not included in the result.
///
/// # Errors
///
/// Returns `RepositoryError::DatabaseError` if the database operation fails.
async fn get_all_labels(&self) -> Result<Vec<(RelayId, RelayLabel)>, RepositoryError>;
}

View File

@@ -5,7 +5,7 @@ use crate::domain::relay::controller::ControllerError;
/// Uses the newtype pattern to provide type safety and prevent mixing relay IDs /// Uses the newtype pattern to provide type safety and prevent mixing relay IDs
/// with other numeric values. Valid values range from 0-255, corresponding to /// with other numeric values. Valid values range from 0-255, corresponding to
/// individual relay channels in the Modbus controller. /// individual relay channels in the Modbus controller.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
#[repr(transparent)] #[repr(transparent)]
pub struct RelayId(u8); pub struct RelayId(u8);

View File

@@ -3,6 +3,131 @@
//! This module provides a mock implementation of the relay controller //! This module provides a mock implementation of the relay controller
//! that stores state in memory, enabling testing without physical Modbus hardware. //! that stores state in memory, enabling testing without physical Modbus hardware.
use std::{
collections::HashMap,
sync::{Arc, Mutex, MutexGuard},
};
use async_trait::async_trait;
use crate::domain::relay::{
controller::{ControllerError, RelayController},
types::{RelayId, RelayState},
};
/// Mock relay controller for testing without physical Modbus hardware.
///
/// This implementation stores relay states in memory using a thread-safe
/// `Arc<Mutex<HashMap>>`, enabling concurrent access in tests. It provides
/// optional timeout simulation for testing error handling scenarios.
#[derive(Debug, Clone)]
pub struct MockRelayController {
states: Arc<Mutex<HashMap<RelayId, RelayState>>>,
firmware_version: Option<String>,
simulate_timeout: bool,
}
impl MockRelayController {
/// Creates a new mock relay controller with default configuration.
///
/// The controller initializes with:
/// - Empty relay state map (no relays configured)
/// - Firmware version set to "v2.00"
/// - Timeout simulation disabled
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Enables timeout simulation for testing error handling.
///
/// When enabled, all operations will simulate a 4-second delay followed
/// by a timeout error. This is useful for testing timeout handling in
/// application code.
#[must_use]
pub const fn with_timeout_simulation(mut self) -> Self {
self.simulate_timeout = true;
self
}
async fn maybe_timeout(&self) -> Result<(), ControllerError> {
if self.simulate_timeout {
tokio::time::sleep(tokio::time::Duration::from_secs(4)).await;
Err(ControllerError::Timeout(3))
} else {
Ok(())
}
}
fn states(&self) -> Result<MutexGuard<'_, HashMap<RelayId, RelayState>>, ControllerError> {
self.states
.lock()
.map_err(|e| ControllerError::ModbusException(e.to_string()))
}
}
impl Default for MockRelayController {
fn default() -> Self {
let hashmap: HashMap<RelayId, RelayState> = HashMap::new();
Self {
states: Arc::new(Mutex::new(hashmap)),
firmware_version: Some("v2.00".to_string()),
simulate_timeout: false,
}
}
}
#[async_trait]
impl RelayController for MockRelayController {
async fn read_relay_state(&self, id: RelayId) -> Result<RelayState, ControllerError> {
self.maybe_timeout().await?;
let states = self.states()?;
states.get(&id).map_or_else(
|| Err(ControllerError::InvalidRelayId(id.as_u8())),
|state| Ok(*state),
)
}
async fn write_relay_state(
&self,
id: RelayId,
state: RelayState,
) -> Result<(), ControllerError> {
self.maybe_timeout().await?;
self.states()?.insert(id, state);
Ok(())
}
async fn read_all_states(&self) -> Result<Vec<RelayState>, ControllerError> {
self.maybe_timeout().await?;
let mut vec: Vec<(RelayId, RelayState)> = self
.states()?
.iter()
.map(|(id, state)| (*id, *state))
.collect();
vec.sort_by_key(|v| v.0);
Ok(vec.iter().map(|v| v.1).collect())
}
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<(), ControllerError> {
self.maybe_timeout().await?;
let mut keys: Vec<RelayId> = self.states()?.keys().copied().collect();
keys.sort();
for update in keys.iter().zip(states) {
self.states()?.insert(*update.0, update.1);
}
Ok(())
}
async fn check_connection(&self) -> Result<(), ControllerError> {
Ok(())
}
async fn get_firmware_version(&self) -> Result<Option<String>, ControllerError> {
Ok(self.firmware_version.clone())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::domain::relay::types::RelayId; use crate::domain::relay::types::RelayId;

View File

@@ -293,13 +293,13 @@
- **File**: src/infrastructure/modbus/mock_controller.rs - **File**: src/infrastructure/modbus/mock_controller.rs
- **Complexity**: Low | **Uncertainty**: Low - **Complexity**: Low | **Uncertainty**: Low
- [ ] **T029** [P] [US1] [TDD] Implement MockRelayController - [x] **T029** [P] [US1] [TDD] Implement MockRelayController
- Struct with Arc<Mutex<HashMap<RelayId, RelayState>>> - Struct with Arc<Mutex<HashMap<RelayId, RelayState>>>
- Implement RelayController trait with in-memory state - Implement RelayController trait with in-memory state
- **File**: src/infrastructure/modbus/mock_controller.rs - **File**: src/infrastructure/modbus/mock_controller.rs
- **Complexity**: Low | **Uncertainty**: Low - **Complexity**: Low | **Uncertainty**: Low
- [ ] **T030** [US1] [TDD] Define RelayController trait - [x] **T030** [US1] [TDD] Define RelayController trait
- async fn read_state(&self, id: RelayId) → Result<RelayState, ControllerError> - async fn read_state(&self, id: RelayId) → Result<RelayState, ControllerError>
- async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError> - async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError>
- async fn read_all(&self) → Result<Vec<(RelayId, RelayState)>, ControllerError> - async fn read_all(&self) → Result<Vec<(RelayId, RelayState)>, ControllerError>
@@ -307,7 +307,7 @@
- **File**: src/infrastructure/modbus/controller.rs - **File**: src/infrastructure/modbus/controller.rs
- **Complexity**: Low | **Uncertainty**: Low - **Complexity**: Low | **Uncertainty**: Low
- [ ] **T031** [P] [US1] [TDD] Define ControllerError enum - [x] **T031** [P] [US1] [TDD] Define ControllerError enum
- Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8) - Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8)
- Implement std::error::Error, Display, Debug - Implement std::error::Error, Display, Debug
- Use thiserror derive macros - Use thiserror derive macros