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)
51 KiB
Implementation Tasks: Modbus Relay Control System
- Development phases
[0/0]- DONE Phase 1: Setup & Foundation (0.5 days)
[8/8] - DONE Phase 0.5: CORS Configuration & Production Security (0.5 days)
[8/8] - DONE Phase 2: Domain Layer - Type-Driven Development (1 day)
[11/11] - Phase 3: Infrastructure Layer (2 days)
[5/5] - Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days)
[0/5] - Phase 5: US2 - Bulk Relay Controls (0.5 days)
[0/9] - Phase 6: US3 - Health Monitoring (1 day)
[0/8] - Phase 7: US4 - Relay Labeling (0.5 days)
[0/6] - Phase 8: Polish & Deployment (1 day)
[0/10]
- DONE Phase 1: Setup & Foundation (0.5 days)
- Dependencies & Execution Order
- Test-Driven Development Workflow
- Notes
- 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
-
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 checkpasses - Complexity: Low | Uncertainty: Low
- Add:
-
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
- Create:
-
T003 [P] [Setup] [TDD] Update
settings.rswith Modbus configuration- Add
ModbusSettingsstruct withhost,port,slave_id,timeout_secsfields - Add
RelaySettingsstruct withlabel_max_lengthfield - Update
Settingsstruct to include modbus and relay fields - Test: Settings loads from
settings/base.yamlwith test Modbus config - Complexity: Low | Uncertainty: Low
- Add
-
T004 [P] [Setup] [TDD] Create
settings/base.yamlwith Modbus defaults- Add modbus section:
host: "192.168.0.200",port: 502,slave_id: 0,timeout_secs: 5 - Add relay section:
label_max_length: 8 - Test:
Settings::new()loads config without errors - Complexity: Low | Uncertainty: Low
- Add modbus section:
-
T005 [P] [Setup] [TDD] Add SQLite schema file
- Create
infrastructure/persistence/schema.sqlwithrelay_labelstable - 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
- Create
-
T006 [P] [Setup] [TDD] Initialize SQLite database module
- Create
infrastructure/persistence/mod.rs - Create
infrastructure/persistence/sqlite_repository.rswithSqliteRelayLabelRepositorystruct - Implement
SqliteRelayLabelRepository::new(path)usingSqlitePool - Test:
SqliteRelayLabelRepository::in_memory()creates in-memory DB with schema - Complexity: Medium | Uncertainty: Low
- Create
-
T007 [P] [Setup] [TDD] Add frontend project scaffolding
- Create
frontend/directory with Vite + Vue 3 + TypeScript - Run:
npm create vite@latest frontend -- --template vue-ts - Install:
axios,@types/node - Test:
npm run devstarts frontend dev server - Complexity: Low | Uncertainty: Low
- Create
-
T008 [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI
- Add poem-openapi spec generation in
startup.rs - Generate
frontend/src/api/client.tsfrom OpenAPI spec - Test: TypeScript client compiles without errors
- Complexity: Medium | Uncertainty: Medium
- Note: May need manual adjustments to generated code
- Add poem-openapi spec generation in
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
-
T009 [P] [Setup] [TDD] Write tests for CorsSettings struct
- Test:
CorsSettingsdeserializes from YAML correctly ✓ - Test: Default
CorsSettingshas emptyallowed_origins(restrictive fail-safe) ✓ - Test:
CorsSettingswith wildcard origin deserializes correctly ✓ - Test:
Settings::new()loads cors section fromdevelopment.yaml✓ - Test:
CorsSettingswith 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)
- Test:
-
T010 [P] [Setup] [TDD] Add CorsSettings struct to settings.rs
- Struct fields:
allowed_origins: Vec<String>,allow_credentials: bool,max_age_secs: i32 - Implement
Defaultwith 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
Settingsstruct to includepub cors: CorsSettings - File:
backend/src/settings.rs - Complexity: Low | Uncertainty: Low
- Struct fields:
-
T011 [Setup] [TDD] Update
development.yamlwith permissive CORS settings- Add cors section with
allowed_origins: ["*"],allow_credentials: false,max_age_secs: 3600 - Update
frontend_urlfromhttp://localhost:3000tohttp://localhost:5173(Vite default port) - Test: cargo run loads development config without errors
- File:
backend/settings/development.yaml - Complexity: Low | Uncertainty: Low
- Add cors section with
-
T012 [P] [Setup] [TDD] Create production.yaml with restrictive CORS settings
- Add cors section with
allowed_origins: ["REACTED"],allow_credentials: true,max_age_secs: 3600 - Add
frontend_url: "https://REDACTED" - Add production-specific application settings (protocol: https, host: 0.0.0.0)
- Test:
Settings::new()withAPP_ENVIRONMENT=productionloads config - File:
backend/settings/production.yaml - Complexity: Low | Uncertainty: Low
- Add cors section with
-
T013 [Setup] [TDD] Write tests for
build_cors()function- Test:
build_cors()with wildcard origin creates permissive Cors (allows any origin) ✓ - Test:
build_cors()with specific origin creates restrictive Cors ✓ - Test:
build_cors()withcredentials=trueand wildcard origin returns error (browser constraint violation) ✓ - Test:
build_cors()sets correct methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) ✓ - Test:
build_cors()sets correct headers (content-type, authorization) ✓ - Test:
build_cors()sets max_age from settings ✓ - File:
backend/src/startup.rs (in tests module) - Complexity: Medium | Uncertainty: Low
- Test:
-
T014 [Setup] [TDD] Implement
build_cors()free function in startup.rs- Function signature:
fn build_cors(settings: &CorsSettings) -> Cors - Validate: if
allow_credentials=trueANDallowed_originscontains “*“, panic with clear error message - Iterate over
allowed_originsand callcors.allow_origin()for each - Hardcode methods:
vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS] - Hardcode headers:
vec![header::CONTENT_TYPE, header::AUTHORIZATION] - Set
allow_credentialsfrom settings - Set
max_agefrom settings.max_age_secs - Add structured logging:
tracing::info!(allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, "CORS middleware configured") - File:
backend/src/startup.rs - Complexity: Medium | Uncertainty: Low
Pseudocode:
fn build_cors(settings: &CorsSettings) -> Cors { // Validation if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) { panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true"); } let mut cors = Cors::new(); // Configure origins for origin in &settings.allowed_origins { cors = cors.allow_origin(origin.as_str()); } // Hardcoded methods (API-specific) cors = cors.allow_methods(vec![ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS ]); // Hardcoded headers (minimum for API) cors = cors.allow_headers(vec![ header::CONTENT_TYPE, header::AUTHORIZATION, ]); // Configure from settings cors = cors .allow_credentials(settings.allow_credentials) .max_age(settings.max_age_secs); tracing::info!( target: "backend::startup", allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, max_age_secs = settings.max_age_secs, "CORS middleware configured" ); cors } - Function signature:
-
T015 [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain
- In
From<Application> for RunnableApplication, replace.with(Cors::new())with.with(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 Corstrait instead ofbuild_cors()function (better design pattern)
- In
-
T016 [P] [Setup] [TDD] Write integration tests for CORS headers
- Test: OPTIONS preflight request to
/api/healthreturns correct CORS headers ✓ - Test: GET
/api/healthwith Origin header returnsAccess-Control-Allow-Originheader ✓ - Test: Preflight response includes
Access-Control-Max-Agematching configuration ✓ - Test: Response includes
Access-Control-Allow-Credentialswhen configured ✓ - Test: Response includes correct
Access-Control-Allow-Methods(GET, POST, PUT, PATCH, DELETE, OPTIONS) ✓ - 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
- Test: OPTIONS preflight request to
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
-
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
- Test:
-
T018 [US1] [TDD] Implement
RelayIdnewtype with validation#[repr(transparent)]newtype wrappingu8- Constructor validates
1..=8range - Implement
Display,Debug,Clone,Copy,PartialEq,Eq - File:
src/domain/relay.rs - Complexity: Low | Uncertainty: Low
-
T019 [P] [US1] [TDD] Write tests for
RelayStateenum- 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
- Test:
-
T020 [P] [US1] [TDD] Implement
RelayStateenum- Enum:
On,Off - Implement
Display,Debug,Clone,Copy,PartialEq,Eq,serde::Serialize/Deserialize - File:
src/domain/relay.rs - Complexity: Low | Uncertainty: Low
- Enum:
-
T021 [US1] [TDD] Write tests for
Relayaggregate- Test:
Relay::new(RelayId(1), RelayState::Off, None)creates relay - Test:
relay.toggle()flips state - Test:
relay.turn_on()sets state toOn - Test:
relay.turn_off()sets state toOff - File:
src/domain/relay.rs - Complexity: Low | Uncertainty: Low
- Test:
-
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
- Struct:
-
T023 [P] [US4] [TDD] Write tests for
RelayLabelnewtype- 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
- Test:
-
T024 [P] [US4] [TDD] Implement
RelayLabelnewtype#[repr(transparent)]newtype wrappingString- Constructor validates
1..=50length - Implement
Display,Debug,Clone,PartialEq,Eq - File:
src/domain/relay.rs - Complexity: Low | Uncertainty: Low
-
T025 [US1] [TDD] Write tests for
ModbusAddresstype- Test:
ModbusAddress::from(RelayId(1))→ModbusAddress(0) - Test:
ModbusAddress::from(RelayId(8))→ModbusAddress(7) - File:
src/domain/modbus.rs - Complexity: Low | Uncertainty: Low
- Test:
-
T026 [US1] [TDD] Implement
ModbusAddresstype with From#[repr(transparent)]newtype wrappingu16- Implement From with offset: user 1-8 → Modbus 0-7
- File:
src/domain/modbus.rs - Complexity: Low | Uncertainty: Low
-
T027 [US3] [TDD] Write tests and implement
HealthStatusenum- Enum:
Healthy,Degraded { consecutive_errors: u32 },Unhealthy { reason: String } - Test transitions between states
- File:
src/domain/health.rs - Complexity: Medium | Uncertainty: Low
- Enum:
Checkpoint: Domain types complete with 100% test coverage
TODO
Phase 3: Infrastructure Layer (2 days) [5/5]
Purpose: Implement Modbus client, mocks, and persistence
-
T028 [P] [US1] [TDD] Write tests for
MockRelayController- Test:
read_state()returns mocked state - Test:
write_state()updates mocked state - Test:
read_all()returns 8 relays in known state - File:
src/infrastructure/modbus/mock_controller.rs - Complexity: Low | Uncertainty: Low
- Test:
-
T029 [P] [US1] [TDD] Implement
MockRelayController- Struct with
Arc<Mutex<HashMap<RelayId, RelayState>>> - Implement
RelayControllertrait with in-memory state - File:
src/infrastructure/modbus/mock_controller.rs - Complexity: Low | Uncertainty: Low
- Struct with
-
T030 [US1] [TDD] Define
RelayControllertraitasync fn read_state(&self, id: RelayId) => Result<RelayState, ControllerError>async fn write_state(&self, id: RelayId, state: RelayState) => Result<(), ControllerError>async fn read_all(&self) => Result<Vec<(RelayId, RelayState)>, ControllerError>async fn write_all(&self, state: RelayState) => Result<(), ControllerError>- File:
src/infrastructure/modbus/controller.rs - Complexity: Low | Uncertainty: Low
-
T031 [P] [US1] [TDD] Define
ControllerErrorenum- Variants:
ConnectionError(String),Timeout(u64),ModbusException(String),InvalidRelayId(u8) - Implement
std::error::Error,Display,Debug - Use
thiserrorderive macros - File:
src/infrastructure/modbus/error.rs - Complexity: Low | Uncertainty: Low
- Variants:
-
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
- Test:
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)
- ?
-
T025a [US1] [TDD] Implement
ModbusRelayControllerconnection 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:
pub struct ModbusRelayController { ctx: Arc<Mutex<tokio_modbus::client::Context>>, timeout_duration: Duration, } impl ModbusRelayController { pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64) -> Result<Self, ControllerError> { use tokio_modbus::prelude::*; // Connect using native Modbus TCP protocol (port 502) let socket_addr = format!("{}:{}", host, port) .parse() .map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?; let ctx = tcp::connect_slave(socket_addr, Slave(slave_id)) .await .map_err(|e| ControllerError::ConnectionError(e.to_string()))?; Ok(Self { ctx: Arc::new(Mutex::new(ctx)), timeout_duration: Duration::from_secs(timeout_secs), }) } }TDD Checklist (write these tests FIRST):
- Test:
new()with valid config connects successfully - Test:
new()with invalid host returnsConnectionError - Test:
new()stores correct timeout_duration
- Struct:
- ?
-
T025b [US1] [TDD] Implement timeout-wrapped
read_coilshelper[4/4]- Private method:
read_coils_with_timeout(addr: u16, count: u16) → Result<Vec<bool>, ControllerError> - Wrap
ctx.read_coils()withtokio::time::timeout() - Handle nested Result: timeout →
io::Error→ Modbus Exception - Note: Modbus TCP uses MBAP header (no CRC validation needed)
- File:
src/infrastructure/modbus/modbus_controller.rs - Complexity: Medium | Uncertainty: Medium
Pseudocode (CRITICAL PATTERN):
async fn read_coils_with_timeout(&self, addr: u16, count: u16) -> Result<Vec<bool>, ControllerError> { use tokio::time::timeout; let ctx = self.ctx.lock().await; // tokio-modbus returns nested Results: Result<Result<T, Exception>, io::Error> // We must unwrap 3 layers: timeout → io::Error → Modbus Exception let result = timeout(self.timeout_duration, ctx.read_coils(addr, count)) .await // Result<Result<Result<Vec<bool>, Exception>, io::Error>, Elapsed> .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? // Handle timeout .map_err(|e| ControllerError::ConnectionError(e.to_string()))? // Handle io::Error .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; // Handle Exception Ok(result) }TDD Checklist:
- Test:
read_coils_with_timeout()returns coil values on success - Test:
read_coils_with_timeout()returns Timeout error when operation exceeds timeout - Test:
read_coils_with_timeout()returnsConnectionErroronio::Error - Test:
read_coils_with_timeout()returnsModbusExceptionon protocol error
- Private method:
- ?
-
T025c [US1] [TDD] Implement timeout-wrapped
write_single_coilhelper[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:
async fn write_single_coil_with_timeout(&self, addr: u16, value: bool) -> Result<(), ControllerError> { use tokio::time::timeout; let ctx = self.ctx.lock().await; timeout(self.timeout_duration, ctx.write_single_coil(addr, value)) .await .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? .map_err(|e| ControllerError::ConnectionError(e.to_string()))? .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; Ok(()) }TDD Checklist:
- Test:
write_single_coil_with_timeout()succeeds for valid write - Test:
write_single_coil_with_timeout()returns Timeout on slow device - Test:
write_single_coil_with_timeout()returns appropriate error on failure
- Private method:
- ?
-
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:
#[async_trait] impl RelayController for ModbusRelayController { async fn read_state(&self, id: RelayId) -> Result<RelayState, ControllerError> { let addr = ModbusAddress::from(id).as_u16(); let coils = self.read_coils_with_timeout(addr, 1).await?; Ok(if coils[0] { RelayState::On } else { RelayState::Off }) } }TDD Checklist:
- Test:
read_state(RelayId(1))returnsOnwhen coil is true - Test:
read_state(RelayId(1))returnsOffwhen coil is false - Test:
read_state()propagatesControllerErrorfrom helper
- Convert
- ?
-
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:
async fn write_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> { let addr = ModbusAddress::from(id).as_u16(); let value = matches!(state, RelayState::On); self.write_single_coil_with_timeout(addr, value).await }TDD Checklist:
- Test:
write_state(RelayId(1), RelayState::On)writes true to coil - Test:
write_state(RelayId(1), RelayState::Off)writes false to coil
- Convert
- ?
-
T025f [US1] [TDD] Implement
RelayController::read_all()andwrite_all()[3/3]read_all(): Callread_coils_with_timeout(0, 8), map toVec<(RelayId, RelayState)>write_all(): Loop over RelayId 1-8, callwrite_state()for each- Add
firmware_version()method (read holding register 0x9999, optional) - File:
src/infrastructure/modbus/modbus_controller.rs - Complexity: Medium | Uncertainty: Low
Pseudocode:
async fn read_all(&self) -> Result<Vec<(RelayId, RelayState)>, ControllerError> { let coils = self.read_coils_with_timeout(0, 8).await?; let mut relays = Vec::new(); for (idx, &coil_value) in coils.iter().enumerate() { let relay_id = RelayId::new((idx + 1) as u8)?; let state = if coil_value { RelayState::On } else { RelayState::Off }; relays.push((relay_id, state)); } Ok(relays) } async fn write_all(&self, state: RelayState) -> Result<(), ControllerError> { for i in 1..=8 { let relay_id = RelayId::new(i)?; self.write_state(relay_id, state).await?; } Ok(()) }TDD Checklist:
- Test:
read_all()returns 8 relay states - Test:
write_all(RelayState::On)turns all relays on - Test:
write_all(RelayState::Off)turns all relays off
-
T034 [US1] [TDD] Integration test with real hardware (optional)
- REQUIRES PHYSICAL DEVICE: Test against actual Modbus relay at configured IP
- Skip if device unavailable, rely on
MockRelayControllerfor CI - File:
tests/integration/modbus_hardware_test.rs - Complexity: Medium | Uncertainty: High
- Note: Use
#[ignore]attribute, run withcargo test -- --ignored
-
T035 [P] [US4] [TDD] Write tests for
RelayLabelRepositorytrait- 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
- Test:
-
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
- Implement
-
T037 [US4] [TDD] Write tests for in-memory mock
LabelRepository- For testing without SQLite dependency
- File:
src/infrastructure/persistence/mock_label_repository.rs - Complexity: Low | Uncertainty: Low
-
T038 [US4] [TDD] Implement in-memory mock
LabelRepository- HashMap-based implementation
- File:
src/infrastructure/persistence/mock_label_repository.rs - Complexity: Low | Uncertainty: Low
-
T039 [US3] [TDD] Write tests for
HealthMonitorservice- Test:
track_success()transitionsDegraded→Healthy - Test:
track_failure()transitionsHealthy→Degraded→Unhealthy - File:
src/application/health_monitor.rs - Complexity: Medium | Uncertainty: Low
- Test:
-
T040 [US3] [TDD] Implement
HealthMonitorservice- 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
- Test:
-
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
- Test:
-
T044 [P] [US1] [TDD] Implement
GetAllRelaysUseCase- Call
controller.read_all(), map to domainRelayobjects - File:
src/application/use_cases/get_all_relays.rs - Complexity: Low | Uncertainty: Low
- Call
TODO
Presentation Layer (Backend API) [0/2]
-
T045 [US1] [TDD] Define
RelayDtoin presentation layer- Fields:
id(u8),state("on"/"off"),label(Option) - Implement
FromforRelayDto - File:
src/presentation/dto/relay_dto.rs - Complexity: Low | Uncertainty: Low
- Fields:
-
T046 [US1] [TDD] Define API error responses
ApiErrorenum 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
ModbusRelayControllerfactory 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
MockRelayControllerif all retries fail (FR-023) - File:
src/infrastructure/modbus/factory.rs - Complexity: Medium | Uncertainty: Medium
Pseudocode:
pub async fn create_relay_controller( settings: &ModbusSettings, use_mock: bool, ) -> Arc<dyn RelayController> { if use_mock { tracing::info!("Using MockRelayController (test mode)"); return Arc::new(MockRelayController::new()); } // Retry 3 times with 2s backoff for attempt in 1..=3 { match ModbusRelayController::new( &settings.host, settings.port, settings.slave_id, settings.timeout_secs, ).await { Ok(controller) => { tracing::info!("Connected to Modbus device on attempt {}", attempt); return Arc::new(controller); } Err(e) => { tracing::warn!( attempt, error = %e, "Failed to connect to Modbus device, retrying..." ); if attempt < 3 { tokio::time::sleep(Duration::from_secs(2)).await; } } } } // Graceful degradation: fallback to MockRelayController tracing::error!( "Could not connect to Modbus device after 3 attempts, \ using MockRelayController as fallback" ); Arc::new(MockRelayController::new()) }TDD Checklist:
- Test: use_mock=true returns
MockRelayControllerimmediately - 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
- Factory function:
- ?
-
T039b [US4] [TDD] Create =RelayLabelRepositor=y factory
- Factory function:
create_label_repository(db_path, use_mock) => Arc - If use_mock: return
MockLabelRepository - Else: return
SQLiteLabelRepositoryconnected todb_path - File:
src/infrastructure/persistence/factory.rs - Complexity: Low | Uncertainty: Low
Pseudocode:
pub fn create_label_repository( db_path: &str, use_mock: bool, ) -> Result<Arc<dyn RelayLabelRepository>, RepositoryError> { if use_mock { tracing::info!("Using MockLabelRepository (test mode)"); return Ok(Arc::new(MockLabelRepository::new())); } let db = Database::new(db_path)?; Ok(Arc::new(SQLiteLabelRepository::new(db))) }TDD Checklist:
- Test: use_mock=true returns
MockLabelRepository - Test: use_mock=false returns
SQLiteLabelRepository - Test: Invalid
db_pathreturnsRepositoryError
- Factory function:
- ?
-
T039c [US1] [TDD] Wire dependencies in
Application::build()- Determine test mode:
cfg!(test) || env::var("CI").is_ok() - Call
create_relay_controller()andcreate_label_repository() - Pass dependencies to
RelayApi::new() - File:
src/startup.rs - Complexity: Medium | Uncertainty: Low
Pseudocode:
impl Application { pub async fn build(settings: Settings) -> Result<Self, StartupError> { let use_mock = cfg!(test) || std::env::var("CI").is_ok(); // Create dependencies let relay_controller = create_relay_controller(&settings.modbus, use_mock).await; let label_repository = create_label_repository(&settings.database.path, use_mock)?; // Create API with dependencies let relay_api = RelayApi::new(relay_controller, label_repository); // Build OpenAPI service let api_service = OpenApiService::new(relay_api, "STA API", "1.0.0") .server("http://localhost:8080"); let ui = api_service.swagger_ui(); let spec = api_service.spec(); let app = Route::new() .nest("/api", api_service) .nest("/", ui) .at("/openapi.json", poem::endpoint::make_sync(move |_| spec.clone())); Ok(Self { app, settings }) } }TDD Checklist:
- Test:
Application::build()succeeds in test mode - Test:
Application::build()creates correct mock dependencies when CI=true - Test:
Application::build()creates real dependencies when not in test mode
- Determine test mode:
- ?
-
T039d [US1] [TDD] Register
RelayApiin route aggregator- Add
RelayApito OpenAPI service - Tag: "Relays"
- File:
src/startup.rs - Complexity: Low | Uncertainty: Low
TDD Checklist:
- Test: OpenAPI spec includes
/api/relaysendpoints - Test: Swagger UI renders
Relaystag
- Add
-
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
- Test: Returns 200 with array of 8
-
T049 [US1] [TDD] Implement
GET /api/relaysendpoint#[oai(path = "/relays", method = "get")]- Call
GetAllRelaysUseCase, map toRelayDto - 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
- Test: Returns 200 with updated
-
T051 [US1] [TDD] Implement
POST /api/relays/{id}/toggleendpoint#[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
RelayDtoTypeScript 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
- getAllRelays():
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
useRelayPollingcomposable structure- Setup reactive refs:
relays,isLoading,error,lastFetchTime - Define interval variable and fetch function signature
- File:
frontend/src/composables/useRelayPolling.ts - Complexity: Low | Uncertainty: Low
Pseudocode:
import { ref, Ref } from 'vue'; import type { RelayDto } from '@/types/relay'; export function useRelayPolling(intervalMs: number = 2000) { const relays: Ref<RelayDto[]> = ref([]); const isLoading = ref(true); const error: Ref<string | null> = ref(null); const lastFetchTime: Ref<Date | null> = ref(null); const isConnected = ref(false); let pollingInterval: number | null = null; // TODO: Implement fetchData, startPolling, stopPolling return { relays, isLoading, error, isConnected, lastFetchTime, refresh: fetchData, startPolling, stopPolling, }; }TDD Checklist:
- Test: Composable returns correct reactive refs
- Test: Initial state is
loading=true,relays=[],error=null
- Setup reactive refs:
-
T046b [US1] [TDD] Implement
fetchDatawith parallel requests- Fetch relays and health status in parallel using
Promise.all - Update reactive state on success
- Handle errors gracefully, set
isConnectedbased on success - File:
frontend/src/composables/useRelayPolling.ts - Complexity: Medium | Uncertainty: Low
Pseudocode:
const fetchData = async () => { try { const [relayData, healthData] = await Promise.all([ apiClient.getAllRelays(), apiClient.getHealth(), ]); relays.value = relayData.relays; isConnected.value = healthData.status === 'healthy'; lastFetchTime.value = new Date(); error.value = null; } catch (err: any) { error.value = err.message || 'Failed to fetch relay data'; isConnected.value = false; console.error('Polling error:', err); } finally { isLoading.value = false; } };TDD Checklist:
- Test:
fetchData()updates relays on success - Test:
fetchData()sets error on API failure - Test:
fetchData()setsisLoading=falseafter completion - Test:
fetchData()updateslastFetchTime
- Fetch relays and health status in parallel using
-
T046c [US1] [TDD] Implement polling lifecycle with cleanup
startPolling(): Fetch immediately, thensetIntervalstopPolling():clearIntervalandcleanup- Use
onMounted/onUnmountedfor automatic lifecycle management - File:
frontend/src/composables/useRelayPolling.ts - Complexity: Medium | Uncertainty: Low
Pseudocode:
import { onMounted, onUnmounted } from 'vue'; const startPolling = () => { if (pollingInterval !== null) return; // Already polling fetchData(); // Immediate first fetch pollingInterval = window.setInterval(fetchData, intervalMs); }; const stopPolling = () => { if (pollingInterval !== null) { clearInterval(pollingInterval); pollingInterval = null; } }; // CRITICAL: Lifecycle cleanup to prevent memory leaks onMounted(() => { startPolling(); }); onUnmounted(() => { stopPolling(); });TDD Checklist:
- Test:
startPolling()triggers immediate fetch - Test:
startPolling()sets interval for subsequent fetches - Test:
stopPolling()clears interval - Test:
onUnmountedhook callsstopPolling()
-
T046d [US1] [TDD] Add connection status tracking
- Track
isConnectedbased on fetch success/failure - Display connection status in UI
- File:
frontend/src/composables/useRelayPolling.ts - Complexity: Low | Uncertainty: Low
Pseudocode:
// Already implemented in T046b, just ensure it's exposed return { relays, isLoading, error, isConnected, // ← Connection status indicator lastFetchTime, refresh: fetchData, startPolling, stopPolling, };TDD Checklist:
- Test:
isConnectedis true after successful fetch - Test:
isConnectedis false after failed fetch
- Track
-
T055 [US1] [TDD] Create
RelayCardcomponent- Props: relay (
RelayDto) - Display relay ID, state, label
- Emit toggle event on button click
- File:
frontend/src/components/RelayCard.vue - Complexity: Low | Uncertainty: Low
- Props: relay (
-
T056 [US1] [TDD] Create
RelayGridcomponent- Use
useRelayPollingcomposable - Render 8 RelayCard components
- Handle toggle events by calling API
- Display loading/error states
- File:
frontend/src/components/RelayGrid.vue - Complexity: Medium | Uncertainty: Low
- Use
-
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
- Test:
-
T059 [US2] [TDD] Implement
BulkControlUseCase- Call
controller.write_all(state) - File:
src/application/use_cases/bulk_control.rs - Complexity: Low | Uncertainty: Low
- Call
-
T060 [US2] [TDD] Define
BulkOperationenum- Variants:
AllOn,AllOff - File:
src/domain/relay.rs - Complexity: Low | Uncertainty: Low
- Variants:
-
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/onendpoint- Call
BulkControlUseCasewithAllOn - File:
src/presentation/api/relay_api.rs - Complexity: Low | Uncertainty: Low
- Call
-
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/offendpoint- Call
BulkControlUseCasewithAllOff - File:
src/presentation/api/relay_api.rs - Complexity: Low | Uncertainty: Low
- Call
-
T065 [US2] [TDD] Add bulk control buttons to frontend
- Add "All On" and "All Off" buttons to
RelayGridcomponent - Call API endpoints and refresh relay states
- File:
frontend/src/components/RelayGrid.vue - Complexity: Low | Uncertainty: Low
- Add "All On" and "All Off" buttons to
-
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
Healthywhen controller is responsive - Test: Returns
Degradedafter 3 consecutive errors - Test: Returns
Unhealthyafter 10 consecutive errors - File:
src/application/use_cases/get_health.rs - Complexity: Medium | Uncertainty: Low
- Test: Returns
-
T068 [US3] [TDD] Implement
GetHealthUseCase- Use
HealthMonitorto track controller status - Return current
HealthStatus - File:
src/application/use_cases/get_health.rs - Complexity: Medium | Uncertainty: Low
- Use
-
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
- Test: Returns 200 with
-
T071 [US3] [TDD] Implement
GET /api/healthendpoint- Call
GetHealthUseCase, map toHealthDto - File:
src/presentation/api/health_api.rs - Complexity: Low | Uncertainty: Low
- Call
-
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
- If controller supports
-
T073 [US3] [TDD] Create
HealthIndicatorcomponent- 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
HealthIndicatorinRelayGrid- Fetch health status in
useRelayPollingcomposable - Pass to
HealthIndicatorcomponent - File:
frontend/src/components/RelayGrid.vue - Complexity: Low | Uncertainty: Low
- Fetch health status in
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:
executewith empty label returns error - Test:
executewith 51-char label returns error - File:
src/application/use_cases/set_label.rs - Complexity: Low | Uncertainty: Low
- Test:
-
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
- Validate label with
-
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}/labelendpoint- Parse id and label, call
SetLabelUseCase - File:
src/presentation/api/relay_api.rs - Complexity: Low | Uncertainty: Low
- Parse id and label, call
-
T079 [US4] [TDD] Add label editing to
RelayCardcomponent- 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 clippyand fix all warnings- Ensure compliance with strict linting
- Complexity: Low | Uncertainty: Low
-
T084 [P] Run
cargo fmtand 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
- Run:
-
T086 [P] Run
cargo auditfor dependency vulnerabilities- Fix any high/critical vulnerabilities
- Complexity: Low | Uncertainty: Medium
-
T087 [P] Update
README.mdwith deployment instructions- Document environment variables
- Document Modbus device configuration
- Add quickstart guide
- File:
README.md - Complexity: Low | Uncertainty: Low
-
T088 [P] Create Docker image for backend
- Multi-stage build with Rust
- Include SQLite database setup
- File:
Dockerfile - Complexity: Medium | Uncertainty: Low
-
T089 [P] Create production
settings/production.yaml- Configure for actual device IP
- Set appropriate timeouts and retry settings
- File:
settings/production.yaml - Complexity: Low | Uncertainty: Low
-
T090 Deploy to production environment
- Test with actual Modbus relay device
- Verify all user stories work end-to-end
- Complexity: Medium | Uncertainty: High
Checkpoint: Production ready, all user stories validated
Dependencies & Execution Order
Phase Dependencies
- Phase 1 (Setup): No dependencies - start immediately
- Phase 2 (Domain TyDD): Depends on Phase 1 module structure
- Phase 3 (Infrastructure): Depends on Phase 2 domain types
- Phase 4 (US1 MVP): Depends on Phase 3 infrastructure
- Phase 5 (US2): Depends on Phase 4 backend API complete
- Phase 6 (US3): Depends on Phase 4 backend API complete (can parallelize with US2)
- Phase 7 (US4): Depends on Phase 4 backend API complete (can parallelize with US2/US3)
- Phase 8 (Polish): Depends on all desired user stories complete
User Story Independence
- US1: No dependencies on other stories
- US2: Reuses US1 backend infrastructure, but independently testable
- US3: Reuses US1 backend infrastructure, but independently testable
- US4: Reuses US1 backend infrastructure, adds new persistence layer
Critical Path
MVP (US1 only): Phase 1 → Phase 2 → Phase 3 → Phase 4 (5 days) Full Feature: MVP + Phase 5 + Phase 6 + Phase 7 + Phase 8 (7 days)
Parallel Opportunities
- Phase 1: T002, T003, T004, T005, T006, T007, T008 can run in parallel
- Phase 2: T011, T015 can run in parallel
- Phase 3: T020, T027, T028, T029, T030 can run in parallel after T022 complete
- Phase 4: T035, T044, T045 can run in parallel
- After Phase 4: US2, US3, US4 can be developed in parallel by different developers
- Phase 8: T073, T074, T075, T076, T078, T079, T080, T081 can run in parallel
Total Parallelizable Tasks: 35 tasks marked [P]
Test-Driven Development Workflow
CRITICAL: For every task marked [TDD], follow this exact sequence:
- Write failing test FIRST (red)
- Verify test fails for the right reason
- Implement minimum code to pass test (green)
- Refactor while keeping tests green
- Commit after each task or logical group
Example TDD Workflow (T010):
# 1. Write failing test for RelayId::new() validation
# In src/domain/relay.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relay_id_valid_range() {
assert!(RelayId::new(1).is_ok());
assert!(RelayId::new(8).is_ok());
}
#[test]
fn test_relay_id_invalid_range() {
assert!(RelayId::new(0).is_err());
assert!(RelayId::new(9).is_err());
}
}
# 2. Run test → VERIFY IT FAILS
cargo test test_relay_id
# 3. Implement RelayId to make test pass
# 4. Run test again → VERIFY IT PASSES
# 5. Refactor if needed, keep tests green
# 6. Commit
jj describe -m "feat: implement RelayId with validation (T010)"
Notes
- [P] = Parallelizable (different files, no dependencies)
- [US1/US2/US3/US4] = User story mapping for traceability
- [TDD] = Test-Driven Development required
- Complexity: Low (< 1 hour) | Medium (1-3 hours) | High (> 3 hours or decomposed)
- Uncertainty: Low (clear path) | Medium (some unknowns) | High (requires research/spike)
- Commit after each task or logical group using
jj describeorjj commit - MVP delivery at task T049 (end of Phase 4)
- Stop at any checkpoint to independently validate user story