# Implementation Tasks: Modbus Relay Control System **Feature**: 001-modbus-relay-control **Total Tasks**: 102 tasks across 9 phases **MVP Delivery**: Phase 4 complete (Task 57) **Parallelizable Tasks**: 39 tasks marked with `[P]` **Approach**: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first --- ## Phase 1: Setup & Foundation (0.5 days) DONE **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 - [x] **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 - [x] **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 - [x] **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 0.5: CORS Configuration & Production Security (0.5 days) **Purpose**: Replace permissive `Cors::new()` with configurable production-ready CORS **⚠️ TDD CRITICAL**: Write failing tests FIRST for every configuration and function - [x] **T009** [P] [Setup] [TDD] Write tests for CorsSettings struct - Test: CorsSettings deserializes from YAML correctly ✓ - Test: Default CorsSettings has empty allowed_origins (restrictive fail-safe) ✓ - Test: CorsSettings with wildcard origin deserializes correctly ✓ - Test: Settings::new() loads cors section from development.yaml ✓ - Test: CorsSettings with partial fields uses defaults ✓ - **File**: backend/src/settings.rs (in tests module) - **Complexity**: Low | **Uncertainty**: Low - **Tests Written**: 5 tests (cors_settings_deserialize_from_yaml, cors_settings_default_has_empty_origins, cors_settings_with_wildcard_deserializes, settings_loads_cors_section_from_yaml, cors_settings_deserialize_with_defaults) - [ ] **T010** [P] [Setup] [TDD] Add CorsSettings struct to settings.rs - Struct fields: `allowed_origins: Vec`, `allow_credentials: bool`, `max_age_secs: i32` - Implement Default with restrictive settings: `allowed_origins: vec![]`, `allow_credentials: false`, `max_age_secs: 3600` - Add `#[derive(Debug, serde::Deserialize, Clone)]` to struct - Add `#[serde(default)]` attribute to Settings.cors field - Update Settings struct to include `pub cors: CorsSettings` - **File**: backend/src/settings.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T011** [Setup] [TDD] Update development.yaml with permissive CORS settings - Add cors section with `allowed_origins: ["*"]`, `allow_credentials: false`, `max_age_secs: 3600` - Update `frontend_url` from `http://localhost:3000` to `http://localhost:5173` (Vite default port) - **Test**: cargo run loads development config without errors - **File**: backend/settings/development.yaml - **Complexity**: Low | **Uncertainty**: Low - [ ] **T012** [P] [Setup] [TDD] Create production.yaml with restrictive CORS settings - Add cors section with `allowed_origins: ["REACTED"]`, `allow_credentials: true`, `max_age_secs: 3600` - Add `frontend_url: "https://REDACTED"` - Add production-specific application settings (protocol: https, host: 0.0.0.0) - **Test**: Settings::new() with APP_ENVIRONMENT=production loads config - **File**: backend/settings/production.yaml - **Complexity**: Low | **Uncertainty**: Low - [ ] **T013** [Setup] [TDD] Write tests for build_cors() function - Test: build_cors() with wildcard origin creates permissive Cors (allows any origin) - Test: build_cors() with specific origin creates restrictive Cors - Test: build_cors() with `credentials=true` and wildcard origin returns error (browser constraint violation) - Test: build_cors() sets correct methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) - Test: build_cors() sets correct headers (content-type, authorization) - Test: build_cors() sets max_age from settings - **File**: backend/src/startup.rs (in tests module) - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs - Function signature: `fn build_cors(settings: &CorsSettings) -> Cors` - Validate: if `allow_credentials=true` AND `allowed_origins` contains "*", panic with clear error message - Iterate over `allowed_origins` and call `cors.allow_origin()` for each - Hardcode methods: `vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS]` - Hardcode headers: `vec![header::CONTENT_TYPE, header::AUTHORIZATION]` - Set `allow_credentials` from settings - Set `max_age` from settings.max_age_secs - Add structured logging: `tracing::info!(allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, "CORS middleware configured")` - **File**: backend/src/startup.rs - **Complexity**: Medium | **Uncertainty**: Low **Pseudocode**: ```rust fn build_cors(settings: &CorsSettings) -> Cors { // Validation if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) { panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true"); } let mut cors = Cors::new(); // Configure origins for origin in &settings.allowed_origins { cors = cors.allow_origin(origin.as_str()); } // Hardcoded methods (API-specific) cors = cors.allow_methods(vec![ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS ]); // Hardcoded headers (minimum for API) cors = cors.allow_headers(vec![ header::CONTENT_TYPE, header::AUTHORIZATION, ]); // Configure from settings cors = cors .allow_credentials(settings.allow_credentials) .max_age(settings.max_age_secs); tracing::info!( target: "backend::startup", allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, max_age_secs = settings.max_age_secs, "CORS middleware configured" ); cors } ``` - [ ] **T015** [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain - In `From for RunnableApplication`, replace `.with(Cors::new())` with `.with(build_cors(&value.settings.cors))` - Add necessary imports: `poem::http::{Method, header}` - Ensure CORS is applied after rate limiting (order: RateLimit → CORS → Data) - **Test**: Integration test verifies CORS headers are present - **File**: backend/src/startup.rs (line ~86) - **Complexity**: Low | **Uncertainty**: Low - [ ] **T016** [P] [Setup] [TDD] Write integration tests for CORS headers - Test: OPTIONS preflight request to `/api/health` returns correct CORS headers - Test: GET `/api/health` with Origin header returns `Access-Control-Allow-Origin` header - Test: Preflight response includes `Access-Control-Max-Age` matching configuration - Test: Response includes `Access-Control-Allow-Credentials` when configured - Test: Response includes correct `Access-Control-Allow-Methods` (GET, POST, PUT, PATCH, DELETE, OPTIONS) - **File**: backend/tests/integration/cors_test.rs (new file) - **Complexity**: Medium | **Uncertainty**: Low **Checkpoint**: CORS configuration complete, production-ready security with environment-specific settings --- ## 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 - [ ] **T017** [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 - [ ] **T018** [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 - [ ] **T019** [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 - [ ] **T020** [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 - [ ] **T021** [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 - [ ] **T022** [US1] [TDD] Implement Relay aggregate - Struct: Relay { id: RelayId, state: RelayState, label: Option } - Methods: new(), toggle(), turn_on(), turn_off(), state(), label() - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T023** [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 - [ ] **T024** [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 - [ ] **T025** [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 - [ ] **T026** [US1] [TDD] Implement ModbusAddress type with From - #[repr(transparent)] newtype wrapping u16 - Implement From with offset: user 1-8 → Modbus 0-7 - **File**: src/domain/modbus.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T027** [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 - [ ] **T028** [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 - [ ] **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 - 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> - async fn write_all(&self, state: RelayState) → Result<(), ControllerError> - **File**: src/infrastructure/modbus/controller.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **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 - **File**: src/infrastructure/modbus/error.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T032** [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 synchronization, timeout wrapping **Protocol**: Native Modbus TCP (MBAP header, no CRC16 validation) - [ ] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup - Struct: ModbusRelayController { ctx: Arc>, timeout_duration: Duration } - Constructor: new(host, port, slave_id, timeout_secs) → Result - 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>, timeout_duration: Duration, } impl ModbusRelayController { pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64) -> Result { 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, 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, ControllerError> { use tokio::time::timeout; let ctx = self.ctx.lock().await; // tokio-modbus returns nested Results: Result, 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, 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 { 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, 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 --- - [ ] **T034** [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 - [ ] **T035** [P] [US4] [TDD] Write tests for RelayLabelRepository trait - Test: get_label(RelayId(1)) → Option - 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 - [ ] **T036** [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 - [ ] **T037** [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 - [ ] **T038** [US4] [TDD] Implement in-memory mock LabelRepository - HashMap-based implementation - **File**: src/infrastructure/persistence/mock_label_repository.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T039** [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 - [ ] **T040** [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 - [ ] **T041** [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 - [ ] **T042** [US1] [TDD] Implement ToggleRelayUseCase - Orchestrate: read current state → toggle → write new state - **File**: src/application/use_cases/toggle_relay.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T043** [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 - [ ] **T044** [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) - [ ] **T045** [US1] [TDD] Define RelayDto in presentation layer - Fields: id (u8), state ("on"/"off"), label (Option) - Implement From for RelayDto - **File**: src/presentation/dto/relay_dto.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T046** [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 - 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 { 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 - 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, 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 { 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 --- - [ ] **T048** [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 - [ ] **T049** [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 - [ ] **T050** [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 - [ ] **T051** [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 - [ ] **T052** [P] [US1] [TDD] Create RelayDto TypeScript interface - Generate from OpenAPI spec or manually define - **File**: frontend/src/types/relay.ts - **Complexity**: Low | **Uncertainty**: Low - [ ] **T053** [P] [US1] [TDD] Create API client service - getAllRelays(): Promise - toggleRelay(id: number): Promise - **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 = ref([]); const isLoading = ref(true); const error: Ref = ref(null); const lastFetchTime: Ref = 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 --- - [ ] **T055** [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 - [ ] **T056** [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 - [ ] **T057** [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 - [ ] **T058** [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 - [ ] **T059** [US2] [TDD] Implement BulkControlUseCase - Call controller.write_all(state) - **File**: src/application/use_cases/bulk_control.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T060** [US2] [TDD] Define BulkOperation enum - Variants: AllOn, AllOff - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T061** [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 - [ ] **T062** [US2] [TDD] Implement POST /api/relays/all/on endpoint - Call BulkControlUseCase with AllOn - **File**: src/presentation/api/relay_api.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T063** [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 - [ ] **T064** [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 - [ ] **T065** [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 - [ ] **T066** [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 - [ ] **T067** [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 - [ ] **T068** [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 - [ ] **T069** [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 - [ ] **T070** [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 - [ ] **T071** [US3] [TDD] Implement GET /api/health endpoint - Call GetHealthUseCase, map to HealthDto - **File**: src/presentation/api/health_api.rs - **Complexity**: Low | **Uncertainty**: Low - [ ] **T072** [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 - [ ] **T073** [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 - [ ] **T074** [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 - [ ] **T075** [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 - [ ] **T076** [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 - [ ] **T077** [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 - [ ] **T078** [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 - [ ] **T079** [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 - [ ] **T080** [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 - [ ] **T081** [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 - [ ] **T082** [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 - [ ] **T083** [P] Run cargo clippy and fix all warnings - Ensure compliance with strict linting - **Complexity**: Low | **Uncertainty**: Low - [ ] **T084** [P] Run cargo fmt and format all code - **Complexity**: Low | **Uncertainty**: Low - [ ] **T085** Generate test coverage report - Run: just coverage - Ensure > 80% coverage for domain and application layers - **Complexity**: Low | **Uncertainty**: Low - [ ] **T086** [P] Run cargo audit for dependency vulnerabilities - Fix any high/critical vulnerabilities - **Complexity**: Low | **Uncertainty**: Medium - [ ] **T087** [P] Update README.md with deployment instructions - Document environment variables - Document Modbus device configuration - Add quickstart guide - **File**: README.md - **Complexity**: Low | **Uncertainty**: Low - [ ] **T088** [P] Create Docker image for backend - Multi-stage build with Rust - Include SQLite database setup - **File**: Dockerfile - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T089** [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 - [ ] **T090** 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