Tests cover validation requirements for the RelayId newtype: - Valid relay IDs (1-8 for 8-channel controller) - Invalid IDs outside valid range - Smart constructor error handling - Type-safe ID representation TDD red phase: Tests fail until RelayId is implemented. Ref: T017 (specs/001-modbus-relay-control/tasks.md)
1289 lines
50 KiB
Markdown
1289 lines
50 KiB
Markdown
# Implementation Tasks: Modbus Relay Control System
|
|
|
|
**Feature**: 001-modbus-relay-control
|
|
**Total Tasks**: 102 tasks across 9 phases
|
|
**MVP Delivery**: Phase 4 complete (Task 57)
|
|
**Parallelizable Tasks**: 39 tasks marked with `[P]`
|
|
**Approach**: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first
|
|
|
|
---
|
|
|
|
## Phase 1: Setup & Foundation (0.5 days) DONE
|
|
|
|
**Purpose**: Initialize project dependencies and directory structure
|
|
|
|
- [x] **T001** [Setup] [TDD] Add Rust dependencies to Cargo.toml
|
|
- Add: tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] }, sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }, mockall = "0.13", async-trait = "0.1"
|
|
- **Test**: cargo check passes
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [x] **T002** [P] [Setup] [TDD] Create module structure in src/
|
|
- Create: src/domain/, src/application/, src/infrastructure/, src/presentation/
|
|
- **Test**: Module declarations compile without errors
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [x] **T003** [P] [Setup] [TDD] Update settings.rs with Modbus configuration
|
|
- Add ModbusSettings struct with `host`, `port`, `slave_id`, `timeout_secs` fields
|
|
- Add RelaySettings struct with `label_max_length` field
|
|
- Update Settings struct to include modbus and relay fields
|
|
- **Test**: Settings loads from settings/base.yaml with test Modbus config
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [x] **T004** [P] [Setup] [TDD] Create settings/base.yaml with Modbus defaults
|
|
- Add modbus section: `host: "192.168.0.200"`, `port: 502`, `slave_id: 0`, `timeout_secs: 5`
|
|
- Add relay section: `label_max_length: 8`
|
|
- **Test**: Settings::new() loads config without errors
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [x] **T005** [P] [Setup] [TDD] Add SQLite schema file
|
|
- Create infrastructure/persistence/schema.sql with relay_labels table
|
|
- Table: `relay_labels (relay_id INTEGER PRIMARY KEY CHECK(relay_id BETWEEN 1 AND 8), label TEXT NOT NULL CHECK(length(label) <= 50))`
|
|
- **Test**: Schema file syntax is valid SQL
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [x] **T006** [P] [Setup] [TDD] Initialize SQLite database module
|
|
- Create infrastructure/persistence/mod.rs
|
|
- Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct
|
|
- Implement SqliteRelayLabelRepository::new(path) using SqlitePool
|
|
- **Test**: SqliteRelayLabelRepository::in_memory() creates in-memory DB with schema
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [x] **T007** [P] [Setup] [TDD] Add frontend project scaffolding
|
|
- Create frontend/ directory with Vite + Vue 3 + TypeScript
|
|
- Run: `npm create vite@latest frontend -- --template vue-ts`
|
|
- Install: axios, @types/node
|
|
- **Test**: `npm run dev` starts frontend dev server
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [x] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI
|
|
- Add poem-openapi spec generation in startup.rs
|
|
- Generate frontend/src/api/client.ts from OpenAPI spec
|
|
- **Test**: TypeScript client compiles without errors
|
|
- **Complexity**: Medium | **Uncertainty**: Medium
|
|
- **Note**: May need manual adjustments to generated code
|
|
|
|
---
|
|
|
|
## Phase 0.5: CORS Configuration & Production Security (0.5 days) DONE
|
|
|
|
**Purpose**: Replace permissive `Cors::new()` with configurable production-ready CORS
|
|
|
|
**⚠️ TDD CRITICAL**: Write failing tests FIRST for every configuration and function
|
|
|
|
- [x] **T009** [P] [Setup] [TDD] Write tests for CorsSettings struct
|
|
- Test: CorsSettings deserializes from YAML correctly ✓
|
|
- Test: Default CorsSettings has empty allowed_origins (restrictive fail-safe) ✓
|
|
- Test: CorsSettings with wildcard origin deserializes correctly ✓
|
|
- Test: Settings::new() loads cors section from development.yaml ✓
|
|
- Test: CorsSettings with partial fields uses defaults ✓
|
|
- **File**: backend/src/settings.rs (in tests module)
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
- **Tests Written**: 5 tests (cors_settings_deserialize_from_yaml, cors_settings_default_has_empty_origins, cors_settings_with_wildcard_deserializes, settings_loads_cors_section_from_yaml, cors_settings_deserialize_with_defaults)
|
|
|
|
- [x] **T010** [P] [Setup] [TDD] Add CorsSettings struct to settings.rs
|
|
- Struct fields: `allowed_origins: Vec<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**:
|
|
```rust
|
|
fn build_cors(settings: &CorsSettings) -> Cors {
|
|
// Validation
|
|
if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) {
|
|
panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true");
|
|
}
|
|
|
|
let mut cors = Cors::new();
|
|
|
|
// Configure origins
|
|
for origin in &settings.allowed_origins {
|
|
cors = cors.allow_origin(origin.as_str());
|
|
}
|
|
|
|
// Hardcoded methods (API-specific)
|
|
cors = cors.allow_methods(vec![
|
|
Method::GET, Method::POST, Method::PUT,
|
|
Method::PATCH, Method::DELETE, Method::OPTIONS
|
|
]);
|
|
|
|
// Hardcoded headers (minimum for API)
|
|
cors = cors.allow_headers(vec![
|
|
header::CONTENT_TYPE,
|
|
header::AUTHORIZATION,
|
|
]);
|
|
|
|
// Configure from settings
|
|
cors = cors
|
|
.allow_credentials(settings.allow_credentials)
|
|
.max_age(settings.max_age_secs);
|
|
|
|
tracing::info!(
|
|
target: "backend::startup",
|
|
allowed_origins = ?settings.allowed_origins,
|
|
allow_credentials = settings.allow_credentials,
|
|
max_age_secs = settings.max_age_secs,
|
|
"CORS middleware configured"
|
|
);
|
|
|
|
cors
|
|
}
|
|
```
|
|
|
|
- [x] **T015** [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain
|
|
- In `From<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
|
|
|
|
---
|
|
|
|
## Phase 2: Domain Layer - Type-Driven Development (1 day)
|
|
|
|
**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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **T026** [US1] [TDD] Implement ModbusAddress type with From<RelayId>
|
|
- #[repr(transparent)] newtype wrapping u16
|
|
- Implement From<RelayId> with offset: user 1-8 → Modbus 0-7
|
|
- **File**: src/domain/modbus.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T027** [US3] [TDD] Write tests and implement HealthStatus enum
|
|
- Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }
|
|
- Test transitions between states
|
|
- **File**: src/domain/health.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
**Checkpoint**: Domain types complete with 100% test coverage
|
|
|
|
---
|
|
|
|
## Phase 3: Infrastructure Layer (2 days)
|
|
|
|
**Purpose**: Implement Modbus client, mocks, and persistence
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **T032** [US1] [TDD] Write tests for ModbusRelayController
|
|
- **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities
|
|
- Test: Connection succeeds with valid config (Modbus TCP on port 502)
|
|
- Test: read_state() returns correct coil value
|
|
- Test: write_state() sends correct Modbus TCP command (no CRC needed)
|
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
|
- **Complexity**: High → DECOMPOSED below
|
|
- **Uncertainty**: High
|
|
|
|
---
|
|
|
|
### T025: ModbusRelayController Implementation (DECOMPOSED)
|
|
|
|
**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 ModbusRelayController connection setup
|
|
- 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**:
|
|
```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),
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
**TDD Checklist** (write these tests FIRST):
|
|
- [ ] Test: new() with valid config connects successfully
|
|
- [ ] Test: new() with invalid host returns ConnectionError
|
|
- [ ] Test: new() stores correct timeout_duration
|
|
|
|
- [ ] **T025b** [US1] [TDD] Implement timeout-wrapped read_coils helper
|
|
- 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):
|
|
```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)
|
|
}
|
|
```
|
|
|
|
**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() returns ConnectionError on io::Error
|
|
- [ ] Test: read_coils_with_timeout() returns ModbusException on protocol error
|
|
|
|
- [ ] **T025c** [US1] [TDD] Implement timeout-wrapped write_single_coil helper
|
|
- Private method: write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>
|
|
- Similar nested Result handling as T025b
|
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
async fn write_single_coil_with_timeout(&self, addr: u16, value: bool)
|
|
-> Result<(), ControllerError>
|
|
{
|
|
use tokio::time::timeout;
|
|
|
|
let ctx = self.ctx.lock().await;
|
|
|
|
timeout(self.timeout_duration, ctx.write_single_coil(addr, value))
|
|
.await
|
|
.map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))?
|
|
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?
|
|
.map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**TDD Checklist**:
|
|
- [ ] 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
|
|
|
|
- [ ] **T025d** [US1] [TDD] Implement RelayController::read_state() using helpers
|
|
- Convert RelayId → ModbusAddress (0-based)
|
|
- Call read_coils_with_timeout(addr, 1)
|
|
- Convert bool → RelayState
|
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
#[async_trait]
|
|
impl RelayController for ModbusRelayController {
|
|
async fn read_state(&self, id: RelayId) -> Result<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)) returns On when coil is true
|
|
- [ ] Test: read_state(RelayId(1)) returns Off when coil is false
|
|
- [ ] Test: read_state() propagates ControllerError from helper
|
|
|
|
- [ ] **T025e** [US1] [TDD] Implement RelayController::write_state() using helpers
|
|
- Convert RelayId → ModbusAddress
|
|
- Convert RelayState → bool (On=true, Off=false)
|
|
- Call write_single_coil_with_timeout()
|
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
async fn write_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> {
|
|
let addr = ModbusAddress::from(id).as_u16();
|
|
let value = matches!(state, RelayState::On);
|
|
self.write_single_coil_with_timeout(addr, value).await
|
|
}
|
|
```
|
|
|
|
**TDD Checklist**:
|
|
- [ ] Test: write_state(RelayId(1), RelayState::On) writes true to coil
|
|
- [ ] Test: write_state(RelayId(1), RelayState::Off) writes false to coil
|
|
|
|
- [ ] **T025f** [US1] [TDD] Implement RelayController::read_all() and write_all()
|
|
- read_all(): Call read_coils_with_timeout(0, 8), map to Vec<(RelayId, RelayState)>
|
|
- write_all(): Loop over RelayId 1-8, call write_state() for each
|
|
- Add firmware_version() method (read holding register 0x9999, optional)
|
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
async fn read_all(&self) -> Result<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 MockRelayController for CI
|
|
- **File**: tests/integration/modbus_hardware_test.rs
|
|
- **Complexity**: Medium | **Uncertainty**: High
|
|
- **Note**: Use #[ignore] attribute, run with cargo test -- --ignored
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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 HealthMonitor service
|
|
- Test: track_success() transitions Degraded → Healthy
|
|
- Test: track_failure() transitions Healthy → Degraded → Unhealthy
|
|
- **File**: src/application/health_monitor.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [ ] **T040** [US3] [TDD] Implement HealthMonitor service
|
|
- Track consecutive errors, transition states per FR-020, FR-021
|
|
- **File**: src/application/health_monitor.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
**Checkpoint**: Infrastructure layer complete with trait abstractions
|
|
|
|
---
|
|
|
|
## Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days)
|
|
|
|
**Goal**: View current state of all 8 relays + toggle individual relay on/off
|
|
|
|
**Independent Test**: GET /api/relays returns 8 relays, POST /api/relays/{id}/toggle changes state
|
|
|
|
### Application Layer
|
|
|
|
- [ ] **T041** [US1] [TDD] Write tests for ToggleRelayUseCase
|
|
- Test: execute(RelayId(1)) toggles relay state via controller
|
|
- Test: execute() returns error if controller fails
|
|
- **File**: src/application/use_cases/toggle_relay.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T042** [US1] [TDD] Implement ToggleRelayUseCase
|
|
- Orchestrate: read current state → toggle → write new state
|
|
- **File**: src/application/use_cases/toggle_relay.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T043** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase
|
|
- Test: execute() returns all 8 relays with states
|
|
- **File**: src/application/use_cases/get_all_relays.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T044** [P] [US1] [TDD] Implement GetAllRelaysUseCase
|
|
- Call controller.read_all(), map to domain Relay objects
|
|
- **File**: src/application/use_cases/get_all_relays.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
### Presentation Layer (Backend API)
|
|
|
|
- [ ] **T045** [US1] [TDD] Define RelayDto in presentation layer
|
|
- Fields: id (u8), state ("on"/"off"), label (Option<String>)
|
|
- Implement From<Relay> for RelayDto
|
|
- **File**: src/presentation/dto/relay_dto.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T046** [US1] [TDD] Define API error responses
|
|
- ApiError enum with status codes and messages
|
|
- Implement poem::error::ResponseError
|
|
- **File**: src/presentation/error.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
---
|
|
|
|
### T039: Dependency Injection Setup (DECOMPOSED)
|
|
|
|
**Complexity**: High → Broken into 4 sub-tasks
|
|
**Uncertainty**: Medium
|
|
**Rationale**: Graceful degradation (FR-023), conditional mock/real controller
|
|
|
|
- [ ] **T039a** [US1] [TDD] Create ModbusRelayController factory with retry and fallback
|
|
- Factory function: create_relay_controller(settings, use_mock) → Arc<dyn RelayController>
|
|
- Retry 3 times with 2s backoff on connection failure
|
|
- Graceful degradation: fallback to MockRelayController if all retries fail (FR-023)
|
|
- **File**: src/infrastructure/modbus/factory.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Medium
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
pub async fn create_relay_controller(
|
|
settings: &ModbusSettings,
|
|
use_mock: bool,
|
|
) -> Arc<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 MockRelayController immediately
|
|
- [ ] Test: Successful connection returns ModbusRelayController
|
|
- [ ] Test: Connection failure after 3 retries returns MockRelayController
|
|
- [ ] Test: Retry delays are 2 seconds between attempts
|
|
- [ ] Test: Logs appropriate messages for each connection attempt
|
|
|
|
- [ ] **T039b** [US4] [TDD] Create RelayLabelRepository factory
|
|
- Factory function: create_label_repository(db_path, use_mock) → Arc<dyn RelayLabelRepository>
|
|
- If use_mock: return MockLabelRepository
|
|
- Else: return SQLiteLabelRepository connected to db_path
|
|
- **File**: src/infrastructure/persistence/factory.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
pub fn create_label_repository(
|
|
db_path: &str,
|
|
use_mock: bool,
|
|
) -> Result<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_path returns RepositoryError
|
|
|
|
- [ ] **T039c** [US1] [TDD] Wire dependencies in Application::build()
|
|
- Determine test mode: cfg!(test) || env::var("CI").is_ok()
|
|
- Call create_relay_controller() and create_label_repository()
|
|
- Pass dependencies to RelayApi::new()
|
|
- **File**: src/startup.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```rust
|
|
impl Application {
|
|
pub async fn build(settings: Settings) -> Result<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
|
|
|
|
- [ ] **T039d** [US1] [TDD] Register RelayApi in route aggregator
|
|
- Add RelayApi to OpenAPI service
|
|
- Tag: "Relays"
|
|
- **File**: src/startup.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**TDD Checklist**:
|
|
- [ ] Test: OpenAPI spec includes /api/relays endpoints
|
|
- [ ] Test: Swagger UI renders Relays tag
|
|
|
|
---
|
|
|
|
- [ ] **T048** [US1] [TDD] Write contract tests for GET /api/relays
|
|
- Test: Returns 200 with array of 8 RelayDto
|
|
- Test: Each relay has id 1-8, state, and optional label
|
|
- **File**: tests/contract/test_relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T049** [US1] [TDD] Implement GET /api/relays endpoint
|
|
- #[oai(path = "/relays", method = "get")]
|
|
- Call GetAllRelaysUseCase, map to RelayDto
|
|
- **File**: src/presentation/api/relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T050** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle
|
|
- Test: Returns 200 with updated RelayDto
|
|
- Test: Returns 404 for id < 1 or id > 8
|
|
- Test: State actually changes in controller
|
|
- **File**: tests/contract/test_relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T051** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint
|
|
- #[oai(path = "/relays/:id/toggle", method = "post")]
|
|
- Parse id, call ToggleRelayUseCase, return updated state
|
|
- **File**: src/presentation/api/relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
### Frontend Implementation
|
|
|
|
- [ ] **T052** [P] [US1] [TDD] Create RelayDto TypeScript interface
|
|
- Generate from OpenAPI spec or manually define
|
|
- **File**: frontend/src/types/relay.ts
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T053** [P] [US1] [TDD] Create API client service
|
|
- getAllRelays(): Promise<RelayDto[]>
|
|
- toggleRelay(id: number): Promise<RelayDto>
|
|
- **File**: frontend/src/api/relayApi.ts
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
---
|
|
|
|
### T046: HTTP Polling Composable (DECOMPOSED)
|
|
|
|
**Complexity**: High → Broken into 4 sub-tasks
|
|
**Uncertainty**: Medium
|
|
**Rationale**: Vue 3 lifecycle hooks, polling management, memory leak prevention
|
|
|
|
- [ ] **T046a** [US1] [TDD] Create useRelayPolling composable structure
|
|
- Setup reactive refs: relays, isLoading, error, lastFetchTime
|
|
- Define interval variable and fetch function signature
|
|
- **File**: frontend/src/composables/useRelayPolling.ts
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```typescript
|
|
import { ref, Ref } from 'vue';
|
|
import type { RelayDto } from '@/types/relay';
|
|
|
|
export function useRelayPolling(intervalMs: number = 2000) {
|
|
const relays: Ref<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
|
|
|
|
- [ ] **T046b** [US1] [TDD] Implement fetchData with parallel requests
|
|
- Fetch relays and health status in parallel using Promise.all
|
|
- Update reactive state on success
|
|
- Handle errors gracefully, set isConnected based on success
|
|
- **File**: frontend/src/composables/useRelayPolling.ts
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```typescript
|
|
const fetchData = async () => {
|
|
try {
|
|
const [relayData, healthData] = await Promise.all([
|
|
apiClient.getAllRelays(),
|
|
apiClient.getHealth(),
|
|
]);
|
|
|
|
relays.value = relayData.relays;
|
|
isConnected.value = healthData.status === 'healthy';
|
|
lastFetchTime.value = new Date();
|
|
error.value = null;
|
|
} catch (err: any) {
|
|
error.value = err.message || 'Failed to fetch relay data';
|
|
isConnected.value = false;
|
|
console.error('Polling error:', err);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
```
|
|
|
|
**TDD Checklist**:
|
|
- [ ] Test: fetchData() updates relays on success
|
|
- [ ] Test: fetchData() sets error on API failure
|
|
- [ ] Test: fetchData() sets isLoading=false after completion
|
|
- [ ] Test: fetchData() updates lastFetchTime
|
|
|
|
- [ ] **T046c** [US1] [TDD] Implement polling lifecycle with cleanup
|
|
- startPolling(): Fetch immediately, then setInterval
|
|
- stopPolling(): clearInterval and cleanup
|
|
- Use onMounted/onUnmounted for automatic lifecycle management
|
|
- **File**: frontend/src/composables/useRelayPolling.ts
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```typescript
|
|
import { onMounted, onUnmounted } from 'vue';
|
|
|
|
const startPolling = () => {
|
|
if (pollingInterval !== null) return; // Already polling
|
|
|
|
fetchData(); // Immediate first fetch
|
|
pollingInterval = window.setInterval(fetchData, intervalMs);
|
|
};
|
|
|
|
const stopPolling = () => {
|
|
if (pollingInterval !== null) {
|
|
clearInterval(pollingInterval);
|
|
pollingInterval = null;
|
|
}
|
|
};
|
|
|
|
// CRITICAL: Lifecycle cleanup to prevent memory leaks
|
|
onMounted(() => {
|
|
startPolling();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopPolling();
|
|
});
|
|
```
|
|
|
|
**TDD Checklist**:
|
|
- [ ] Test: startPolling() triggers immediate fetch
|
|
- [ ] Test: startPolling() sets interval for subsequent fetches
|
|
- [ ] Test: stopPolling() clears interval
|
|
- [ ] Test: onUnmounted hook calls stopPolling()
|
|
|
|
- [ ] **T046d** [US1] [TDD] Add connection status tracking
|
|
- Track isConnected based on fetch success/failure
|
|
- Display connection status in UI
|
|
- **File**: frontend/src/composables/useRelayPolling.ts
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Pseudocode**:
|
|
```typescript
|
|
// Already implemented in T046b, just ensure it's exposed
|
|
return {
|
|
relays,
|
|
isLoading,
|
|
error,
|
|
isConnected, // ← Connection status indicator
|
|
lastFetchTime,
|
|
refresh: fetchData,
|
|
startPolling,
|
|
stopPolling,
|
|
};
|
|
```
|
|
|
|
**TDD Checklist**:
|
|
- [ ] Test: isConnected is true after successful fetch
|
|
- [ ] Test: isConnected is false after failed fetch
|
|
|
|
---
|
|
|
|
- [ ] **T055** [US1] [TDD] Create RelayCard component
|
|
- Props: relay (RelayDto)
|
|
- Display relay ID, state, label
|
|
- Emit toggle event on button click
|
|
- **File**: frontend/src/components/RelayCard.vue
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T056** [US1] [TDD] Create RelayGrid component
|
|
- Use useRelayPolling composable
|
|
- Render 8 RelayCard components
|
|
- Handle toggle events by calling API
|
|
- Display loading/error states
|
|
- **File**: frontend/src/components/RelayGrid.vue
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [ ] **T057** [US1] [TDD] Integration test for US1
|
|
- End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change
|
|
- Use Playwright or Cypress
|
|
- **File**: frontend/tests/e2e/relay-control.spec.ts
|
|
- **Complexity**: Medium | **Uncertainty**: Medium
|
|
|
|
**Checkpoint**: US1 MVP complete - users can view and toggle individual relays
|
|
|
|
---
|
|
|
|
## Phase 5: US2 - Bulk Relay Controls (0.5 days)
|
|
|
|
**Goal**: Turn all relays on/off with single action
|
|
|
|
**Independent Test**: POST /api/relays/all/on turns all 8 relays on
|
|
|
|
- [ ] **T058** [US2] [TDD] Write tests for BulkControlUseCase
|
|
- Test: execute(BulkOperation::AllOn) turns all relays on
|
|
- Test: execute(BulkOperation::AllOff) turns all relays off
|
|
- **File**: src/application/use_cases/bulk_control.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T059** [US2] [TDD] Implement BulkControlUseCase
|
|
- Call controller.write_all(state)
|
|
- **File**: src/application/use_cases/bulk_control.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T060** [US2] [TDD] Define BulkOperation enum
|
|
- Variants: AllOn, AllOff
|
|
- **File**: src/domain/relay.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T061** [US2] [TDD] Write contract tests for POST /api/relays/all/on
|
|
- Test: Returns 200, all relays turn on
|
|
- **File**: tests/contract/test_relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T062** [US2] [TDD] Implement POST /api/relays/all/on endpoint
|
|
- Call BulkControlUseCase with AllOn
|
|
- **File**: src/presentation/api/relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T063** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off
|
|
- Test: Returns 200, all relays turn off
|
|
- **File**: tests/contract/test_relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T064** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint
|
|
- Call BulkControlUseCase with AllOff
|
|
- **File**: src/presentation/api/relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T065** [US2] [TDD] Add bulk control buttons to frontend
|
|
- Add "All On" and "All Off" buttons to RelayGrid component
|
|
- Call API endpoints and refresh relay states
|
|
- **File**: frontend/src/components/RelayGrid.vue
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T066** [US2] [TDD] Integration test for US2
|
|
- Click "All On" → verify all 8 relays turn on
|
|
- Click "All Off" → verify all 8 relays turn off
|
|
- **File**: frontend/tests/e2e/bulk-control.spec.ts
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Checkpoint**: US2 complete - bulk controls functional
|
|
|
|
---
|
|
|
|
## Phase 6: US3 - Health Monitoring (1 day)
|
|
|
|
**Goal**: Display connection status and device health
|
|
|
|
**Independent Test**: GET /api/health returns health status
|
|
|
|
- [ ] **T067** [US3] [TDD] Write tests for GetHealthUseCase
|
|
- Test: Returns Healthy when controller is responsive
|
|
- Test: Returns Degraded after 3 consecutive errors
|
|
- Test: Returns Unhealthy after 10 consecutive errors
|
|
- **File**: src/application/use_cases/get_health.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [ ] **T068** [US3] [TDD] Implement GetHealthUseCase
|
|
- Use HealthMonitor to track controller status
|
|
- Return current HealthStatus
|
|
- **File**: src/application/use_cases/get_health.rs
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [ ] **T069** [US3] [TDD] Define HealthDto
|
|
- Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional)
|
|
- **File**: src/presentation/dto/health_dto.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T070** [US3] [TDD] Write contract tests for GET /api/health
|
|
- Test: Returns 200 with HealthDto
|
|
- **File**: tests/contract/test_health_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T071** [US3] [TDD] Implement GET /api/health endpoint
|
|
- Call GetHealthUseCase, map to HealthDto
|
|
- **File**: src/presentation/api/health_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T072** [P] [US3] [TDD] Add firmware version display (optional)
|
|
- If controller supports firmware_version(), display in UI
|
|
- **File**: frontend/src/components/DeviceInfo.vue
|
|
- **Complexity**: Low | **Uncertainty**: Medium
|
|
- **Note**: Device may not support this feature
|
|
|
|
- [ ] **T073** [US3] [TDD] Create HealthIndicator component
|
|
- Display connection status with color-coded indicator
|
|
- Show firmware version if available
|
|
- **File**: frontend/src/components/HealthIndicator.vue
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T074** [US3] [TDD] Integrate HealthIndicator in RelayGrid
|
|
- Fetch health status in useRelayPolling composable
|
|
- Pass to HealthIndicator component
|
|
- **File**: frontend/src/components/RelayGrid.vue
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Checkpoint**: US3 complete - health monitoring visible
|
|
|
|
---
|
|
|
|
## Phase 7: US4 - Relay Labeling (0.5 days)
|
|
|
|
**Goal**: Set custom labels for each relay
|
|
|
|
**Independent Test**: PUT /api/relays/{id}/label sets label, GET /api/relays returns label
|
|
|
|
- [ ] **T075** [US4] [TDD] Write tests for SetLabelUseCase
|
|
- Test: execute(RelayId(1), "Pump") sets label
|
|
- Test: execute with empty label returns error
|
|
- Test: execute with 51-char label returns error
|
|
- **File**: src/application/use_cases/set_label.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T076** [US4] [TDD] Implement SetLabelUseCase
|
|
- Validate label with RelayLabel::new()
|
|
- Call label_repository.set_label()
|
|
- **File**: src/application/use_cases/set_label.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T077** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label
|
|
- Test: Returns 200, label is persisted
|
|
- Test: Returns 400 for invalid label
|
|
- **File**: tests/contract/test_relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T078** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint
|
|
- Parse id and label, call SetLabelUseCase
|
|
- **File**: src/presentation/api/relay_api.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T079** [US4] [TDD] Add label editing to RelayCard component
|
|
- Click label → show input field
|
|
- Submit → call PUT /api/relays/{id}/label
|
|
- **File**: frontend/src/components/RelayCard.vue
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [ ] **T080** [US4] [TDD] Integration test for US4
|
|
- Set label for relay 1 → refresh → verify label persists
|
|
- **File**: frontend/tests/e2e/relay-labeling.spec.ts
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
**Checkpoint**: US4 complete - relay labeling functional
|
|
|
|
---
|
|
|
|
## Phase 8: Polish & Deployment (1 day)
|
|
|
|
**Purpose**: Testing, documentation, and production readiness
|
|
|
|
- [ ] **T081** [P] Add comprehensive logging at all architectural boundaries
|
|
- Log all API requests/responses
|
|
- Log all Modbus operations
|
|
- Log health status transitions
|
|
- **Files**: All API and infrastructure modules
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T082** [P] Add OpenAPI documentation for all endpoints
|
|
- Document request/response schemas
|
|
- Add example values
|
|
- Tag endpoints appropriately
|
|
- **File**: src/presentation/api/*.rs
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T083** [P] Run cargo clippy and fix all warnings
|
|
- Ensure compliance with strict linting
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T084** [P] Run cargo fmt and format all code
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T085** Generate test coverage report
|
|
- Run: just coverage
|
|
- Ensure > 80% coverage for domain and application layers
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T086** [P] Run cargo audit for dependency vulnerabilities
|
|
- Fix any high/critical vulnerabilities
|
|
- **Complexity**: Low | **Uncertainty**: Medium
|
|
|
|
- [ ] **T087** [P] Update README.md with deployment instructions
|
|
- Document environment variables
|
|
- Document Modbus device configuration
|
|
- Add quickstart guide
|
|
- **File**: README.md
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T088** [P] Create Docker image for backend
|
|
- Multi-stage build with Rust
|
|
- Include SQLite database setup
|
|
- **File**: Dockerfile
|
|
- **Complexity**: Medium | **Uncertainty**: Low
|
|
|
|
- [ ] **T089** [P] Create production settings/production.yaml
|
|
- Configure for actual device IP
|
|
- Set appropriate timeouts and retry settings
|
|
- **File**: settings/production.yaml
|
|
- **Complexity**: Low | **Uncertainty**: Low
|
|
|
|
- [ ] **T090** Deploy to production environment
|
|
- Test with actual Modbus relay device
|
|
- Verify all user stories work end-to-end
|
|
- **Complexity**: Medium | **Uncertainty**: High
|
|
|
|
**Checkpoint**: Production ready, all user stories validated
|
|
|
|
---
|
|
|
|
## Dependencies & Execution Order
|
|
|
|
### Phase Dependencies
|
|
|
|
1. **Phase 1 (Setup)**: No dependencies - start immediately
|
|
2. **Phase 2 (Domain TyDD)**: Depends on Phase 1 module structure
|
|
3. **Phase 3 (Infrastructure)**: Depends on Phase 2 domain types
|
|
4. **Phase 4 (US1 MVP)**: Depends on Phase 3 infrastructure
|
|
5. **Phase 5 (US2)**: Depends on Phase 4 backend API complete
|
|
6. **Phase 6 (US3)**: Depends on Phase 4 backend API complete (can parallelize with US2)
|
|
7. **Phase 7 (US4)**: Depends on Phase 4 backend API complete (can parallelize with US2/US3)
|
|
8. **Phase 8 (Polish)**: Depends on all desired user stories complete
|
|
|
|
### User Story Independence
|
|
|
|
- **US1**: No dependencies on other stories
|
|
- **US2**: Reuses US1 backend infrastructure, but independently testable
|
|
- **US3**: Reuses US1 backend infrastructure, but independently testable
|
|
- **US4**: Reuses US1 backend infrastructure, adds new persistence layer
|
|
|
|
### Critical Path
|
|
|
|
**MVP (US1 only)**: Phase 1 → Phase 2 → Phase 3 → Phase 4 (5 days)
|
|
**Full Feature**: MVP + Phase 5 + Phase 6 + Phase 7 + Phase 8 (7 days)
|
|
|
|
### Parallel Opportunities
|
|
|
|
- **Phase 1**: T002, T003, T004, T005, T006, T007, T008 can run in parallel
|
|
- **Phase 2**: T011, T015 can run in parallel
|
|
- **Phase 3**: T020, T027, T028, T029, T030 can run in parallel after T022 complete
|
|
- **Phase 4**: T035, T044, T045 can run in parallel
|
|
- **After Phase 4**: US2, US3, US4 can be developed in parallel by different developers
|
|
- **Phase 8**: T073, T074, T075, T076, T078, T079, T080, T081 can run in parallel
|
|
|
|
**Total Parallelizable Tasks**: 35 tasks marked `[P]`
|
|
|
|
---
|
|
|
|
## Test-Driven Development Workflow
|
|
|
|
**CRITICAL**: For every task marked `[TDD]`, follow this exact sequence:
|
|
|
|
1. **Write failing test FIRST** (red)
|
|
2. **Verify test fails** for the right reason
|
|
3. **Implement minimum code** to pass test (green)
|
|
4. **Refactor** while keeping tests green
|
|
5. **Commit** after each task or logical group
|
|
|
|
### Example TDD Workflow (T010):
|
|
|
|
```bash
|
|
# 1. Write failing test for RelayId::new() validation
|
|
# In src/domain/relay.rs:
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_relay_id_valid_range() {
|
|
assert!(RelayId::new(1).is_ok());
|
|
assert!(RelayId::new(8).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_relay_id_invalid_range() {
|
|
assert!(RelayId::new(0).is_err());
|
|
assert!(RelayId::new(9).is_err());
|
|
}
|
|
}
|
|
|
|
# 2. Run test → VERIFY IT FAILS
|
|
cargo test test_relay_id
|
|
|
|
# 3. Implement RelayId to make test pass
|
|
# 4. Run test again → VERIFY IT PASSES
|
|
# 5. Refactor if needed, keep tests green
|
|
# 6. Commit
|
|
jj describe -m "feat: implement RelayId with validation (T010)"
|
|
```
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- **[P]** = Parallelizable (different files, no dependencies)
|
|
- **[US1/US2/US3/US4]** = User story mapping for traceability
|
|
- **[TDD]** = Test-Driven Development required
|
|
- **Complexity**: Low (< 1 hour) | Medium (1-3 hours) | High (> 3 hours or decomposed)
|
|
- **Uncertainty**: Low (clear path) | Medium (some unknowns) | High (requires research/spike)
|
|
- Commit after each task or logical group using `jj describe` or `jj commit`
|
|
- MVP delivery at task T049 (end of Phase 4)
|
|
- Stop at any checkpoint to independently validate user story
|