Files
sta/specs/001-modbus-relay-control/tasks.md

1157 lines
43 KiB
Markdown
Raw Normal View History

# Implementation Tasks: Modbus Relay Control System
**Feature**: 001-modbus-relay-control
**Total Tasks**: 94 tasks across 8 phases
**MVP Delivery**: Phase 4 complete (Task 49)
**Parallelizable Tasks**: 35 tasks marked with `[P]`
**Approach**: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first
---
## Phase 1: Setup & Foundation (0.5 days)
**Purpose**: Initialize project dependencies and directory structure
- [x] **T001** [Setup] [TDD] Add Rust dependencies to Cargo.toml
- Add: tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] }, sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }, mockall = "0.13", async-trait = "0.1"
- **Test**: cargo check passes
- **Complexity**: Low | **Uncertainty**: Low
- [x] **T002** [P] [Setup] [TDD] Create module structure in src/
- Create: src/domain/, src/application/, src/infrastructure/, src/presentation/
- **Test**: Module declarations compile without errors
- **Complexity**: Low | **Uncertainty**: Low
- [x] **T003** [P] [Setup] [TDD] Update settings.rs with Modbus configuration
- Add ModbusSettings struct with `host`, `port`, `slave_id`, `timeout_secs` fields
- Add RelaySettings struct with `label_max_length` field
- Update Settings struct to include modbus and relay fields
- **Test**: Settings loads from settings/base.yaml with test Modbus config
- **Complexity**: Low | **Uncertainty**: Low
- [x] **T004** [P] [Setup] [TDD] Create settings/base.yaml with Modbus defaults
- Add modbus section: `host: "192.168.0.200"`, `port: 502`, `slave_id: 0`, `timeout_secs: 5`
- Add relay section: `label_max_length: 8`
- **Test**: Settings::new() loads config without errors
- **Complexity**: Low | **Uncertainty**: Low
- [x] **T005** [P] [Setup] [TDD] Add SQLite schema file
- Create infrastructure/persistence/schema.sql with relay_labels table
- Table: `relay_labels (relay_id INTEGER PRIMARY KEY CHECK(relay_id BETWEEN 1 AND 8), label TEXT NOT NULL CHECK(length(label) <= 50))`
- **Test**: Schema file syntax is valid SQL
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T006** [P] [Setup] [TDD] Initialize SQLite database module
- Create infrastructure/persistence/mod.rs
- Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct
- Implement SqliteRelayLabelRepository::new(path) using SqlitePool
- **Test**: SqliteRelayLabelRepository::in_memory() creates in-memory DB with schema
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T007** [P] [Setup] [TDD] Add frontend project scaffolding
- Create frontend/ directory with Vite + Vue 3 + TypeScript
- Run: npm create vite@latest frontend -- --template vue-ts
- Install: axios, @types/node
- **Test**: npm run dev starts frontend dev server
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI
- Add poem-openapi spec generation in startup.rs
- Generate frontend/src/api/client.ts from OpenAPI spec
- **Test**: TypeScript client compiles without errors
- **Complexity**: Medium | **Uncertainty**: Medium
- **Note**: May need manual adjustments to generated code
---
## Phase 2: Domain Layer - Type-Driven Development (1 day)
**Purpose**: Build domain types with 100% test coverage, bottom-to-top
**⚠️ TDD CRITICAL**: Write failing tests FIRST for every type, then implement
- [ ] **T009** [US1] [TDD] Write tests for RelayId newtype
- Test: RelayId::new(1) → Ok(RelayId(1))
- Test: RelayId::new(8) → Ok(RelayId(8))
- Test: RelayId::new(0) → Err(InvalidRelayId)
- Test: RelayId::new(9) → Err(InvalidRelayId)
- Test: RelayId::as_u8() returns inner value
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T010** [US1] [TDD] Implement RelayId newtype with validation
- #[repr(transparent)] newtype wrapping u8
- Constructor validates 1..=8 range
- Implement Display, Debug, Clone, Copy, PartialEq, Eq
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T011** [P] [US1] [TDD] Write tests for RelayState enum
- Test: RelayState::On → serializes to "on"
- Test: RelayState::Off → serializes to "off"
- Test: Parse "on"/"off" from strings
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T012** [P] [US1] [TDD] Implement RelayState enum
- Enum: On, Off
- Implement Display, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize/Deserialize
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T013** [US1] [TDD] Write tests for Relay aggregate
- Test: Relay::new(RelayId(1), RelayState::Off, None) creates relay
- Test: relay.toggle() flips state
- Test: relay.turn_on() sets state to On
- Test: relay.turn_off() sets state to Off
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T014** [US1] [TDD] Implement Relay aggregate
- Struct: Relay { id: RelayId, state: RelayState, label: Option<RelayLabel> }
- Methods: new(), toggle(), turn_on(), turn_off(), state(), label()
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T015** [P] [US4] [TDD] Write tests for RelayLabel newtype
- Test: RelayLabel::new("Pump") → Ok
- Test: RelayLabel::new("A".repeat(50)) → Ok
- Test: RelayLabel::new("") → Err(EmptyLabel)
- Test: RelayLabel::new("A".repeat(51)) → Err(LabelTooLong)
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T016** [P] [US4] [TDD] Implement RelayLabel newtype
- #[repr(transparent)] newtype wrapping String
- Constructor validates 1..=50 length
- Implement Display, Debug, Clone, PartialEq, Eq
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T017** [US1] [TDD] Write tests for ModbusAddress type
- Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0)
- Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7)
- **File**: src/domain/modbus.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T018** [US1] [TDD] Implement ModbusAddress type with From<RelayId>
- #[repr(transparent)] newtype wrapping u16
- Implement From<RelayId> with offset: user 1-8 → Modbus 0-7
- **File**: src/domain/modbus.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T019** [US3] [TDD] Write tests and implement HealthStatus enum
- Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }
- Test transitions between states
- **File**: src/domain/health.rs
- **Complexity**: Medium | **Uncertainty**: Low
**Checkpoint**: Domain types complete with 100% test coverage
---
## Phase 3: Infrastructure Layer (2 days)
**Purpose**: Implement Modbus client, mocks, and persistence
- [ ] **T020** [P] [US1] [TDD] Write tests for MockRelayController
- Test: read_state() returns mocked state
- Test: write_state() updates mocked state
- Test: read_all() returns 8 relays in known state
- **File**: src/infrastructure/modbus/mock_controller.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T021** [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
- [ ] **T022** [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>
- async fn write_all(&self, state: RelayState) → Result<(), ControllerError>
- **File**: src/infrastructure/modbus/controller.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T023** [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
- **File**: src/infrastructure/modbus/error.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController
- **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities
- Test: Connection succeeds with valid config (Modbus TCP on port 502)
- Test: read_state() returns correct coil value
- Test: write_state() sends correct Modbus TCP command (no CRC needed)
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: High → DECOMPOSED below
- **Uncertainty**: High
---
### T025: ModbusRelayController Implementation (DECOMPOSED)
**Complexity**: High → Broken into 6 sub-tasks
**Uncertainty**: High
**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()
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Medium | **Uncertainty**: Medium
**Pseudocode**:
```rust
pub struct ModbusRelayController {
ctx: Arc<Mutex<tokio_modbus::client::Context>>,
timeout_duration: Duration,
}
impl ModbusRelayController {
pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64)
-> Result<Self, ControllerError>
{
use tokio_modbus::prelude::*;
// Connect using native Modbus TCP protocol (port 502)
let socket_addr = format!("{}:{}", host, port)
.parse()
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?;
let ctx = tcp::connect_slave(socket_addr, Slave(slave_id))
.await
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?;
Ok(Self {
ctx: Arc::new(Mutex::new(ctx)),
timeout_duration: Duration::from_secs(timeout_secs),
})
}
}
```
**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
- [ ] **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
- **Complexity**: Medium | **Uncertainty**: Medium
**Pseudocode** (CRITICAL PATTERN):
```rust
async fn read_coils_with_timeout(&self, addr: u16, count: u16)
-> Result<Vec<bool>, ControllerError>
{
use tokio::time::timeout;
let ctx = self.ctx.lock().await;
// tokio-modbus returns nested Results: Result<Result<T, Exception>, io::Error>
// We must unwrap 3 layers: timeout → io::Error → Modbus Exception
let result = timeout(self.timeout_duration, ctx.read_coils(addr, count))
.await // Result<Result<Result<Vec<bool>, Exception>, io::Error>, Elapsed>
.map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? // Handle timeout
.map_err(|e| ControllerError::ConnectionError(e.to_string()))? // Handle io::Error
.map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; // Handle Exception
Ok(result)
}
```
**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
- [ ] **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
**Pseudocode**:
```rust
async fn write_single_coil_with_timeout(&self, addr: u16, value: bool)
-> Result<(), ControllerError>
{
use tokio::time::timeout;
let ctx = self.ctx.lock().await;
timeout(self.timeout_duration, ctx.write_single_coil(addr, value))
.await
.map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))?
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?
.map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?;
Ok(())
}
```
**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
- [ ] **T025d** [US1] [TDD] Implement RelayController::read_state() using helpers
- Convert RelayId → ModbusAddress (0-based)
- Call read_coils_with_timeout(addr, 1)
- Convert bool → RelayState
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Low | **Uncertainty**: Low
**Pseudocode**:
```rust
#[async_trait]
impl RelayController for ModbusRelayController {
async fn read_state(&self, id: RelayId) -> Result<RelayState, ControllerError> {
let addr = ModbusAddress::from(id).as_u16();
let coils = self.read_coils_with_timeout(addr, 1).await?;
Ok(if coils[0] { RelayState::On } else { RelayState::Off })
}
}
```
**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
- [ ] **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()
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Low | **Uncertainty**: Low
**Pseudocode**:
```rust
async fn write_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> {
let addr = ModbusAddress::from(id).as_u16();
let value = matches!(state, RelayState::On);
self.write_single_coil_with_timeout(addr, value).await
}
```
**TDD Checklist**:
- [ ] Test: write_state(RelayId(1), RelayState::On) writes true to coil
- [ ] 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)
- **File**: src/infrastructure/modbus/modbus_controller.rs
- **Complexity**: Medium | **Uncertainty**: Low
**Pseudocode**:
```rust
async fn read_all(&self) -> Result<Vec<(RelayId, RelayState)>, ControllerError> {
let coils = self.read_coils_with_timeout(0, 8).await?;
let mut relays = Vec::new();
for (idx, &coil_value) in coils.iter().enumerate() {
let relay_id = RelayId::new((idx + 1) as u8)?;
let state = if coil_value { RelayState::On } else { RelayState::Off };
relays.push((relay_id, state));
}
Ok(relays)
}
async fn write_all(&self, state: RelayState) -> Result<(), ControllerError> {
for i in 1..=8 {
let relay_id = RelayId::new(i)?;
self.write_state(relay_id, state).await?;
}
Ok(())
}
```
**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
---
- [ ] **T026** [US1] [TDD] Integration test with real hardware (optional)
- **REQUIRES PHYSICAL DEVICE**: Test against actual Modbus relay at configured IP
- Skip if device unavailable, rely on MockRelayController for CI
- **File**: tests/integration/modbus_hardware_test.rs
- **Complexity**: Medium | **Uncertainty**: High
- **Note**: Use #[ignore] attribute, run with cargo test -- --ignored
- [ ] **T027** [P] [US4] [TDD] Write tests for RelayLabelRepository trait
- Test: get_label(RelayId(1)) → Option<RelayLabel>
- Test: set_label(RelayId(1), label) → Result<(), RepositoryError>
- Test: delete_label(RelayId(1)) → Result<(), RepositoryError>
- **File**: src/infrastructure/persistence/label_repository.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T028** [P] [US4] [TDD] Implement SQLite RelayLabelRepository
- Implement get_label(), set_label(), delete_label() using SQLx
- Use sqlx::query! macros for compile-time SQL verification
- **File**: src/infrastructure/persistence/sqlite_label_repository.rs
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T029** [US4] [TDD] Write tests for in-memory mock LabelRepository
- For testing without SQLite dependency
- **File**: src/infrastructure/persistence/mock_label_repository.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T030** [US4] [TDD] Implement in-memory mock LabelRepository
- HashMap-based implementation
- **File**: src/infrastructure/persistence/mock_label_repository.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T031** [US3] [TDD] Write tests for HealthMonitor service
- Test: track_success() transitions Degraded → Healthy
- Test: track_failure() transitions Healthy → Degraded → Unhealthy
- **File**: src/application/health_monitor.rs
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T032** [US3] [TDD] Implement HealthMonitor service
- Track consecutive errors, transition states per FR-020, FR-021
- **File**: src/application/health_monitor.rs
- **Complexity**: Medium | **Uncertainty**: Low
**Checkpoint**: Infrastructure layer complete with trait abstractions
---
## Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days)
**Goal**: View current state of all 8 relays + toggle individual relay on/off
**Independent Test**: GET /api/relays returns 8 relays, POST /api/relays/{id}/toggle changes state
### Application Layer
- [ ] **T033** [US1] [TDD] Write tests for ToggleRelayUseCase
- Test: execute(RelayId(1)) toggles relay state via controller
- Test: execute() returns error if controller fails
- **File**: src/application/use_cases/toggle_relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T034** [US1] [TDD] Implement ToggleRelayUseCase
- Orchestrate: read current state → toggle → write new state
- **File**: src/application/use_cases/toggle_relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T035** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase
- Test: execute() returns all 8 relays with states
- **File**: src/application/use_cases/get_all_relays.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T036** [P] [US1] [TDD] Implement GetAllRelaysUseCase
- Call controller.read_all(), map to domain Relay objects
- **File**: src/application/use_cases/get_all_relays.rs
- **Complexity**: Low | **Uncertainty**: Low
### Presentation Layer (Backend API)
- [ ] **T037** [US1] [TDD] Define RelayDto in presentation layer
- Fields: id (u8), state ("on"/"off"), label (Option<String>)
- Implement From<Relay> for RelayDto
- **File**: src/presentation/dto/relay_dto.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T038** [US1] [TDD] Define API error responses
- ApiError enum with status codes and messages
- Implement poem::error::ResponseError
- **File**: src/presentation/error.rs
- **Complexity**: Low | **Uncertainty**: Low
---
### T039: Dependency Injection Setup (DECOMPOSED)
**Complexity**: High → Broken into 4 sub-tasks
**Uncertainty**: Medium
**Rationale**: Graceful degradation (FR-023), conditional mock/real controller
- [ ] **T039a** [US1] [TDD] Create ModbusRelayController factory with retry and fallback
- Factory function: create_relay_controller(settings, use_mock) → Arc<dyn RelayController>
- Retry 3 times with 2s backoff on connection failure
- Graceful degradation: fallback to MockRelayController if all retries fail (FR-023)
- **File**: src/infrastructure/modbus/factory.rs
- **Complexity**: Medium | **Uncertainty**: Medium
**Pseudocode**:
```rust
pub async fn create_relay_controller(
settings: &ModbusSettings,
use_mock: bool,
) -> Arc<dyn RelayController> {
if use_mock {
tracing::info!("Using MockRelayController (test mode)");
return Arc::new(MockRelayController::new());
}
// Retry 3 times with 2s backoff
for attempt in 1..=3 {
match ModbusRelayController::new(
&settings.host,
settings.port,
settings.slave_id,
settings.timeout_secs,
).await {
Ok(controller) => {
tracing::info!("Connected to Modbus device on attempt {}", attempt);
return Arc::new(controller);
}
Err(e) => {
tracing::warn!(
attempt,
error = %e,
"Failed to connect to Modbus device, retrying..."
);
if attempt < 3 {
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
// Graceful degradation: fallback to MockRelayController
tracing::error!(
"Could not connect to Modbus device after 3 attempts, \
using MockRelayController as fallback"
);
Arc::new(MockRelayController::new())
}
```
**TDD Checklist**:
- [ ] Test: use_mock=true returns MockRelayController immediately
- [ ] Test: Successful connection returns ModbusRelayController
- [ ] Test: Connection failure after 3 retries returns MockRelayController
- [ ] Test: Retry delays are 2 seconds between attempts
- [ ] Test: Logs appropriate messages for each connection attempt
- [ ] **T039b** [US4] [TDD] Create RelayLabelRepository factory
- Factory function: create_label_repository(db_path, use_mock) → Arc<dyn RelayLabelRepository>
- If use_mock: return MockLabelRepository
- Else: return SQLiteLabelRepository connected to db_path
- **File**: src/infrastructure/persistence/factory.rs
- **Complexity**: Low | **Uncertainty**: Low
**Pseudocode**:
```rust
pub fn create_label_repository(
db_path: &str,
use_mock: bool,
) -> Result<Arc<dyn RelayLabelRepository>, RepositoryError> {
if use_mock {
tracing::info!("Using MockLabelRepository (test mode)");
return Ok(Arc::new(MockLabelRepository::new()));
}
let db = Database::new(db_path)?;
Ok(Arc::new(SQLiteLabelRepository::new(db)))
}
```
**TDD Checklist**:
- [ ] Test: use_mock=true returns MockLabelRepository
- [ ] Test: use_mock=false returns SQLiteLabelRepository
- [ ] Test: Invalid db_path returns RepositoryError
- [ ] **T039c** [US1] [TDD] Wire dependencies in Application::build()
- Determine test mode: cfg!(test) || env::var("CI").is_ok()
- Call create_relay_controller() and create_label_repository()
- Pass dependencies to RelayApi::new()
- **File**: src/startup.rs
- **Complexity**: Medium | **Uncertainty**: Low
**Pseudocode**:
```rust
impl Application {
pub async fn build(settings: Settings) -> Result<Self, StartupError> {
let use_mock = cfg!(test) || std::env::var("CI").is_ok();
// Create dependencies
let relay_controller = create_relay_controller(&settings.modbus, use_mock).await;
let label_repository = create_label_repository(&settings.database.path, use_mock)?;
// Create API with dependencies
let relay_api = RelayApi::new(relay_controller, label_repository);
// Build OpenAPI service
let api_service = OpenApiService::new(relay_api, "STA API", "1.0.0")
.server("http://localhost:8080");
let ui = api_service.swagger_ui();
let spec = api_service.spec();
let app = Route::new()
.nest("/api", api_service)
.nest("/", ui)
.at("/openapi.json", poem::endpoint::make_sync(move |_| spec.clone()));
Ok(Self { app, settings })
}
}
```
**TDD Checklist**:
- [ ] Test: Application::build() succeeds in test mode
- [ ] Test: Application::build() creates correct mock dependencies when CI=true
- [ ] Test: Application::build() creates real dependencies when not in test mode
- [ ] **T039d** [US1] [TDD] Register RelayApi in route aggregator
- Add RelayApi to OpenAPI service
- Tag: "Relays"
- **File**: src/startup.rs
- **Complexity**: Low | **Uncertainty**: Low
**TDD Checklist**:
- [ ] Test: OpenAPI spec includes /api/relays endpoints
- [ ] Test: Swagger UI renders Relays tag
---
- [ ] **T040** [US1] [TDD] Write contract tests for GET /api/relays
- Test: Returns 200 with array of 8 RelayDto
- Test: Each relay has id 1-8, state, and optional label
- **File**: tests/contract/test_relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T041** [US1] [TDD] Implement GET /api/relays endpoint
- #[oai(path = "/relays", method = "get")]
- Call GetAllRelaysUseCase, map to RelayDto
- **File**: src/presentation/api/relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T042** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle
- Test: Returns 200 with updated RelayDto
- Test: Returns 404 for id < 1 or id > 8
- Test: State actually changes in controller
- **File**: tests/contract/test_relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T043** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint
- #[oai(path = "/relays/:id/toggle", method = "post")]
- Parse id, call ToggleRelayUseCase, return updated state
- **File**: src/presentation/api/relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
### Frontend Implementation
- [ ] **T044** [P] [US1] [TDD] Create RelayDto TypeScript interface
- Generate from OpenAPI spec or manually define
- **File**: frontend/src/types/relay.ts
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T045** [P] [US1] [TDD] Create API client service
- getAllRelays(): Promise<RelayDto[]>
- toggleRelay(id: number): Promise<RelayDto>
- **File**: frontend/src/api/relayApi.ts
- **Complexity**: Low | **Uncertainty**: Low
---
### T046: HTTP Polling Composable (DECOMPOSED)
**Complexity**: High → Broken into 4 sub-tasks
**Uncertainty**: Medium
**Rationale**: Vue 3 lifecycle hooks, polling management, memory leak prevention
- [ ] **T046a** [US1] [TDD] Create useRelayPolling composable structure
- Setup reactive refs: relays, isLoading, error, lastFetchTime
- Define interval variable and fetch function signature
- **File**: frontend/src/composables/useRelayPolling.ts
- **Complexity**: Low | **Uncertainty**: Low
**Pseudocode**:
```typescript
import { ref, Ref } from 'vue';
import type { RelayDto } from '@/types/relay';
export function useRelayPolling(intervalMs: number = 2000) {
const relays: Ref<RelayDto[]> = ref([]);
const isLoading = ref(true);
const error: Ref<string | null> = ref(null);
const lastFetchTime: Ref<Date | null> = ref(null);
const isConnected = ref(false);
let pollingInterval: number | null = null;
// TODO: Implement fetchData, startPolling, stopPolling
return {
relays,
isLoading,
error,
isConnected,
lastFetchTime,
refresh: fetchData,
startPolling,
stopPolling,
};
}
```
**TDD Checklist**:
- [ ] Test: Composable returns correct reactive refs
- [ ] Test: Initial state is loading=true, relays=[], error=null
- [ ] **T046b** [US1] [TDD] Implement fetchData with parallel requests
- Fetch relays and health status in parallel using Promise.all
- Update reactive state on success
- Handle errors gracefully, set isConnected based on success
- **File**: frontend/src/composables/useRelayPolling.ts
- **Complexity**: Medium | **Uncertainty**: Low
**Pseudocode**:
```typescript
const fetchData = async () => {
try {
const [relayData, healthData] = await Promise.all([
apiClient.getAllRelays(),
apiClient.getHealth(),
]);
relays.value = relayData.relays;
isConnected.value = healthData.status === 'healthy';
lastFetchTime.value = new Date();
error.value = null;
} catch (err: any) {
error.value = err.message || 'Failed to fetch relay data';
isConnected.value = false;
console.error('Polling error:', err);
} finally {
isLoading.value = false;
}
};
```
**TDD Checklist**:
- [ ] Test: fetchData() updates relays on success
- [ ] Test: fetchData() sets error on API failure
- [ ] Test: fetchData() sets isLoading=false after completion
- [ ] Test: fetchData() updates lastFetchTime
- [ ] **T046c** [US1] [TDD] Implement polling lifecycle with cleanup
- startPolling(): Fetch immediately, then setInterval
- stopPolling(): clearInterval and cleanup
- Use onMounted/onUnmounted for automatic lifecycle management
- **File**: frontend/src/composables/useRelayPolling.ts
- **Complexity**: Medium | **Uncertainty**: Low
**Pseudocode**:
```typescript
import { onMounted, onUnmounted } from 'vue';
const startPolling = () => {
if (pollingInterval !== null) return; // Already polling
fetchData(); // Immediate first fetch
pollingInterval = window.setInterval(fetchData, intervalMs);
};
const stopPolling = () => {
if (pollingInterval !== null) {
clearInterval(pollingInterval);
pollingInterval = null;
}
};
// CRITICAL: Lifecycle cleanup to prevent memory leaks
onMounted(() => {
startPolling();
});
onUnmounted(() => {
stopPolling();
});
```
**TDD Checklist**:
- [ ] Test: startPolling() triggers immediate fetch
- [ ] Test: startPolling() sets interval for subsequent fetches
- [ ] Test: stopPolling() clears interval
- [ ] Test: onUnmounted hook calls stopPolling()
- [ ] **T046d** [US1] [TDD] Add connection status tracking
- Track isConnected based on fetch success/failure
- Display connection status in UI
- **File**: frontend/src/composables/useRelayPolling.ts
- **Complexity**: Low | **Uncertainty**: Low
**Pseudocode**:
```typescript
// Already implemented in T046b, just ensure it's exposed
return {
relays,
isLoading,
error,
isConnected, // ← Connection status indicator
lastFetchTime,
refresh: fetchData,
startPolling,
stopPolling,
};
```
**TDD Checklist**:
- [ ] Test: isConnected is true after successful fetch
- [ ] Test: isConnected is false after failed fetch
---
- [ ] **T047** [US1] [TDD] Create RelayCard component
- Props: relay (RelayDto)
- Display relay ID, state, label
- Emit toggle event on button click
- **File**: frontend/src/components/RelayCard.vue
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T048** [US1] [TDD] Create RelayGrid component
- Use useRelayPolling composable
- Render 8 RelayCard components
- Handle toggle events by calling API
- Display loading/error states
- **File**: frontend/src/components/RelayGrid.vue
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T049** [US1] [TDD] Integration test for US1
- End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change
- Use Playwright or Cypress
- **File**: frontend/tests/e2e/relay-control.spec.ts
- **Complexity**: Medium | **Uncertainty**: Medium
**Checkpoint**: US1 MVP complete - users can view and toggle individual relays
---
## Phase 5: US2 - Bulk Relay Controls (0.5 days)
**Goal**: Turn all relays on/off with single action
**Independent Test**: POST /api/relays/all/on turns all 8 relays on
- [ ] **T050** [US2] [TDD] Write tests for BulkControlUseCase
- Test: execute(BulkOperation::AllOn) turns all relays on
- Test: execute(BulkOperation::AllOff) turns all relays off
- **File**: src/application/use_cases/bulk_control.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T051** [US2] [TDD] Implement BulkControlUseCase
- Call controller.write_all(state)
- **File**: src/application/use_cases/bulk_control.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T052** [US2] [TDD] Define BulkOperation enum
- Variants: AllOn, AllOff
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T053** [US2] [TDD] Write contract tests for POST /api/relays/all/on
- Test: Returns 200, all relays turn on
- **File**: tests/contract/test_relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T054** [US2] [TDD] Implement POST /api/relays/all/on endpoint
- Call BulkControlUseCase with AllOn
- **File**: src/presentation/api/relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T055** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off
- Test: Returns 200, all relays turn off
- **File**: tests/contract/test_relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T056** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint
- Call BulkControlUseCase with AllOff
- **File**: src/presentation/api/relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T057** [US2] [TDD] Add bulk control buttons to frontend
- Add "All On" and "All Off" buttons to RelayGrid component
- Call API endpoints and refresh relay states
- **File**: frontend/src/components/RelayGrid.vue
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T058** [US2] [TDD] Integration test for US2
- Click "All On" → verify all 8 relays turn on
- Click "All Off" → verify all 8 relays turn off
- **File**: frontend/tests/e2e/bulk-control.spec.ts
- **Complexity**: Low | **Uncertainty**: Low
**Checkpoint**: US2 complete - bulk controls functional
---
## Phase 6: US3 - Health Monitoring (1 day)
**Goal**: Display connection status and device health
**Independent Test**: GET /api/health returns health status
- [ ] **T059** [US3] [TDD] Write tests for GetHealthUseCase
- Test: Returns Healthy when controller is responsive
- Test: Returns Degraded after 3 consecutive errors
- Test: Returns Unhealthy after 10 consecutive errors
- **File**: src/application/use_cases/get_health.rs
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T060** [US3] [TDD] Implement GetHealthUseCase
- Use HealthMonitor to track controller status
- Return current HealthStatus
- **File**: src/application/use_cases/get_health.rs
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T061** [US3] [TDD] Define HealthDto
- Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional)
- **File**: src/presentation/dto/health_dto.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T062** [US3] [TDD] Write contract tests for GET /api/health
- Test: Returns 200 with HealthDto
- **File**: tests/contract/test_health_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T063** [US3] [TDD] Implement GET /api/health endpoint
- Call GetHealthUseCase, map to HealthDto
- **File**: src/presentation/api/health_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T064** [P] [US3] [TDD] Add firmware version display (optional)
- If controller supports firmware_version(), display in UI
- **File**: frontend/src/components/DeviceInfo.vue
- **Complexity**: Low | **Uncertainty**: Medium
- **Note**: Device may not support this feature
- [ ] **T065** [US3] [TDD] Create HealthIndicator component
- Display connection status with color-coded indicator
- Show firmware version if available
- **File**: frontend/src/components/HealthIndicator.vue
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T066** [US3] [TDD] Integrate HealthIndicator in RelayGrid
- Fetch health status in useRelayPolling composable
- Pass to HealthIndicator component
- **File**: frontend/src/components/RelayGrid.vue
- **Complexity**: Low | **Uncertainty**: Low
**Checkpoint**: US3 complete - health monitoring visible
---
## Phase 7: US4 - Relay Labeling (0.5 days)
**Goal**: Set custom labels for each relay
**Independent Test**: PUT /api/relays/{id}/label sets label, GET /api/relays returns label
- [ ] **T067** [US4] [TDD] Write tests for SetLabelUseCase
- Test: execute(RelayId(1), "Pump") sets label
- Test: execute with empty label returns error
- Test: execute with 51-char label returns error
- **File**: src/application/use_cases/set_label.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T068** [US4] [TDD] Implement SetLabelUseCase
- Validate label with RelayLabel::new()
- Call label_repository.set_label()
- **File**: src/application/use_cases/set_label.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T069** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label
- Test: Returns 200, label is persisted
- Test: Returns 400 for invalid label
- **File**: tests/contract/test_relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T070** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint
- Parse id and label, call SetLabelUseCase
- **File**: src/presentation/api/relay_api.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T071** [US4] [TDD] Add label editing to RelayCard component
- Click label → show input field
- Submit → call PUT /api/relays/{id}/label
- **File**: frontend/src/components/RelayCard.vue
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T072** [US4] [TDD] Integration test for US4
- Set label for relay 1 → refresh → verify label persists
- **File**: frontend/tests/e2e/relay-labeling.spec.ts
- **Complexity**: Low | **Uncertainty**: Low
**Checkpoint**: US4 complete - relay labeling functional
---
## Phase 8: Polish & Deployment (1 day)
**Purpose**: Testing, documentation, and production readiness
- [ ] **T073** [P] Add comprehensive logging at all architectural boundaries
- Log all API requests/responses
- Log all Modbus operations
- Log health status transitions
- **Files**: All API and infrastructure modules
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T074** [P] Add OpenAPI documentation for all endpoints
- Document request/response schemas
- Add example values
- Tag endpoints appropriately
- **File**: src/presentation/api/*.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T075** [P] Run cargo clippy and fix all warnings
- Ensure compliance with strict linting
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T076** [P] Run cargo fmt and format all code
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T077** Generate test coverage report
- Run: just coverage
- Ensure > 80% coverage for domain and application layers
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T078** [P] Run cargo audit for dependency vulnerabilities
- Fix any high/critical vulnerabilities
- **Complexity**: Low | **Uncertainty**: Medium
- [ ] **T079** [P] Update README.md with deployment instructions
- Document environment variables
- Document Modbus device configuration
- Add quickstart guide
- **File**: README.md
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T080** [P] Create Docker image for backend
- Multi-stage build with Rust
- Include SQLite database setup
- **File**: Dockerfile
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T081** [P] Create production settings/production.yaml
- Configure for actual device IP
- Set appropriate timeouts and retry settings
- **File**: settings/production.yaml
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T082** Deploy to production environment
- Test with actual Modbus relay device
- Verify all user stories work end-to-end
- **Complexity**: Medium | **Uncertainty**: High
**Checkpoint**: Production ready, all user stories validated
---
## Dependencies & Execution Order
### Phase Dependencies
1. **Phase 1 (Setup)**: No dependencies - start immediately
2. **Phase 2 (Domain TyDD)**: Depends on Phase 1 module structure
3. **Phase 3 (Infrastructure)**: Depends on Phase 2 domain types
4. **Phase 4 (US1 MVP)**: Depends on Phase 3 infrastructure
5. **Phase 5 (US2)**: Depends on Phase 4 backend API complete
6. **Phase 6 (US3)**: Depends on Phase 4 backend API complete (can parallelize with US2)
7. **Phase 7 (US4)**: Depends on Phase 4 backend API complete (can parallelize with US2/US3)
8. **Phase 8 (Polish)**: Depends on all desired user stories complete
### User Story Independence
- **US1**: No dependencies on other stories
- **US2**: Reuses US1 backend infrastructure, but independently testable
- **US3**: Reuses US1 backend infrastructure, but independently testable
- **US4**: Reuses US1 backend infrastructure, adds new persistence layer
### Critical Path
**MVP (US1 only)**: Phase 1 → Phase 2 → Phase 3 → Phase 4 (5 days)
**Full Feature**: MVP + Phase 5 + Phase 6 + Phase 7 + Phase 8 (7 days)
### Parallel Opportunities
- **Phase 1**: T002, T003, T004, T005, T006, T007, T008 can run in parallel
- **Phase 2**: T011, T015 can run in parallel
- **Phase 3**: T020, T027, T028, T029, T030 can run in parallel after T022 complete
- **Phase 4**: T035, T044, T045 can run in parallel
- **After Phase 4**: US2, US3, US4 can be developed in parallel by different developers
- **Phase 8**: T073, T074, T075, T076, T078, T079, T080, T081 can run in parallel
**Total Parallelizable Tasks**: 35 tasks marked `[P]`
---
## Test-Driven Development Workflow
**CRITICAL**: For every task marked `[TDD]`, follow this exact sequence:
1. **Write failing test FIRST** (red)
2. **Verify test fails** for the right reason
3. **Implement minimum code** to pass test (green)
4. **Refactor** while keeping tests green
5. **Commit** after each task or logical group
### Example TDD Workflow (T010):
```bash
# 1. Write failing test for RelayId::new() validation
# In src/domain/relay.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relay_id_valid_range() {
assert!(RelayId::new(1).is_ok());
assert!(RelayId::new(8).is_ok());
}
#[test]
fn test_relay_id_invalid_range() {
assert!(RelayId::new(0).is_err());
assert!(RelayId::new(9).is_err());
}
}
# 2. Run test → VERIFY IT FAILS
cargo test test_relay_id
# 3. Implement RelayId to make test pass
# 4. Run test again → VERIFY IT PASSES
# 5. Refactor if needed, keep tests green
# 6. Commit
jj describe -m "feat: implement RelayId with validation (T010)"
```
---
## Notes
- **[P]** = Parallelizable (different files, no dependencies)
- **[US1/US2/US3/US4]** = User story mapping for traceability
- **[TDD]** = Test-Driven Development required
- **Complexity**: Low (< 1 hour) | Medium (1-3 hours) | High (> 3 hours or decomposed)
- **Uncertainty**: Low (clear path) | Medium (some unknowns) | High (requires research/spike)
- Commit after each task or logical group using `jj describe` or `jj commit`
- MVP delivery at task T049 (end of Phase 4)
- Stop at any checkpoint to independently validate user story