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 bb3824727e
commit 8d6ff23cbc
7 changed files with 1088 additions and 41 deletions

View File

@@ -331,13 +331,13 @@
**Complexity**: High → Broken into 6 sub-tasks
**Uncertainty**: High
**Rationale**: Nested Result handling, Arc<Mutex> synchronization, timeout wrapping
**Rationale**: Nested Result handling, `Arc<Mutex>` synchronization, timeout wrapping
**Protocol**: Native Modbus TCP (MBAP header, no CRC16 validation)
- [ ] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup
- Struct: ModbusRelayController { ctx: Arc<Mutex<Context>>, timeout_duration: Duration }
- Constructor: new(host, port, slave_id, timeout_secs) → Result<Self, ControllerError>
- Use tokio_modbus::client::tcp::connect_slave()
- [x] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup
- Struct: `ModbusRelayController { ctx: Arc<Mutex<Context>>, timeout_duration: Duration }`
- Constructor: `new(host, port, slave_id, timeout_secs) → Result<Self, ControllerError>`
- Use `tokio_modbus::client::tcp::connect_slave()`
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Medium | **Uncertainty**: Medium
@@ -372,13 +372,13 @@
```
**TDD Checklist** (write these tests FIRST):
- [ ] Test: new() with valid config connects successfully
- [ ] Test: new() with invalid host returns ConnectionError
- [ ] Test: new() stores correct timeout_duration
- [x] Test: `new()` with valid config connects successfully
- [x] Test: `new()` with invalid host returns ConnectionError
- [x] Test: `new()` stores correct timeout_duration
- [ ] **T025b** [US1] [TDD] Implement timeout-wrapped read_coils helper
- Private method: read_coils_with_timeout(addr: u16, count: u16) → Result<Vec<bool>, ControllerError>
- Wrap ctx.read_coils() with tokio::time::timeout()
- [x] **T025b** [US1] [TDD] Implement timeout-wrapped read_coils helper
- Private method: `read_coils_with_timeout(addr: u16, count: u16) → Result<Vec<bool>, ControllerError>`
- Wrap `ctx.read_coils()` with `tokio::time::timeout()`
- Handle nested Result: timeout → io::Error → Modbus Exception
- **Note**: Modbus TCP uses MBAP header (no CRC validation needed)
- **File**: src/infrastructure/modbus/modbus_controller.rs
@@ -407,13 +407,13 @@
```
**TDD Checklist**:
- [ ] Test: read_coils_with_timeout() returns coil values on success
- [ ] Test: read_coils_with_timeout() returns Timeout error when operation exceeds timeout
- [ ] Test: read_coils_with_timeout() returns ConnectionError on io::Error
- [ ] Test: read_coils_with_timeout() returns ModbusException on protocol error
- [x] Test: `read_coils_with_timeout()` returns coil values on success
- [x] Test: `read_coils_with_timeout()` returns Timeout error when operation exceeds timeout
- [x] Test: `read_coils_with_timeout()` returns ConnectionError on io::Error
- [x] Test: `read_coils_with_timeout()` returns ModbusException on protocol error
- [ ] **T025c** [US1] [TDD] Implement timeout-wrapped write_single_coil helper
- Private method: write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>
- [x] **T025c** [US1] [TDD] Implement timeout-wrapped `write_single_coil` helper
- Private method: `write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>`
- Similar nested Result handling as T025b
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Low | **Uncertainty**: Low
@@ -438,13 +438,13 @@
```
**TDD Checklist**:
- [ ] Test: write_single_coil_with_timeout() succeeds for valid write
- [ ] Test: write_single_coil_with_timeout() returns Timeout on slow device
- [ ] Test: write_single_coil_with_timeout() returns appropriate error on failure
- [x] Test: `write_single_coil_with_timeout()` succeeds for valid write
- [x] Test: `write_single_coil_with_timeout()` returns Timeout on slow device
- [x] Test: `write_single_coil_with_timeout()` returns appropriate error on failure
- [ ] **T025d** [US1] [TDD] Implement RelayController::read_state() using helpers
- [x] **T025d** [US1] [TDD] Implement RelayController::read_state() using helpers
- Convert RelayId → ModbusAddress (0-based)
- Call read_coils_with_timeout(addr, 1)
- Call `read_coils_with_timeout(addr, 1)`
- Convert bool → RelayState
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Low | **Uncertainty**: Low
@@ -463,14 +463,14 @@
```
**TDD Checklist**:
- [ ] Test: read_state(RelayId(1)) returns On when coil is true
- [ ] Test: read_state(RelayId(1)) returns Off when coil is false
- [ ] Test: read_state() propagates ControllerError from helper
- [x] Test: `read_state(RelayId(1))` returns On when coil is true
- [x] Test: `read_state(RelayId(1))` returns Off when coil is false
- [x] Test: `read_state()` propagates ControllerError from helper
- [ ] **T025e** [US1] [TDD] Implement RelayController::write_state() using helpers
- [x] **T025e** [US1] [TDD] Implement `RelayController::write_state()` using helpers
- Convert RelayId → ModbusAddress
- Convert RelayState → bool (On=true, Off=false)
- Call write_single_coil_with_timeout()
- Call `write_single_coil_with_timeout()`
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Low | **Uncertainty**: Low
@@ -484,13 +484,13 @@
```
**TDD Checklist**:
- [ ] Test: write_state(RelayId(1), RelayState::On) writes true to coil
- [ ] Test: write_state(RelayId(1), RelayState::Off) writes false to coil
- [x] Test: `write_state(RelayId(1), RelayState::On)` writes true to coil
- [x] Test: `write_state(RelayId(1), RelayState::Off)` writes false to coil
- [ ] **T025f** [US1] [TDD] Implement RelayController::read_all() and write_all()
- read_all(): Call read_coils_with_timeout(0, 8), map to Vec<(RelayId, RelayState)>
- write_all(): Loop over RelayId 1-8, call write_state() for each
- Add firmware_version() method (read holding register 0x9999, optional)
- [x] **T025f** [US1] [TDD] Implement `RelayController::read_all()` and `write_all()`
- `read_all()`: Call `read_coils_with_timeout(0, 8)`, map to `Vec<(RelayId, RelayState)>`
- `write_all()`: Loop over RelayId 1-8, call `write_state()` for each
- Add `firmware_version()` method (read holding register 0x9999, optional)
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Medium | **Uncertainty**: Low
@@ -518,9 +518,9 @@
```
**TDD Checklist**:
- [ ] Test: read_all() returns 8 relay states
- [ ] Test: write_all(RelayState::On) turns all relays on
- [ ] Test: write_all(RelayState::Off) turns all relays off
- [x] Test: `read_all()` returns 8 relay states
- [x] Test: `write_all(RelayState::On)` turns all relays on
- [x] Test: `write_all(RelayState::Off)` turns all relays off
---