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 036be64d3c
commit e8e6a1e702
5 changed files with 258 additions and 6 deletions

View File

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