#+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