From 1cb4d5f3fcf701cee3a041c662c6637387cc4582 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Wed, 21 Jan 2026 19:50:03 +0100 Subject: [PATCH] refactor(specs): switch tasks to org format --- specs/001-modbus-relay-control/tasks.md | 1290 ---------------------- specs/001-modbus-relay-control/tasks.org | 1271 +++++++++++++++++++++ 2 files changed, 1271 insertions(+), 1290 deletions(-) delete mode 100644 specs/001-modbus-relay-control/tasks.md create mode 100644 specs/001-modbus-relay-control/tasks.org diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md deleted file mode 100644 index caa4a8a..0000000 --- a/specs/001-modbus-relay-control/tasks.md +++ /dev/null @@ -1,1290 +0,0 @@ -# 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) DONE - -**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) - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - } - ``` - -- [x] **T015** [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain - - In `From for RunnableApplication`, replace `.with(Cors::new())` with `.with(Cors::from(value.settings.cors.clone()))` ✓ - - CORS is applied after rate limiting (order: RateLimit → CORS → Data) ✓ - - **Test**: Unit test verifies CORS middleware uses settings ✓ - - **File**: `backend/src/startup.rs (line 84)` - - **Complexity**: Low | **Uncertainty**: Low - - **Note**: Used `From for Cors` trait instead of `build_cors()` function (better design pattern) - -- [x] **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) ✓ - - Test: Wildcard origin behavior verified ✓ - - Test: Multiple origins are supported ✓ - - Test: Unauthorized origins are rejected with 403 ✓ - - Test: Credentials disabled by default ✓ - - **File**: `backend/tests/cors_test.rs (9 integration tests)` - - **Complexity**: Medium | **Uncertainty**: Low - - **Tests Written**: 9 comprehensive integration tests covering all CORS scenarios - -**Checkpoint**: CORS configuration complete, production-ready security with environment-specific settings - ---- - -## Phase 2: Domain Layer - Type-Driven Development (1 day) DONE - -**Purpose**: Build domain types with 100% test coverage, bottom-to-top - -**⚠️ TDD CRITICAL**: Write failing tests FIRST for every type, then implement - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **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 - -- [x] **T032** [US1] [TDD] Write tests for MockRelayController - - Test: read_relay_state() returns mocked state ✓ - - Test: write_relay_state() updates mocked state ✓ - - Test: read_all_states() returns 8 relays in known state ✓ - - Test: write_relay_state() for all 8 relays independently ✓ - - Test: read_relay_state() with invalid relay ID (type system prevents) ✓ - - Test: concurrent access is thread-safe ✓ - - **File**: `src/infrastructure/modbus/mock_controller.rs` - - **Complexity**: Low | **Uncertainty**: Low - - **Tests Written**: 6 comprehensive tests covering all mock controller scenarios - ---- - -### 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) - -- [x] **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): - - [x] Test: `new()` with valid config connects successfully - - [x] Test: `new()` with invalid host returns ConnectionError - - [x] Test: `new()` stores correct timeout_duration - -- [x] **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**: - - [x] Test: `read_coils_with_timeout()` returns coil values on success - - [x] Test: `read_coils_with_timeout()` returns Timeout error when operation exceeds timeout - - [x] Test: `read_coils_with_timeout()` returns ConnectionError on io::Error - - [x] Test: `read_coils_with_timeout()` returns ModbusException on protocol error - -- [x] **T025c** [US1] [TDD] Implement timeout-wrapped `write_single_coil` helper - - Private method: `write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>` - - Similar nested Result handling as T025b - - **File**: `src/infrastructure/modbus/modbus_controller.rs` - - **Complexity**: Low | **Uncertainty**: Low - - **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**: - - [x] Test: `write_single_coil_with_timeout()` succeeds for valid write - - [x] Test: `write_single_coil_with_timeout()` returns Timeout on slow device - - [x] Test: `write_single_coil_with_timeout()` returns appropriate error on failure - -- [x] **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**: - - [x] Test: `read_state(RelayId(1))` returns On when coil is true - - [x] Test: `read_state(RelayId(1))` returns Off when coil is false - - [x] Test: `read_state()` propagates ControllerError from helper - -- [x] **T025e** [US1] [TDD] Implement `RelayController::write_state()` using helpers - - Convert RelayId → ModbusAddress - - Convert RelayState → bool (On=true, Off=false) - - Call `write_single_coil_with_timeout()` - - **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**: - - [x] Test: `write_state(RelayId(1), RelayState::On)` writes true to coil - - [x] Test: `write_state(RelayId(1), RelayState::Off)` writes false to coil - -- [x] **T025f** [US1] [TDD] Implement `RelayController::read_all()` and `write_all()` - - `read_all()`: Call `read_coils_with_timeout(0, 8)`, map to `Vec<(RelayId, RelayState)>` - - `write_all()`: Loop over RelayId 1-8, call `write_state()` for each - - Add `firmware_version()` method (read holding register 0x9999, optional) - - **File**: `src/infrastructure/modbus/modbus_controller.rs` - - **Complexity**: Medium | **Uncertainty**: Low - - **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**: - - [x] Test: `read_all()` returns 8 relay states - - [x] Test: `write_all(RelayState::On)` turns all relays on - - [x] Test: `write_all(RelayState::Off)` turns all relays off - ---- - -- [ ] **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` - -- [x] **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 - -- [x] **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 - -- [x] **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 diff --git a/specs/001-modbus-relay-control/tasks.org b/specs/001-modbus-relay-control/tasks.org new file mode 100644 index 0000000..bd1f22e --- /dev/null +++ b/specs/001-modbus-relay-control/tasks.org @@ -0,0 +1,1271 @@ +#+title: Implementation Tasks: Modbus Relay Control System +#+author: Lucien Cartier-Tilet +#+email: lucien@phundrak.com +#+options: ^:nil +#+LATEX_CLASS_OPTIONS: [a4paper,10pt] +#+LATEX_HEADER: \makeatletter \@ifpackageloaded{geometry}{\geometry{margin=2cm}}{\usepackage[margin=2cm]{geometry}} \makeatother +#+LATEX_HEADER: \setlength{\parindent}{0pt} +#+latex_header: \sloppy +#+latex_header: \usepackage[none]{hyphenat} +#+latex_header: \raggedright +#+todo: TODO(t) STARTED(s!) | DONE(d!) + +#+begin_src emacs-lisp :exports none :results none +(defun org-summary-todo (n-done n-not-done) + "Switch entry to DONE when all subentries are done, to TODO otherwise." + (let (org-log-done org-todo-log-states) ; turn off logging + (org-todo (if (= n-not-done 0) "DONE" "TODO")))) + +(add-hook 'org-after-todo-statistics-hook #'org-summary-todo) +#+end_src + +- 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 + +-------------- + +* Development phases [0/0] +** DONE Phase 1: Setup & Foundation (0.5 days) [8/8] +*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 + +-------------- + +** DONE Phase 0.5: CORS Configuration & Production Security (0.5 days) [8/8] +*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=) + +- [X] *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 + +- [X] *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 + +- [X] *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 + +- [X] *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 + +- [X] *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*: + + #+begin_src 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 + } + #+end_src + +- [X] *T015* [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain + + - In =From for RunnableApplication=, replace =.with(Cors::new())= with =.with(Cors::from(value.settings.cors.clone()))= ✓ + - CORS is applied after rate limiting (order: RateLimit → CORS → Data) ✓ + - *Test*: Unit test verifies CORS middleware uses settings ✓ + - *File*: =backend/src/startup.rs (line 84)= + - *Complexity*: Low | *Uncertainty*: Low + - *Note*: Used =From for Cors= trait instead of =build_cors()= function (better design pattern) + +- [X] *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) ✓ + - Test: Wildcard origin behavior verified ✓ + - Test: Multiple origins are supported ✓ + - Test: Unauthorized origins are rejected with 403 ✓ + - Test: Credentials disabled by default ✓ + - *File*: =backend/tests/cors_test.rs (9 integration tests)= + - *Complexity*: Medium | *Uncertainty*: Low + - *Tests Written*: 9 comprehensive integration tests covering all CORS scenarios + +*Checkpoint*: CORS configuration complete, production-ready security with environment-specific settings + +-------------- + +** DONE Phase 2: Domain Layer - Type-Driven Development (1 day) [11/11] +*Purpose*: Build domain types with 100% test coverage, bottom-to-top + +*⚠️ TDD CRITICAL*: Write failing tests FIRST for every type, then implement + +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 + +-------------- + +** TODO Phase 3: Infrastructure Layer (2 days) [5/5] +*Purpose*: Implement Modbus client, mocks, and persistence + +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *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 +- [X] *T032* [US1] [TDD] Write tests for =MockRelayController= + - Test: =read_relay_state()= returns mocked state ✓ + - Test: =write_relay_state()= updates mocked state ✓ + - Test: =read_all_states()= returns 8 relays in known state ✓ + - Test: =write_relay_state()= for all 8 relays independently ✓ + - Test: =read_relay_state()= with invalid relay ID (type system prevents) ✓ + - Test: concurrent access is thread-safe ✓ + - *File*: =src/infrastructure/modbus/mock_controller.rs= + - *Complexity*: Low | *Uncertainty*: Low + - *Tests Written*: 6 comprehensive tests covering all mock controller scenarios + +-------------- + +*** STARTED T025: ModbusRelayController Implementation (DECOMPOSED) [9/13] +- 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) + +- [X] *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*: + + #+begin_src 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), + }) + } + } + #+end_src + + *TDD Checklist* (write these tests FIRST): + + - [X] Test: =new()= with valid config connects successfully + - [X] Test: =new()= with invalid host returns =ConnectionError= + - [X] Test: =new()= stores correct timeout_duration + +- [X] *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): + + #+begin_src 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) + } + #+end_src + + *TDD Checklist*: + + - [X] Test: =read_coils_with_timeout()= returns coil values on success + - [X] Test: =read_coils_with_timeout()= returns Timeout error when operation exceeds timeout + - [X] Test: =read_coils_with_timeout()= returns =ConnectionError= on =io::Error= + - [X] Test: =read_coils_with_timeout()= returns =ModbusException= on protocol error + +- [X] *T025c* [US1] [TDD] Implement timeout-wrapped =write_single_coil= helper + + - Private method: =write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>= + - Similar nested Result handling as T025b + - *File*: =src/infrastructure/modbus/modbus_controller.rs= + - *Complexity*: Low | *Uncertainty*: Low + + *Pseudocode*: + + #+begin_src 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(()) + } + #+end_src + + *TDD Checklist*: + + - [X] Test: =write_single_coil_with_timeout()= succeeds for valid write + - [X] Test: =write_single_coil_with_timeout()= returns Timeout on slow device + - [X] Test: =write_single_coil_with_timeout()= returns appropriate error on failure + +- [X] *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*: + + #+begin_src 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 }) + } + } + #+end_src + + *TDD Checklist*: + + - [X] Test: =read_state(RelayId(1))= returns =On= when coil is true + - [X] Test: =read_state(RelayId(1))= returns =Off= when coil is false + - [X] Test: =read_state()= propagates =ControllerError= from helper + +- [X] *T025e* [US1] [TDD] Implement =RelayController::write_state()= using helpers + + - Convert =RelayId= → =ModbusAddress= + - Convert =RelayState= → bool (On=true, Off=false) + - Call =write_single_coil_with_timeout()= + - *File*: =src/infrastructure/modbus/modbus_controller.rs= + - *Complexity*: Low | *Uncertainty*: Low + + *Pseudocode*: + + #+begin_src 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 + } + #+end_src + + *TDD Checklist*: + + - [X] Test: =write_state(RelayId(1), RelayState::On)= writes true to coil + - [X] Test: =write_state(RelayId(1), RelayState::Off)= writes false to coil + +- [X] *T025f* [US1] [TDD] Implement =RelayController::read_all()= and =write_all()= + + - =read_all()=: Call =read_coils_with_timeout(0, 8)=, map to =Vec<(RelayId, RelayState)>= + - =write_all()=: Loop over RelayId 1-8, call =write_state()= for each + - Add =firmware_version()= method (read holding register 0x9999, optional) + - *File*: =src/infrastructure/modbus/modbus_controller.rs= + - *Complexity*: Medium | *Uncertainty*: Low + + *Pseudocode*: + + #+begin_src 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(()) + } + #+end_src + + *TDD Checklist*: + + - [X] Test: =read_all()= returns 8 relay states + - [X] Test: =write_all(RelayState::On)= turns all relays on + - [X] Test: =write_all(RelayState::Off)= turns all relays off + +-------------- + +- [ ] *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= +- [X] *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 +- [X] *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 +- [X] *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 + +-------------- + +** TODO Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [0/5] +*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 + +*** TODO Application Layer [0/4] +- [ ] *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 + +*** TODO Presentation Layer (Backend API) [0/2] +- [ ] *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 + +-------------- + +*** TODO T039: Dependency Injection Setup (DECOMPOSED) [0/8] +- 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*: + + #+begin_src 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()) + } + #+end_src + + *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 =RelayLabelRepositor=y 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*: + + #+begin_src 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))) + } + #+end_src + + *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*: + + #+begin_src 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 }) + } + } + #+end_src + + *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 + +*** TODO Frontend Implementation [0/2] +- [ ] *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 + +-------------- + +*** TODO T046: HTTP Polling Composable (DECOMPOSED) [0/7] +*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*: + + #+begin_src 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, + }; + } + #+end_src + + *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*: + + #+begin_src 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; + } + }; + #+end_src + + *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*: + + #+begin_src 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(); + }); + #+end_src + + *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*: + + #+begin_src typescript + // Already implemented in T046b, just ensure it's exposed + return { + relays, + isLoading, + error, + isConnected, // ← Connection status indicator + lastFetchTime, + refresh: fetchData, + startPolling, + stopPolling, + }; + #+end_src + + *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 + +-------------- + +** TODO Phase 5: US2 - Bulk Relay Controls (0.5 days) [0/9] +*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 + +-------------- + +** TODO Phase 6: US3 - Health Monitoring (1 day) [0/8] +*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 + +-------------- + +** TODO Phase 7: US4 - Relay Labeling (0.5 days) [0/6] +*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 + +-------------- + +** TODO Phase 8: Polish & Deployment (1 day) [0/10] +*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): +#+begin_src sh +# 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)" +#+end_src + +-------------- + +* 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