Add complete SQLite implementation of RelayLabelRepository trait with all CRUD operations (get_label, save_label, delete_label, get_all_labels). Key changes: - Create infrastructure entities module with RelayLabelRecord struct - Implement TryFrom traits for converting between database records and domain types - Add From<sqlx::Error> and From<RelayLabelError> for RepositoryError - Write comprehensive functional tests covering all repository operations - Verify proper handling of edge cases (empty results, overwrites, max length) TDD phase: GREEN - All repository trait tests now passing with SQLite implementation Ref: T036 (specs/001-modbus-relay-control/tasks.md)
1274 lines
52 KiB
Org Mode
1274 lines
52 KiB
Org Mode
#+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
|
|
|
|
--------------
|
|
|
|
* TODO Development phases [4/9]
|
|
** 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<String>=, =allow_credentials: bool=, =max_age_secs: i32=
|
|
- Implement =Default= with restrictive settings: =allowed_origins: vec![]=, =allow_credentials: false=, =max_age_secs: 3600=
|
|
- Add =#[derive(Debug, serde::Deserialize, Clone)]= to struct
|
|
- Add =#[serde(default)]= attribute to Settings.cors field
|
|
- Update =Settings= struct to include =pub cors: CorsSettings=
|
|
- *File*: =backend/src/settings.rs=
|
|
- *Complexity*: Low | *Uncertainty*: Low
|
|
|
|
- [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<Application> 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<CorsSettings> 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<RelayLabel> }=
|
|
- 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
|
|
|
|
--------------
|
|
|
|
** DONE Phase 3: Infrastructure Layer (2 days) [1/1]
|
|
*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<Mutex<HashMap<RelayId, RelayState>>>=
|
|
- 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<RelayState, ControllerError>~
|
|
- ~async fn write_state(&self, id: RelayId, state: RelayState) => Result<(), ControllerError>~
|
|
- ~async fn read_all(&self) => Result<Vec<(RelayId, RelayState)>, ControllerError>~
|
|
- ~async fn write_all(&self, state: RelayState) => Result<(), ControllerError>~
|
|
- *File*: =src/infrastructure/modbus/controller.rs=
|
|
- *Complexity*: Low | *Uncertainty*: Low
|
|
- [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
|
|
|
|
--------------
|
|
|
|
*** DONE T025: ModbusRelayController Implementation (DECOMPOSED) [13/13]
|
|
CLOSED: [2026-01-22 jeu. 00:02]
|
|
- State "DONE" from "STARTED" [2026-01-22 jeu. 00:02]
|
|
- Complexity :: High → Broken into 6 sub-tasks
|
|
- Uncertainty :: High
|
|
- Rationale :: Nested Result handling, =Arc<Mutex>= synchronization, timeout wrapping
|
|
- Protocol :: Native Modbus TCP (MBAP header, no CRC16 validation)
|
|
|
|
- [X] *T025a* [US1] [TDD] Implement =ModbusRelayController= connection setup [3/3]
|
|
|
|
- Struct: =ModbusRelayController { ctx: Arc<Mutex<Context>>, timeout_duration: Duration }=
|
|
- Constructor: =new(host, port, slave_id, timeout_secs) → Result<Self, ControllerError>=
|
|
- Use =tokio_modbus::client::tcp::connect_slave()=
|
|
- *File*: =src/infrastructure/modbus/modbus_controller.rs=
|
|
- *Complexity*: Medium | *Uncertainty*: Medium
|
|
|
|
*Pseudocode*:
|
|
|
|
#+begin_src rust
|
|
pub struct ModbusRelayController {
|
|
ctx: Arc<Mutex<tokio_modbus::client::Context>>,
|
|
timeout_duration: Duration,
|
|
}
|
|
|
|
impl ModbusRelayController {
|
|
pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64)
|
|
-> Result<Self, ControllerError>
|
|
{
|
|
use tokio_modbus::prelude::*;
|
|
|
|
// Connect using native Modbus TCP protocol (port 502)
|
|
let socket_addr = format!("{}:{}", host, port)
|
|
.parse()
|
|
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?;
|
|
|
|
let ctx = tcp::connect_slave(socket_addr, Slave(slave_id))
|
|
.await
|
|
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?;
|
|
|
|
Ok(Self {
|
|
ctx: Arc::new(Mutex::new(ctx)),
|
|
timeout_duration: Duration::from_secs(timeout_secs),
|
|
})
|
|
}
|
|
}
|
|
#+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 [4/4]
|
|
|
|
- Private method: =read_coils_with_timeout(addr: u16, count: u16) → Result<Vec<bool>, ControllerError>=
|
|
- Wrap =ctx.read_coils()= with =tokio::time::timeout()=
|
|
- Handle nested Result: timeout → =io::Error= → Modbus Exception
|
|
- *Note*: Modbus TCP uses MBAP header (no CRC validation needed)
|
|
- *File*: =src/infrastructure/modbus/modbus_controller.rs=
|
|
- *Complexity*: Medium | *Uncertainty*: Medium
|
|
|
|
*Pseudocode* (CRITICAL PATTERN):
|
|
|
|
#+begin_src rust
|
|
async fn read_coils_with_timeout(&self, addr: u16, count: u16)
|
|
-> Result<Vec<bool>, ControllerError>
|
|
{
|
|
use tokio::time::timeout;
|
|
|
|
let ctx = self.ctx.lock().await;
|
|
|
|
// tokio-modbus returns nested Results: Result<Result<T, Exception>, io::Error>
|
|
// We must unwrap 3 layers: timeout → io::Error → Modbus Exception
|
|
|
|
let result = timeout(self.timeout_duration, ctx.read_coils(addr, count))
|
|
.await // Result<Result<Result<Vec<bool>, Exception>, io::Error>, Elapsed>
|
|
.map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? // Handle timeout
|
|
.map_err(|e| ControllerError::ConnectionError(e.to_string()))? // Handle io::Error
|
|
.map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; // Handle Exception
|
|
|
|
Ok(result)
|
|
}
|
|
#+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 [3/3]
|
|
|
|
- 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 [3/3]
|
|
|
|
- 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<RelayState, ControllerError> {
|
|
let addr = ModbusAddress::from(id).as_u16();
|
|
let coils = self.read_coils_with_timeout(addr, 1).await?;
|
|
|
|
Ok(if coils[0] { RelayState::On } else { RelayState::Off })
|
|
}
|
|
}
|
|
#+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 [2/2]
|
|
|
|
- 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()= [3/3]
|
|
|
|
- =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<Vec<(RelayId, RelayState)>, ControllerError> {
|
|
let coils = self.read_coils_with_timeout(0, 8).await?;
|
|
|
|
let mut relays = Vec::new();
|
|
for (idx, &coil_value) in coils.iter().enumerate() {
|
|
let relay_id = RelayId::new((idx + 1) as u8)?;
|
|
let state = if coil_value { RelayState::On } else { RelayState::Off };
|
|
relays.push((relay_id, state));
|
|
}
|
|
Ok(relays)
|
|
}
|
|
|
|
async fn write_all(&self, state: RelayState) -> Result<(), ControllerError> {
|
|
for i in 1..=8 {
|
|
let relay_id = RelayId::new(i)?;
|
|
self.write_state(relay_id, state).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
#+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
|
|
|
|
--------------
|
|
|
|
- [X] *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<RelayLabel>=
|
|
- Test: =set_label(RelayId(1), label) → Result<(), RepositoryError>=
|
|
- Test: =delete_label(RelayId(1)) → Result<(), RepositoryError>=
|
|
- *File*: =src/infrastructure/persistence/label_repository.rs=
|
|
- *Complexity*: Low | *Uncertainty*: Low
|
|
- [X] *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
|
|
- [X] *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
|
|
- [X] *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<dyn RelayController> {
|
|
if use_mock {
|
|
tracing::info!("Using MockRelayController (test mode)");
|
|
return Arc::new(MockRelayController::new());
|
|
}
|
|
|
|
// Retry 3 times with 2s backoff
|
|
for attempt in 1..=3 {
|
|
match ModbusRelayController::new(
|
|
&settings.host,
|
|
settings.port,
|
|
settings.slave_id,
|
|
settings.timeout_secs,
|
|
).await {
|
|
Ok(controller) => {
|
|
tracing::info!("Connected to Modbus device on attempt {}", attempt);
|
|
return Arc::new(controller);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
attempt,
|
|
error = %e,
|
|
"Failed to connect to Modbus device, retrying..."
|
|
);
|
|
if attempt < 3 {
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Graceful degradation: fallback to MockRelayController
|
|
tracing::error!(
|
|
"Could not connect to Modbus device after 3 attempts, \
|
|
using MockRelayController as fallback"
|
|
);
|
|
Arc::new(MockRelayController::new())
|
|
}
|
|
#+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<Arc<dyn RelayLabelRepository>, RepositoryError> {
|
|
if use_mock {
|
|
tracing::info!("Using MockLabelRepository (test mode)");
|
|
return Ok(Arc::new(MockLabelRepository::new()));
|
|
}
|
|
|
|
let db = Database::new(db_path)?;
|
|
Ok(Arc::new(SQLiteLabelRepository::new(db)))
|
|
}
|
|
#+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<Self, StartupError> {
|
|
let use_mock = cfg!(test) || std::env::var("CI").is_ok();
|
|
|
|
// Create dependencies
|
|
let relay_controller = create_relay_controller(&settings.modbus, use_mock).await;
|
|
let label_repository = create_label_repository(&settings.database.path, use_mock)?;
|
|
|
|
// Create API with dependencies
|
|
let relay_api = RelayApi::new(relay_controller, label_repository);
|
|
|
|
// Build OpenAPI service
|
|
let api_service = OpenApiService::new(relay_api, "STA API", "1.0.0")
|
|
.server("http://localhost:8080");
|
|
|
|
let ui = api_service.swagger_ui();
|
|
let spec = api_service.spec();
|
|
|
|
let app = Route::new()
|
|
.nest("/api", api_service)
|
|
.nest("/", ui)
|
|
.at("/openapi.json", poem::endpoint::make_sync(move |_| spec.clone()));
|
|
|
|
Ok(Self { app, settings })
|
|
}
|
|
}
|
|
#+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<RelayDto[]>=
|
|
- =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<RelayDto[]> = ref([]);
|
|
const isLoading = ref(true);
|
|
const error: Ref<string | null> = ref(null);
|
|
const lastFetchTime: Ref<Date | null> = ref(null);
|
|
const isConnected = ref(false);
|
|
|
|
let pollingInterval: number | null = null;
|
|
|
|
// TODO: Implement fetchData, startPolling, stopPolling
|
|
|
|
return {
|
|
relays,
|
|
isLoading,
|
|
error,
|
|
isConnected,
|
|
lastFetchTime,
|
|
refresh: fetchData,
|
|
startPolling,
|
|
stopPolling,
|
|
};
|
|
}
|
|
#+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
|