From 215e8d552ae7163720050dfdb87795ac1d8b01a7 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sat, 10 Jan 2026 12:56:22 +0100 Subject: [PATCH] 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> 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) --- backend/src/domain/relay/controller.rs | 91 ++++++++++++- backend/src/domain/relay/repository.rs | 40 +++++- backend/src/domain/relay/types/relayid.rs | 2 +- .../infrastructure/modbus/mock_controller.rs | 125 ++++++++++++++++++ specs/001-modbus-relay-control/tasks.md | 6 +- 5 files changed, 258 insertions(+), 6 deletions(-) diff --git a/backend/src/domain/relay/controller.rs b/backend/src/domain/relay/controller.rs index 50bc63e..87931c9 100644 --- a/backend/src/domain/relay/controller.rs +++ b/backend/src/domain/relay/controller.rs @@ -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 = std::result::Result; + +/// 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; + + /// 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>; + + /// 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) -> 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>; +} diff --git a/backend/src/domain/relay/repository.rs b/backend/src/domain/relay/repository.rs index d3c2ded..47ef2f3 100644 --- a/backend/src/domain/relay/repository.rs +++ b/backend/src/domain/relay/repository.rs @@ -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, 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, RepositoryError>; +} diff --git a/backend/src/domain/relay/types/relayid.rs b/backend/src/domain/relay/types/relayid.rs index d13fca6..4ed92ab 100644 --- a/backend/src/domain/relay/types/relayid.rs +++ b/backend/src/domain/relay/types/relayid.rs @@ -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); diff --git a/backend/src/infrastructure/modbus/mock_controller.rs b/backend/src/infrastructure/modbus/mock_controller.rs index 347e05b..ddb9216 100644 --- a/backend/src/infrastructure/modbus/mock_controller.rs +++ b/backend/src/infrastructure/modbus/mock_controller.rs @@ -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>`, enabling concurrent access in tests. It provides +/// optional timeout simulation for testing error handling scenarios. +#[derive(Debug, Clone)] +pub struct MockRelayController { + states: Arc>>, + firmware_version: Option, + 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>, ControllerError> { + self.states + .lock() + .map_err(|e| ControllerError::ModbusException(e.to_string())) + } +} + +impl Default for MockRelayController { + fn default() -> Self { + let hashmap: HashMap = 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 { + 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, 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) -> Result<(), ControllerError> { + self.maybe_timeout().await?; + let mut keys: Vec = 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, ControllerError> { + Ok(self.firmware_version.clone()) + } +} + #[cfg(test)] mod tests { use crate::domain::relay::types::RelayId; diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index a1f901b..98c2a75 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -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>> - 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 - async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError> - async fn read_all(&self) → Result, 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