Files
sta/specs/001-modbus-relay-control/tasks.org
Lucien Cartier-Tilet 0730a477f8 feat(application): HealthMonitor service and hardware integration test
Add HealthMonitor service for tracking system health status with
comprehensive state transition logic and thread-safe operations.
Includes 16 unit tests covering all functionality including concurrent
access scenarios.

Add optional Modbus hardware integration tests with 7 test cases for
real device testing. Tests are marked as ignored and can be run with

running 21 tests
test infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure ... FAILED
test infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device ... FAILED
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response ... FAILED
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false ... FAILED
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error ... FAILED
test infrastructure::modbus::client::tests::t025a_connection_setup_tests::test_new_with_valid_config_connects_successfully ... ok
test infrastructure::modbus::client::tests::t025a_connection_setup_tests::test_new_stores_correct_timeout_duration ... ok
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_coil_values_on_success ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_9_states_returns_invalid_input ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_empty_vector_returns_invalid_input ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_can_toggle_relay_multiple_times ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_8_states_succeeds ... ok
test infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_succeeds_for_valid_write ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_correctly_maps_relay_id_to_modbus_address ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_7_states_returns_invalid_input ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_on_writes_true_to_coil ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_correctly_maps_relay_id_to_modbus_address ... ok

failures:

---- infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure stdout ----

thread 'infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure' (1157113) panicked at backend/src/infrastructure/modbus/client_test.rs:320:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device stdout ----

thread 'infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device' (1157114) panicked at backend/src/infrastructure/modbus/client_test.rs:293:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response stdout ----

thread 'infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response' (1157112) panicked at backend/src/infrastructure/modbus/client_test.rs:176:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error stdout ----

thread 'infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error' (1157111) panicked at backend/src/infrastructure/modbus/client_test.rs:227:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true stdout ----

thread 'infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true' (1157119) panicked at backend/src/infrastructure/modbus/client_test.rs:354:14:
Failed to connect to test server: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error stdout ----

thread 'infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error' (1157117) panicked at backend/src/infrastructure/modbus/client_test.rs:396:14:
Failed to connect to test server: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false stdout ----

thread 'infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false' (1157118) panicked at backend/src/infrastructure/modbus/client_test.rs:375:14:
Failed to connect to test server: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error stdout ----

thread 'infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error' (1157110) panicked at backend/src/infrastructure/modbus/client_test.rs:202:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil stdout ----

thread 'infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil' (1157122) panicked at backend/src/infrastructure/modbus/client_test.rs:508:9:
assertion `left == right` failed: Relay should be Off after writing Off state
  left: On
 right: Off


failures:
    infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error
    infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error
    infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response
    infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure
    infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device
    infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error
    infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false
    infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true
    infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil

test result: FAILED. 12 passed; 9 failed; 0 ignored; 0 measured; 128 filtered out; finished in 3.27s.

Ref: T034, T039, T040 (specs/001-modbus-relay-control/tasks.org)
2026-01-22 00:39:10 +01:00

1272 lines
51 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
--------------
* 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<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
--------------
** 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<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
--------------
*** STARTED T025: ModbusRelayController Implementation (DECOMPOSED) [12/13]
- 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
- [ ] *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