Set up CORS policy to allow requests from frontend development server and update development.yaml with proper frontend origin URL configuration. Ref: T011 (specs/001-modbus-relay-control)
50 KiB
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
-
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
-
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
-
T003 [P] [Setup] [TDD] Update settings.rs with Modbus configuration
- Add ModbusSettings struct with
host,port,slave_id,timeout_secsfields - Add RelaySettings struct with
label_max_lengthfield - Update Settings struct to include modbus and relay fields
- Test: Settings loads from settings/base.yaml with test Modbus config
- Complexity: Low | Uncertainty: Low
- Add ModbusSettings struct with
-
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
- Add modbus section:
-
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 devstarts 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 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
-
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<String>,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
- Struct fields:
-
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_urlfromhttp://localhost:3000tohttp://localhost:5173(Vite default port) - Test: cargo run loads development config without errors
- File: backend/settings/development.yaml
- Complexity: Low | Uncertainty: Low
- Add cors section with
-
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
- Add cors section with
-
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=trueand 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=trueANDallowed_originscontains "*", panic with clear error message - Iterate over
allowed_originsand callcors.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_credentialsfrom settings - Set
max_agefrom 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:
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 } - Function signature:
-
T015 [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain
- In
From<Application> 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
- In
-
T016 [P] [Setup] [TDD] Write integration tests for CORS headers
- Test: OPTIONS preflight request to
/api/healthreturns correct CORS headers - Test: GET
/api/healthwith Origin header returnsAccess-Control-Allow-Originheader - Test: Preflight response includes
Access-Control-Max-Agematching configuration - Test: Response includes
Access-Control-Allow-Credentialswhen 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
- Test: OPTIONS preflight request to
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<Mutex<HashMap<RelayId, RelayState>>>
- 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<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
-
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<Mutex>, 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:
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, 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):
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:
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:
#[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:
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:
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
-
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:
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
- If use_mock: return MockLabelRepository
- Else: return SQLiteLabelRepository connected to db_path
- File: src/infrastructure/persistence/factory.rs
- Complexity: Low | Uncertainty: Low
Pseudocode:
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:
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
-
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<RelayDto[]>
- 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:
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:
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:
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:
// 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
- Phase 1 (Setup): No dependencies - start immediately
- Phase 2 (Domain TyDD): Depends on Phase 1 module structure
- Phase 3 (Infrastructure): Depends on Phase 2 domain types
- Phase 4 (US1 MVP): Depends on Phase 3 infrastructure
- Phase 5 (US2): Depends on Phase 4 backend API complete
- Phase 6 (US3): Depends on Phase 4 backend API complete (can parallelize with US2)
- Phase 7 (US4): Depends on Phase 4 backend API complete (can parallelize with US2/US3)
- 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:
- Write failing test FIRST (red)
- Verify test fails for the right reason
- Implement minimum code to pass test (green)
- Refactor while keeping tests green
- Commit after each task or logical group
Example TDD Workflow (T010):
# 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 describeorjj commit - MVP delivery at task T049 (end of Phase 4)
- Stop at any checkpoint to independently validate user story