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:
@@ -1,7 +1,96 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{RelayId, RelayState};
|
||||
|
||||
/// Errors that can occur during relay controller operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
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}")]
|
||||
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>>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
@@ -13,3 +15,39 @@ pub enum RepositoryError {
|
||||
#[error("Relay not found: {0}")]
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::domain::relay::controller::ControllerError;
|
||||
/// 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
|
||||
/// 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)]
|
||||
pub struct RelayId(u8);
|
||||
|
||||
|
||||
@@ -3,6 +3,131 @@
|
||||
//! This module provides a mock implementation of the relay controller
|
||||
//! 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)]
|
||||
mod tests {
|
||||
use crate::domain::relay::types::RelayId;
|
||||
|
||||
@@ -293,13 +293,13 @@
|
||||
- **File**: src/infrastructure/modbus/mock_controller.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T029** [P] [US1] [TDD] Implement MockRelayController
|
||||
- [x] **T029** [P] [US1] [TDD] Implement MockRelayController
|
||||
- Struct with Arc<Mutex<HashMap<RelayId, RelayState>>>
|
||||
- Implement RelayController trait with in-memory state
|
||||
- **File**: src/infrastructure/modbus/mock_controller.rs
|
||||
- **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 write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError>
|
||||
- async fn read_all(&self) → Result<Vec<(RelayId, RelayState)>, ControllerError>
|
||||
@@ -307,7 +307,7 @@
|
||||
- **File**: src/infrastructure/modbus/controller.rs
|
||||
- **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)
|
||||
- Implement std::error::Error, Display, Debug
|
||||
- Use thiserror derive macros
|
||||
|
||||
Reference in New Issue
Block a user