Files
sta/specs/001-modbus-relay-control/plan.md
Lucien Cartier-Tilet 2365bbc9b3 docs(cors): add CORS configuration planning and tasks
Add comprehensive CORS planning documentation and task breakdown for
Phase 0.5 (8 tasks: T009-T016).

- Create research-cors.md with security analysis and decisions
- Add FR-022a to spec.md for production CORS requirements
- Update tasks.md: 94 → 102 tasks across 9 phases
- Document CORS in README and plan.md

Configuration approach: hybrid (configurable origins/credentials,
hardcoded methods/headers) with restrictive fail-safe defaults.
2026-01-22 00:57:10 +01:00

2221 lines
65 KiB
Markdown

# Implementation Plan: Modbus Relay Control System
**Branch**: `001-modbus-relay-control` | **Date**: 2025-12-29 | **Spec**: [spec.md](./spec.md)
## Summary
**Primary Requirement**: Web-based control system for 8-channel Modbus relay device with real-time state monitoring and remote control capabilities.
**Technical Approach**:
- **Architecture**: Pragmatic Balance (Service Layer Pattern) - Hexagonal architecture with domain/application/infrastructure/presentation layers
- **Backend**: Rust with tokio-modbus 0.17.0 for Modbus TCP, Poem 3.1 for HTTP API with OpenAPI
- **Frontend**: Vue 3 + TypeScript with HTTP polling (2-second intervals), deployed to Cloudflare Pages
- **Reverse Proxy**: Traefik on Raspberry Pi with Authelia middleware for authentication and HTTPS termination
- **Persistence**: SQLite for relay labels
- **Testing**: TDD with mockall for unit tests, real hardware for integration tests
- **Timeline**: 7 days (5 days backend + 2 days frontend)
## Technical Context
**Language/Version**: Rust 1.75+
**Primary Dependencies**:
- tokio-modbus 0.17.0 with TCP feature only (Modbus TCP protocol)
- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI + CORS middleware)
- Tokio 1.48 (async runtime)
- sqlx 0.8 (SQLite persistence with compile-time verification)
- mockall + async-trait (testing)
- Vue 3 + TypeScript + Vite (frontend)
**Storage**: SQLite (relay labels, device configuration)
**Testing**: cargo test + mockall (mocks) + real hardware integration tests (marked `#[ignore]` for CI)
**Target Platform**:
- Backend: Linux (NixOS development, Raspberry Pi 3B+ production with Traefik reverse proxy)
- Frontend: Cloudflare Pages (static hosting with CDN)
**Project Type**: Web (backend + frontend)
**Performance Goals**:
- API response: <100ms (excluding Modbus communication)
- Relay toggle operation: <1s end-to-end
- Concurrent users: 10
- Frontend polling: 2-second intervals
**Constraints**:
- Modbus timeout: 3 seconds (FR-006)
- Test coverage: >90% (constitution requirement)
- Retry strategy: Retry once on Modbus failure (FR-007)
- Graceful degradation: Backend starts even when device unavailable (FR-023)
**Scale/Scope**:
- 8 relays per device
- Single device support (MVP)
- 5 core API endpoints + 1 health endpoint
- Backend: Local network (Raspberry Pi) behind Traefik reverse proxy
- Frontend: CDN-hosted (Cloudflare Pages), accesses backend via HTTPS
## Constitution Check
*GATE: Must pass before implementation. Verified against `specs/constitution.md` v1.1.0*
**Hexagonal Architecture**: Enforced through domain/application/infrastructure/presentation layers with inward-pointing dependencies
**Domain-Driven Design**: Rich domain models with value objects (RelayId, RelayState, RelayLabel), entities (Relay), repositories
**Test-First Development**: TDD mandatory - write failing tests before implementation for every component
**API-First Design**: RESTful HTTP with OpenAPI specification, contracts defined before implementation
**Observability & Monitoring**: Structured logging with tracing crate at all architectural boundaries
**SOLID Principles**:
- SRP: Each module has single responsibility (domain types, services, repositories)
- OCP: Trait-based abstractions allow extension without modification
- LSP: Mock and real implementations substitutable through traits
- ISP: Focused traits (RelayController, RelayLabelRepository)
- DIP: High-level use cases depend on abstractions, not concrete implementations
## Project Structure
### Documentation (this feature)
```text
specs/001-modbus-relay-control/
├── plan.md # This file
├── spec.md # Feature specification
├── decisions.md # Architecture and technical decisions
├── research.md # Technical research findings
├── research-cors.md # CORS configuration research and decisions
└── types-design.md # Type system design (TyDD)
```
### Source Code (repository root)
```text
sta/ (repository root)
├── src/
│ ├── domain/
│ │ └── relay/
│ │ ├── mod.rs # Module exports
│ │ ├── types.rs # RelayId, RelayState, RelayLabel (newtypes)
│ │ ├── entity.rs # Relay entity, RelayCollection
│ │ ├── repository.rs # RelayLabelRepository trait
│ │ ├── controller.rs # RelayController trait
│ │ └── error.rs # Domain-specific errors
│ │
│ ├── application/
│ │ └── relay/
│ │ ├── mod.rs # Use case exports
│ │ ├── get_status.rs # GetRelayStatus use case
│ │ ├── toggle_relay.rs # ToggleRelay use case
│ │ ├── bulk_control.rs # BulkControl use cases
│ │ ├── update_label.rs # UpdateLabel use case
│ │ └── get_health.rs # GetDeviceHealth use case
│ │
│ ├── infrastructure/
│ │ ├── modbus/
│ │ │ ├── mod.rs # Module exports
│ │ │ ├── client.rs # ModbusRelayController (real impl)
│ │ │ ├── mock.rs # MockRelayController (testing)
│ │ │ ├── config.rs # Modbus configuration
│ │ │ └── connection.rs # Connection management
│ │ │
│ │ └── persistence/
│ │ ├── mod.rs # Module exports
│ │ ├── sqlite_repository.rs # SqliteRelayLabelRepository
│ │ └── schema.sql # Database schema
│ │
│ ├── route/
│ │ ├── mod.rs # Update: Add Relay API category
│ │ └── relay.rs # New: Relay API handlers
│ │
│ ├── settings.rs # Update: Add ModbusSettings
│ ├── startup.rs # Update: Wire relay dependencies
│ └── (existing files...)
├── tests/
│ ├── unit/
│ │ └── relay/
│ │ ├── domain_types_test.rs
│ │ ├── entity_test.rs
│ │ └── use_cases_test.rs
│ │
│ ├── integration/
│ │ ├── modbus_mock_test.rs
│ │ ├── modbus_real_hardware_test.rs # marked #[ignore] for CI
│ │ ├── sqlite_repository_test.rs
│ │ └── api_integration_test.rs
│ │
│ └── contract/
│ └── relay_api_contract_test.rs
└── frontend/ (new directory)
├── src/
│ ├── components/
│ │ ├── RelayGrid.vue
│ │ ├── RelayCard.vue
│ │ ├── BulkControls.vue
│ │ └── HealthStatus.vue
│ ├── services/
│ │ └── api-client.ts # OpenAPI generated
│ ├── composables/
│ │ └── useRelayPolling.ts
│ ├── types/
│ │ └── relay.ts
│ ├── App.vue
│ └── main.ts
├── package.json
├── tsconfig.json
├── vite.config.ts
└── index.html
```
**Structure Decision**: Web application structure with backend (existing `src/`) and new `frontend/` directory. Backend follows hexagonal architecture with domain/application/infrastructure/presentation layers. Frontend is separate Vue 3 project with Vite.
---
## Phase Breakdown
### Phase 0: Setup & Dependencies (0.5 days)
**Objective**: Set up project dependencies and infrastructure for both backend and frontend.
**Prerequisites**:
- Existing codebase (`sta` repository)
- Rust toolchain 1.75+
- Node.js 18+ (for frontend)
**Tasks**:
#### Task 0.1: Add Rust Dependencies
**File**: `Cargo.toml`
**Action**: Add the following dependencies:
```toml
[dependencies]
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"
```
**Verification**: Run `cargo check` - should compile without errors.
#### Task 0.2: Create Domain Module Structure
**Files to create**:
- `src/domain/mod.rs`
- `src/domain/relay/mod.rs`
**Action**: Create empty module files with proper visibility:
```rust
// src/domain/mod.rs
pub mod relay;
```
```rust
// src/domain/relay/mod.rs
pub mod types;
pub mod entity;
pub mod repository;
pub mod controller;
pub mod error;
```
**Verification**: `cargo check` passes, modules are accessible.
#### Task 0.3: Update Settings for Modbus Configuration
**File**: `src/settings.rs`
**Action**: Add `ModbusSettings` struct:
```rust
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ModbusSettings {
pub host: String,
pub port: u16,
pub slave_id: u8,
pub timeout_secs: u64,
}
```
Update `Settings` struct to include `modbus: ModbusSettings`.
Update `settings/base.yaml`:
```yaml
modbus:
host: "192.168.1.100" # Replace with actual IP
port: 502
slave_id: 1
timeout_secs: 3
```
**Verification**: Run `cargo run` - settings should load without errors.
**Deliverables**:
- All dependencies added and compiling
- Module structure created
- Modbus configuration in settings
---
### Phase 1: Domain Layer - Types & Entities (1 day)
**Objective**: Implement pure domain logic with type-driven design (TyDD). No external dependencies.
**Prerequisites**: Phase 0 complete
#### Task 1.1: Write Tests for RelayId (TDD)
**File**: `tests/unit/relay/domain_types_test.rs`
**Action**: Write failing tests FIRST:
```rust
#[cfg(test)]
mod relay_id_tests {
use sta::domain::relay::types::RelayId;
#[test]
fn valid_relay_id_succeeds() {
for id in 1..=8 {
assert!(RelayId::new(id).is_ok());
}
}
#[test]
fn relay_id_zero_fails() {
assert!(RelayId::new(0).is_err());
}
#[test]
fn relay_id_above_8_fails() {
assert!(RelayId::new(9).is_err());
}
#[test]
fn relay_id_to_modbus_address() {
let id = RelayId::new(1).unwrap();
assert_eq!(id.to_modbus_address(), 0);
let id = RelayId::new(8).unwrap();
assert_eq!(id.to_modbus_address(), 7);
}
}
```
**Verification**: Run `cargo test` - tests should FAIL (not compile).
#### Task 1.2: Implement RelayId Newtype
**File**: `src/domain/relay/types.rs`
**Action**: Implement to make tests pass:
```rust
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct RelayId(u8);
#[derive(Debug, Error)]
pub enum RelayIdError {
#[error("Relay ID must be between 1 and 8, got {0}")]
OutOfRange(u8),
}
impl RelayId {
/// Creates a new RelayId (1-8 for user-facing)
pub fn new(value: u8) -> Result<Self, RelayIdError> {
if value < 1 || value > 8 {
return Err(RelayIdError::OutOfRange(value));
}
Ok(Self(value))
}
/// Converts user-facing ID (1-8) to Modbus address (0-7)
pub fn to_modbus_address(self) -> u16 {
u16::from(self.0 - 1)
}
pub fn value(self) -> u8 {
self.0
}
}
```
**Acceptance Criteria**:
- [ ] All RelayId tests pass
- [ ] `cargo clippy` shows no warnings
- [ ] Type is `#[repr(transparent)]` for zero-cost
**Verification**: Run `cargo test domain_types_test` - all tests PASS.
#### Task 1.3: Write Tests for RelayState and RelayLabel
**File**: `tests/unit/relay/domain_types_test.rs`
**Action**: Add tests for RelayState enum and RelayLabel newtype (following same TDD pattern).
#### Task 1.4: Implement RelayState and RelayLabel
**File**: `src/domain/relay/types.rs`
**Action**: Implement types to pass tests:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayState {
On,
Off,
}
impl RelayState {
pub fn toggle(self) -> Self {
match self {
Self::On => Self::Off,
Self::Off => Self::On,
}
}
pub fn to_modbus_value(self) -> u16 {
match self {
Self::On => 0xFF00,
Self::Off => 0x0000,
}
}
pub fn from_modbus_coil(coil: bool) -> Self {
if coil { Self::On } else { Self::Off }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct RelayLabel(String);
#[derive(Debug, Error)]
pub enum RelayLabelError {
#[error("Label cannot be empty")]
Empty,
#[error("Label exceeds maximum length of 50 characters")]
TooLong,
}
impl RelayLabel {
pub fn new(value: String) -> Result<Self, RelayLabelError> {
if value.is_empty() {
return Err(RelayLabelError::Empty);
}
if value.len() > 50 {
return Err(RelayLabelError::TooLong);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for RelayLabel {
fn default() -> Self {
Self(String::from("Unlabeled"))
}
}
```
**Verification**: All domain type tests pass.
#### Task 1.5: Write Tests for Relay Entity
**File**: `tests/unit/relay/entity_test.rs`
**Action**: Write tests for Relay entity:
```rust
#[cfg(test)]
mod relay_entity_tests {
use sta::domain::relay::{entity::Relay, types::*};
#[test]
fn new_relay_defaults_to_off_and_unlabeled() {
let relay = Relay::new(RelayId::new(1).unwrap());
assert_eq!(relay.state(), RelayState::Off);
assert_eq!(relay.label().as_str(), "Unlabeled");
}
#[test]
fn toggle_changes_state() {
let mut relay = Relay::new(RelayId::new(1).unwrap());
assert_eq!(relay.state(), RelayState::Off);
relay.toggle();
assert_eq!(relay.state(), RelayState::On);
relay.toggle();
assert_eq!(relay.state(), RelayState::Off);
}
#[test]
fn update_label_changes_label() {
let mut relay = Relay::new(RelayId::new(1).unwrap());
let label = RelayLabel::new("Garage Door".to_string()).unwrap();
relay.update_label(label.clone());
assert_eq!(relay.label(), &label);
}
}
```
**Verification**: Tests FAIL (entity not yet implemented).
#### Task 1.6: Implement Relay Entity
**File**: `src/domain/relay/entity.rs`
**Action**: Implement Relay entity:
```rust
use super::types::{RelayId, RelayLabel, RelayState};
#[derive(Debug, Clone, PartialEq)]
pub struct Relay {
id: RelayId,
state: RelayState,
label: RelayLabel,
}
impl Relay {
pub fn new(id: RelayId) -> Self {
Self {
id,
state: RelayState::Off,
label: RelayLabel::default(),
}
}
pub fn with_state(id: RelayId, state: RelayState) -> Self {
Self {
id,
state,
label: RelayLabel::default(),
}
}
pub fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self {
Self { id, state, label }
}
pub fn id(&self) -> RelayId {
self.id
}
pub fn state(&self) -> RelayState {
self.state
}
pub fn label(&self) -> &RelayLabel {
&self.label
}
pub fn toggle(&mut self) {
self.state = self.state.toggle();
}
pub fn set_state(&mut self, state: RelayState) {
self.state = state;
}
pub fn update_label(&mut self, label: RelayLabel) {
self.label = label;
}
}
```
**Acceptance Criteria**:
- [ ] All Relay entity tests pass
- [ ] Entity has no external dependencies
- [ ] Methods follow domain logic only
**Verification**: `cargo test entity_test` passes.
#### Task 1.7: Define Repository and Controller Traits
**File**: `src/domain/relay/repository.rs` and `src/domain/relay/controller.rs`
**Action**: Define traits (no tests needed for traits themselves):
```rust
// repository.rs
use super::types::{RelayId, RelayLabel};
use async_trait::async_trait;
#[derive(Debug, thiserror::Error)]
pub enum RepositoryError {
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Relay not found: {0}")]
NotFound(RelayId),
}
#[async_trait]
pub trait RelayLabelRepository: Send + Sync {
async fn get_label(&self, id: RelayId) -> Result<Option<RelayLabel>, RepositoryError>;
async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError>;
async fn get_all_labels(&self) -> Result<Vec<(RelayId, RelayLabel)>, RepositoryError>;
}
```
```rust
// controller.rs
use super::types::{RelayId, RelayState};
use async_trait::async_trait;
#[derive(Debug, thiserror::Error)]
pub enum ControllerError {
#[error("Connection error: {0}")]
ConnectionError(String),
#[error("Timeout after {0} seconds")]
Timeout(u64),
#[error("Modbus exception: {0}")]
ModbusException(String),
#[error("Invalid relay ID: {0}")]
InvalidRelayId(u8),
}
#[async_trait]
pub trait RelayController: Send + Sync {
async fn read_relay_state(&self, id: RelayId) -> Result<RelayState, ControllerError>;
async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError>;
async fn read_all_states(&self) -> Result<Vec<RelayState>, ControllerError>;
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<(), ControllerError>;
async fn check_connection(&self) -> Result<(), ControllerError>;
async fn get_firmware_version(&self) -> Result<Option<String>, ControllerError>;
}
```
**Verification**: `cargo check` passes, traits compile.
**Deliverables**:
- Domain types (RelayId, RelayState, RelayLabel) fully tested and implemented
- Relay entity implemented with unit tests
- Repository and Controller traits defined
- 100% test coverage for domain layer
- No external dependencies in domain layer
---
### Phase 2: Infrastructure - Mock Implementation (0.5 days)
**Objective**: Create mock implementations for testing without hardware.
**Prerequisites**: Phase 1 complete (traits defined)
#### Task 2.1: Implement MockRelayController
**File**: `src/infrastructure/modbus/mock.rs`
**Action**: Create mock using in-memory state:
```rust
use crate::domain::relay::{
controller::{ControllerError, RelayController},
types::{RelayId, RelayState},
};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct MockRelayController {
states: Arc<Mutex<[RelayState; 8]>>,
firmware_version: Option<String>,
simulate_timeout: bool,
}
impl MockRelayController {
pub fn new() -> Self {
Self {
states: Arc::new(Mutex::new([RelayState::Off; 8])),
firmware_version: Some("v2.00".to_string()),
simulate_timeout: false,
}
}
pub fn with_timeout_simulation(mut self) -> Self {
self.simulate_timeout = true;
self
}
}
#[async_trait]
impl RelayController for MockRelayController {
async fn read_relay_state(&self, id: RelayId) -> Result<RelayState, ControllerError> {
if self.simulate_timeout {
tokio::time::sleep(tokio::time::Duration::from_secs(4)).await;
return Err(ControllerError::Timeout(3));
}
let states = self.states.lock().await;
let index = (id.value() - 1) as usize;
Ok(states[index])
}
async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> {
if self.simulate_timeout {
return Err(ControllerError::Timeout(3));
}
let mut states = self.states.lock().await;
let index = (id.value() - 1) as usize;
states[index] = state;
Ok(())
}
async fn read_all_states(&self) -> Result<Vec<RelayState>, ControllerError> {
let states = self.states.lock().await;
Ok(states.to_vec())
}
async fn write_all_states(&self, new_states: Vec<RelayState>) -> Result<(), ControllerError> {
let mut states = self.states.lock().await;
for (i, state) in new_states.iter().enumerate() {
states[i] = *state;
}
Ok(())
}
async fn check_connection(&self) -> Result<(), ControllerError> {
Ok(())
}
async fn get_firmware_version(&self) -> Result<Option<String>, ControllerError> {
Ok(self.firmware_version.clone())
}
}
```
**Verification**: `cargo check` passes.
#### Task 2.2: Write Integration Tests with Mock
**File**: `tests/integration/modbus_mock_test.rs`
**Action**: Test mock behavior:
```rust
use sta::domain::relay::{controller::RelayController, types::*};
use sta::infrastructure::modbus::mock::MockRelayController;
#[tokio::test]
async fn mock_relay_read_write_cycle() {
let controller = MockRelayController::new();
let id = RelayId::new(1).unwrap();
// Read initial state (should be OFF)
let state = controller.read_relay_state(id).await.unwrap();
assert_eq!(state, RelayState::Off);
// Write ON
controller.write_relay_state(id, RelayState::On).await.unwrap();
// Read again (should be ON)
let state = controller.read_relay_state(id).await.unwrap();
assert_eq!(state, RelayState::On);
}
#[tokio::test]
async fn mock_timeout_simulation() {
let controller = MockRelayController::new().with_timeout_simulation();
let id = RelayId::new(1).unwrap();
let result = controller.read_relay_state(id).await;
assert!(result.is_err());
}
```
**Acceptance Criteria**:
- [ ] Mock controller passes all integration tests
- [ ] Mock supports timeout simulation
- [ ] Mock thread-safe (Arc<Mutex>)
**Verification**: `cargo test modbus_mock_test` passes.
**Deliverables**:
- MockRelayController fully functional
- Integration tests with mocks passing
- Foundation for TDD without hardware
---
### Phase 3: Infrastructure - SQLite Repository (1 day)
**Objective**: Implement persistent label storage with SQLite.
**Prerequisites**: Phase 1 complete (repository trait defined)
#### Task 3.1: Create Database Schema
**File**: `src/infrastructure/persistence/schema.sql`
**Action**: Define schema:
```sql
-- Relay label storage
CREATE TABLE IF NOT EXISTS relay_labels (
relay_id INTEGER PRIMARY KEY CHECK(relay_id >= 1 AND relay_id <= 8),
label TEXT NOT NULL CHECK(length(label) > 0 AND length(label) <= 50)
);
-- Pre-populate with defaults
INSERT OR IGNORE INTO relay_labels (relay_id, label) VALUES
(1, 'Relay 1'),
(2, 'Relay 2'),
(3, 'Relay 3'),
(4, 'Relay 4'),
(5, 'Relay 5'),
(6, 'Relay 6'),
(7, 'Relay 7'),
(8, 'Relay 8');
```
**Verification**: Schema is valid SQL.
#### Task 3.2: Write Tests for SqliteRelayLabelRepository (TDD)
**File**: `tests/integration/sqlite_repository_test.rs`
**Action**: Write failing tests:
```rust
use sta::domain::relay::{repository::RelayLabelRepository, types::*};
use sta::infrastructure::persistence::sqlite_repository::SqliteRelayLabelRepository;
#[tokio::test]
async fn get_label_returns_default_for_new_relay() {
let repo = SqliteRelayLabelRepository::in_memory().await.unwrap();
let id = RelayId::new(1).unwrap();
let label = repo.get_label(id).await.unwrap();
assert!(label.is_some());
assert_eq!(label.unwrap().as_str(), "Relay 1");
}
#[tokio::test]
async fn save_and_get_label_persists() {
let repo = SqliteRelayLabelRepository::in_memory().await.unwrap();
let id = RelayId::new(3).unwrap();
let label = RelayLabel::new("Water Pump".to_string()).unwrap();
repo.save_label(id, label.clone()).await.unwrap();
let retrieved = repo.get_label(id).await.unwrap().unwrap();
assert_eq!(retrieved, label);
}
#[tokio::test]
async fn get_all_labels_returns_all_eight() {
let repo = SqliteRelayLabelRepository::in_memory().await.unwrap();
let labels = repo.get_all_labels().await.unwrap();
assert_eq!(labels.len(), 8);
}
```
**Verification**: Tests FAIL (repository not implemented).
#### Task 3.3: Implement SqliteRelayLabelRepository
**File**: `src/infrastructure/persistence/sqlite_repository.rs`
**Action**: Implement repository:
```rust
use crate::domain::relay::{
repository::{RelayLabelRepository, RepositoryError},
types::{RelayId, RelayLabel},
};
use async_trait::async_trait;
use sqlx::{sqlite::SqlitePool, Row};
pub struct SqliteRelayLabelRepository {
pool: SqlitePool,
}
impl SqliteRelayLabelRepository {
pub async fn new(db_path: &str) -> Result<Self, RepositoryError> {
let pool = SqlitePool::connect(db_path)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
let repo = Self { pool };
repo.initialize_schema().await?;
Ok(repo)
}
pub async fn in_memory() -> Result<Self, RepositoryError> {
Self::new("sqlite::memory:").await
}
async fn initialize_schema(&self) -> Result<(), RepositoryError> {
sqlx::query(include_str!("schema.sql"))
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
Ok(())
}
}
#[async_trait]
impl RelayLabelRepository for SqliteRelayLabelRepository {
async fn get_label(&self, id: RelayId) -> Result<Option<RelayLabel>, RepositoryError> {
let label_str: Option<String> = sqlx::query_scalar(
"SELECT label FROM relay_labels WHERE relay_id = ?1"
)
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
match label_str {
Some(s) => Ok(Some(
RelayLabel::new(s).map_err(|e| RepositoryError::DatabaseError(e.to_string()))?,
)),
None => Ok(None),
}
}
async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError> {
sqlx::query("INSERT OR REPLACE INTO relay_labels (relay_id, label) VALUES (?1, ?2)")
.bind(id.value())
.bind(label.as_str())
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
Ok(())
}
async fn get_all_labels(&self) -> Result<Vec<(RelayId, RelayLabel)>, RepositoryError> {
let rows = sqlx::query("SELECT relay_id, label FROM relay_labels ORDER BY relay_id")
.fetch_all(&self.pool)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
let mut result = Vec::new();
for row in rows {
let id_val: u8 = row.try_get("relay_id")
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
let label_str: String = row.try_get("label")
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
let id = RelayId::new(id_val)
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
let label = RelayLabel::new(label_str)
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
result.push((id, label));
}
Ok(result)
}
}
```
**Acceptance Criteria**:
- [ ] All SQLite repository tests pass
- [ ] Schema initializes automatically
- [ ] Labels persist across repository instances (file-based)
- [ ] Connection pool handles concurrency automatically
**Verification**: `cargo test sqlite_repository_test` passes.
**Deliverables**:
- SQLite repository fully functional
- Schema auto-initialization
- Persistence tests passing
---
### Phase 4: Infrastructure - Real Modbus Client (1.5 days)
**Objective**: Implement real Modbus TCP communication using tokio-modbus.
**Prerequisites**: Phase 1 complete (controller trait), hardware available for testing
#### Task 4.1: Implement ModbusRelayController
**File**: `src/infrastructure/modbus/client.rs`
**Action**: Implement real Modbus controller:
```rust
use crate::domain::relay::{
controller::{ControllerError, RelayController},
types::{RelayId, RelayState},
};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use tokio_modbus::prelude::*;
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> {
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),
})
}
async fn read_coils_with_timeout(&self, addr: u16, count: u16) -> Result<Vec<bool>, ControllerError> {
let ctx = self.ctx.lock().await;
// tokio-modbus returns Result<Result<T, Exception>, io::Error>
let result = timeout(self.timeout_duration, ctx.read_coils(addr, count))
.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(result)
}
async fn write_single_coil_with_timeout(&self, addr: u16, value: bool) -> Result<(), ControllerError> {
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(())
}
}
#[async_trait]
impl RelayController for ModbusRelayController {
async fn read_relay_state(&self, id: RelayId) -> Result<RelayState, ControllerError> {
let addr = id.to_modbus_address();
let coils = self.read_coils_with_timeout(addr, 1).await?;
let state = RelayState::from_modbus_coil(coils[0]);
tracing::debug!(target: "modbus", relay_id = id.value(), ?state, "Read relay state");
Ok(state)
}
async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> {
let addr = id.to_modbus_address();
let value = state == RelayState::On;
self.write_single_coil_with_timeout(addr, value).await?;
tracing::info!(target: "modbus", relay_id = id.value(), ?state, "Wrote relay state");
Ok(())
}
async fn read_all_states(&self) -> Result<Vec<RelayState>, ControllerError> {
let coils = self.read_coils_with_timeout(0x0000, 8).await?;
let states: Vec<RelayState> = coils
.into_iter()
.map(RelayState::from_modbus_coil)
.collect();
tracing::debug!(target: "modbus", "Read all relay states");
Ok(states)
}
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<(), ControllerError> {
if states.len() != 8 {
return Err(ControllerError::ConnectionError(
"Must provide exactly 8 states".to_string(),
));
}
let ctx = self.ctx.lock().await;
let coils: Vec<bool> = states.iter().map(|s| *s == RelayState::On).collect();
timeout(self.timeout_duration, ctx.write_multiple_coils(0x0000, &coils))
.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)))?;
tracing::info!(target: "modbus", "Wrote all relay states");
Ok(())
}
async fn check_connection(&self) -> Result<(), ControllerError> {
// Try reading first coil as health check
self.read_coils_with_timeout(0x0000, 1).await?;
Ok(())
}
async fn get_firmware_version(&self) -> Result<Option<String>, ControllerError> {
let ctx = self.ctx.lock().await;
// Read firmware version from register 0x8000
let result = timeout(
self.timeout_duration,
ctx.read_holding_registers(0x8000, 1),
)
.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)))?;
if let Some(&version_raw) = result.first() {
let version = f32::from(version_raw) / 100.0;
Ok(Some(format!("v{:.2}", version)))
} else {
Ok(None)
}
}
}
```
**Verification**: `cargo check` passes.
#### Task 4.2: Write Real Hardware Integration Tests
**File**: `tests/integration/modbus_real_hardware_test.rs`
**Action**: Create hardware tests (marked `#[ignore]` for CI):
```rust
use sta::domain::relay::{controller::RelayController, types::*};
use sta::infrastructure::modbus::client::ModbusRelayController;
#[tokio::test]
#[ignore] // Only run with real hardware: cargo test --ignored
async fn real_hardware_read_all_states() {
let controller = ModbusRelayController::new("192.168.1.100", 502, 1, 3)
.await
.expect("Failed to connect to Modbus device");
let states = controller.read_all_states().await.unwrap();
assert_eq!(states.len(), 8);
}
#[tokio::test]
#[ignore]
async fn real_hardware_toggle_relay() {
let controller = ModbusRelayController::new("192.168.1.100", 502, 1, 3)
.await
.expect("Failed to connect");
let id = RelayId::new(1).unwrap();
// Read current state
let initial = controller.read_relay_state(id).await.unwrap();
// Toggle
let new_state = initial.toggle();
controller.write_relay_state(id, new_state).await.unwrap();
// Verify
let final_state = controller.read_relay_state(id).await.unwrap();
assert_eq!(final_state, new_state);
// Toggle back
controller.write_relay_state(id, initial).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn real_hardware_firmware_version() {
let controller = ModbusRelayController::new("192.168.1.100", 502, 1, 3)
.await
.expect("Failed to connect");
let version = controller.get_firmware_version().await.unwrap();
assert!(version.is_some());
println!("Firmware version: {}", version.unwrap());
}
```
**Acceptance Criteria**:
- [ ] Connection to real hardware succeeds
- [ ] Read operations return valid data
- [ ] Write operations physically toggle relays
- [ ] Timeout handling works (tested manually with disconnected device)
- [ ] Firmware version reads correctly
**Verification**:
- `cargo test` passes (hardware tests skipped)
- `cargo test --ignored` passes WITH real hardware connected
**Deliverables**:
- Real Modbus controller fully functional
- Hardware integration tests
- Timeout and error handling verified
---
### Phase 5: Application Layer - Use Cases (1 day)
**Objective**: Implement business logic orchestration (use cases).
**Prerequisites**: Phases 1-4 complete (domain, mock, repository, real controller)
#### Task 5.1: Write Tests for GetRelayStatus Use Case (TDD)
**File**: `tests/unit/relay/use_cases_test.rs`
**Action**: Write failing tests:
```rust
use sta::application::relay::get_status::GetRelayStatus;
use sta::domain::relay::{controller::RelayController, repository::RelayLabelRepository, types::*};
use sta::infrastructure::modbus::mock::MockRelayController;
use sta::infrastructure::persistence::sqlite_repository::SqliteRelayLabelRepository;
#[tokio::test]
async fn get_relay_status_combines_state_and_label() {
let controller = MockRelayController::new();
let repository = SqliteRelayLabelRepository::in_memory().await.unwrap();
let id = RelayId::new(1).unwrap();
let label = RelayLabel::new("Test Relay".to_string()).unwrap();
repository.save_label(id, label.clone()).await.unwrap();
controller.write_relay_state(id, RelayState::On).await.unwrap();
let use_case = GetRelayStatus::new(Box::new(controller), Box::new(repository));
let relay = use_case.execute(id).await.unwrap();
assert_eq!(relay.id(), id);
assert_eq!(relay.state(), RelayState::On);
assert_eq!(relay.label(), &label);
}
```
**Verification**: Test FAILS (use case not implemented).
#### Task 5.2: Implement GetRelayStatus Use Case
**File**: `src/application/relay/get_status.rs`
**Action**: Implement use case:
```rust
use crate::domain::relay::{
controller::{ControllerError, RelayController},
entity::Relay,
repository::{RelayLabelRepository, RepositoryError},
types::RelayId,
};
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum GetRelayStatusError {
#[error("Controller error: {0}")]
Controller(#[from] ControllerError),
#[error("Repository error: {0}")]
Repository(#[from] RepositoryError),
}
pub struct GetRelayStatus {
controller: Arc<dyn RelayController>,
repository: Arc<dyn RelayLabelRepository>,
}
impl GetRelayStatus {
pub fn new(
controller: Arc<dyn RelayController>,
repository: Arc<dyn RelayLabelRepository>,
) -> Self {
Self {
controller,
repository,
}
}
pub async fn execute(&self, id: RelayId) -> Result<Relay, GetRelayStatusError> {
tracing::debug!(target: "use_case", relay_id = id.value(), "Getting relay status");
// Read state from Modbus hardware
let state = self.controller.read_relay_state(id).await?;
// Read label from repository
let label = self.repository.get_label(id).await?.unwrap_or_default();
let relay = Relay::with_label(id, state, label);
tracing::debug!(target: "use_case", relay_id = id.value(), ?state,
label = relay.label().as_str(), "Retrieved relay status");
Ok(relay)
}
pub async fn execute_all(&self) -> Result<Vec<Relay>, GetRelayStatusError> {
tracing::debug!(target: "use_case", "Getting all relay statuses");
// Read all states from Modbus
let states = self.controller.read_all_states().await?;
// Read all labels from repository
let labels = self.repository.get_all_labels().await?;
let relays: Vec<Relay> = (1..=8)
.map(|id_val| {
let id = RelayId::new(id_val).unwrap();
let state = states[(id_val - 1) as usize];
let label = labels
.iter()
.find(|(label_id, _)| *label_id == id)
.map(|(_, l)| l.clone())
.unwrap_or_default();
Relay::with_label(id, state, label)
})
.collect();
tracing::debug!(target: "use_case", "Retrieved all relay statuses");
Ok(relays)
}
}
```
**Acceptance Criteria**:
- [ ] Use case tests pass
- [ ] Combines controller (state) + repository (label)
- [ ] Structured logging at boundaries
- [ ] Both single and bulk operations work
**Verification**: `cargo test use_cases_test` passes.
#### Task 5.3: Implement ToggleRelay and BulkControl Use Cases
**Files**:
- `src/application/relay/toggle_relay.rs`
- `src/application/relay/bulk_control.rs`
- `src/application/relay/update_label.rs`
**Action**: Follow same TDD pattern (write tests, then implementation).
**ToggleRelay**: Read current state → toggle → write new state
**BulkControl**: Write all ON or all OFF
**UpdateLabel**: Save label to repository
**Verification**: All use case tests pass.
**Deliverables**:
- All use cases implemented with TDD
- Use cases combine controller + repository
- >95% test coverage for application layer
- Structured logging throughout
---
### Phase 6: Presentation Layer - HTTP API (1.5 days)
**Objective**: Expose use cases via RESTful HTTP API with OpenAPI.
**Prerequisites**: Phase 5 complete (use cases)
#### Task 6.1: Define API DTOs and Responses
**File**: `src/route/relay.rs`
**Action**: Create DTOs:
```rust
use poem_openapi::{Object, ApiResponse, payload::Json};
use serde::{Deserialize, Serialize};
#[derive(Object, Debug, Clone, Serialize, Deserialize)]
pub struct RelayDto {
pub id: u8,
pub state: String, // "on" | "off"
pub label: String,
}
#[derive(Object, Debug, Clone, Serialize, Deserialize)]
pub struct RelayListResponse {
pub relays: Vec<RelayDto>,
}
#[derive(Object, Debug, Clone, Serialize, Deserialize)]
pub struct ToggleRequest {
// Empty body - toggle action implied by endpoint
}
#[derive(Object, Debug, Clone, Serialize, Deserialize)]
pub struct UpdateLabelRequest {
pub label: String,
}
#[derive(Object, Debug, Clone, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String, // "healthy" | "unhealthy"
pub device_connected: bool,
pub firmware_version: Option<String>,
}
#[derive(ApiResponse)]
pub enum RelayApiResponse {
#[oai(status = 200)]
Ok(Json<RelayDto>),
#[oai(status = 400)]
BadRequest(Json<ErrorResponse>),
#[oai(status = 500)]
InternalServerError(Json<ErrorResponse>),
#[oai(status = 504)]
GatewayTimeout(Json<ErrorResponse>),
}
#[derive(ApiResponse)]
pub enum RelayListApiResponse {
#[oai(status = 200)]
Ok(Json<RelayListResponse>),
#[oai(status = 500)]
InternalServerError(Json<ErrorResponse>),
}
#[derive(Object, Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
}
```
#### Task 6.2: Implement API Endpoints
**File**: `src/route/relay.rs` (continued)
**Action**: Implement handlers:
```rust
use poem_openapi::{OpenApi, param::Path, payload::Json};
use crate::application::relay::*;
use crate::domain::relay::types::*;
pub struct RelayApi {
get_status: Arc<get_status::GetRelayStatus>,
toggle_relay: Arc<toggle_relay::ToggleRelay>,
bulk_control: Arc<bulk_control::BulkControl>,
update_label: Arc<update_label::UpdateLabel>,
get_health: Arc<get_health::GetDeviceHealth>,
}
impl RelayApi {
pub fn new(
controller: Arc<dyn RelayController>,
repository: Arc<dyn RelayLabelRepository>,
) -> Self {
Self {
get_status: Arc::new(get_status::GetRelayStatus::new(
controller.clone(),
repository.clone(),
)),
toggle_relay: Arc::new(toggle_relay::ToggleRelay::new(
controller.clone(),
repository.clone(),
)),
bulk_control: Arc::new(bulk_control::BulkControl::new(controller.clone())),
update_label: Arc::new(update_label::UpdateLabel::new(repository.clone())),
get_health: Arc::new(get_health::GetDeviceHealth::new(controller.clone())),
}
}
}
#[OpenApi(tag = "ApiCategory::Relay")]
impl RelayApi {
/// Get all relay statuses
#[oai(path = "/relays", method = "get")]
async fn get_all_relays(&self) -> RelayListApiResponse {
tracing::info!(target: "api", "GET /api/relays");
match self.get_status.execute_all().await {
Ok(relays) => {
let dtos: Vec<RelayDto> = relays.iter().map(|r| RelayDto {
id: r.id().value(),
state: match r.state() {
RelayState::On => "on".to_string(),
RelayState::Off => "off".to_string(),
},
label: r.label().as_str().to_string(),
}).collect();
RelayListApiResponse::Ok(Json(RelayListResponse { relays: dtos }))
}
Err(e) => {
tracing::error!(target: "api", error = %e, "Failed to get all relays");
RelayListApiResponse::InternalServerError(Json(ErrorResponse {
error: e.to_string(),
}))
}
}
}
/// Get single relay status
#[oai(path = "/relays/:id", method = "get")]
async fn get_relay(&self, id: Path<u8>) -> RelayApiResponse {
tracing::info!(target: "api", relay_id = id.0, "GET /api/relays/{}", id.0);
let relay_id = match RelayId::new(id.0) {
Ok(id) => id,
Err(e) => {
return RelayApiResponse::BadRequest(Json(ErrorResponse {
error: e.to_string(),
}));
}
};
match self.get_status.execute(relay_id).await {
Ok(relay) => RelayApiResponse::Ok(Json(RelayDto {
id: relay.id().value(),
state: match relay.state() {
RelayState::On => "on".to_string(),
RelayState::Off => "off".to_string(),
},
label: relay.label().as_str().to_string(),
})),
Err(e) => {
tracing::error!(target: "api", relay_id = id.0, error = %e, "Failed to get relay");
RelayApiResponse::InternalServerError(Json(ErrorResponse {
error: e.to_string(),
}))
}
}
}
/// Toggle relay state
#[oai(path = "/relays/:id/toggle", method = "post")]
async fn toggle_relay(&self, id: Path<u8>) -> RelayApiResponse {
tracing::info!(target: "api", relay_id = id.0, "POST /api/relays/{}/toggle", id.0);
let relay_id = match RelayId::new(id.0) {
Ok(id) => id,
Err(e) => {
return RelayApiResponse::BadRequest(Json(ErrorResponse {
error: e.to_string(),
}));
}
};
match self.toggle_relay.execute(relay_id).await {
Ok(relay) => RelayApiResponse::Ok(Json(RelayDto {
id: relay.id().value(),
state: match relay.state() {
RelayState::On => "on".to_string(),
RelayState::Off => "off".to_string(),
},
label: relay.label().as_str().to_string(),
})),
Err(e) => {
tracing::error!(target: "api", relay_id = id.0, error = %e, "Failed to toggle relay");
RelayApiResponse::InternalServerError(Json(ErrorResponse {
error: e.to_string(),
}))
}
}
}
/// Turn all relays ON
#[oai(path = "/relays/bulk/on", method = "post")]
async fn all_on(&self) -> RelayListApiResponse {
tracing::info!(target: "api", "POST /api/relays/bulk/on");
// Implementation...
}
/// Turn all relays OFF
#[oai(path = "/relays/bulk/off", method = "post")]
async fn all_off(&self) -> RelayListApiResponse {
tracing::info!(target: "api", "POST /api/relays/bulk/off");
// Implementation...
}
/// Update relay label
#[oai(path = "/relays/:id/label", method = "patch")]
async fn update_label(&self, id: Path<u8>, req: Json<UpdateLabelRequest>) -> RelayApiResponse {
tracing::info!(target: "api", relay_id = id.0, label = %req.0.label, "PATCH /api/relays/{}/label", id.0);
// Implementation...
}
/// Get device health status
#[oai(path = "/health", method = "get")]
async fn health(&self) -> poem_openapi::payload::Json<HealthResponse> {
tracing::info!(target: "api", "GET /api/health");
// Implementation...
}
}
```
#### Task 6.3: Register RelayApi in Route Aggregator
**File**: `src/route/mod.rs`
**Action**: Add Relay category and register API:
```rust
#[derive(Tags)]
enum ApiCategory {
Health,
Meta,
Relay, // Add this
}
pub(crate) struct Api {
health: health::HealthApi,
meta: meta::MetaApi,
relay: relay::RelayApi, // Add this
}
impl From<&Settings> for Api {
fn from(value: &Settings) -> Self {
let health = health::HealthApi;
let meta = meta::MetaApi::from(&value.application);
// Initialize relay dependencies
let controller = // ... create based on settings
let repository = // ... create based on settings
let relay = relay::RelayApi::new(controller, repository);
Self { health, meta, relay }
}
}
impl Api {
pub fn apis(self) -> (health::HealthApi, meta::MetaApi, relay::RelayApi) {
(self.health, self.meta, self.relay)
}
}
```
#### Task 6.4: Write API Contract Tests
**File**: `tests/contract/relay_api_contract_test.rs`
**Action**: Test API contracts:
```rust
use poem::test::TestClient;
use sta::get_test_app;
#[tokio::test]
async fn get_all_relays_returns_200() {
let app = get_test_app();
let cli = TestClient::new(app);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
let json: serde_json::Value = resp.json().await.value().deserialize();
assert!(json["relays"].is_array());
assert_eq!(json["relays"].as_array().unwrap().len(), 8);
}
#[tokio::test]
async fn toggle_relay_returns_200() {
let app = get_test_app();
let cli = TestClient::new(app);
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status_is_ok();
let json: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(json["id"], 1);
assert!(json["state"] == "on" || json["state"] == "off");
}
#[tokio::test]
async fn invalid_relay_id_returns_400() {
let app = get_test_app();
let cli = TestClient::new(app);
let resp = cli.get("/api/relays/9").send().await;
resp.assert_status(400);
}
```
**Acceptance Criteria**:
- [ ] All 6 endpoints implemented
- [ ] OpenAPI spec auto-generated
- [ ] Swagger UI accessible at `/`
- [ ] All contract tests pass
- [ ] Error responses include meaningful messages
- [ ] Logging at all API boundaries
**Verification**:
- `cargo test contract` passes
- Visit `http://localhost:8000/` and test via Swagger UI
**Deliverables**:
- Complete HTTP API with OpenAPI
- All endpoints tested
- Route registration complete
- API documentation auto-generated
---
### Phase 7: Frontend - Vue 3 Application (2 days)
**Objective**: Build responsive web interface with HTTP polling.
**Prerequisites**: Phase 6 complete (API endpoints working)
#### Task 7.1: Initialize Vue 3 Project
**Directory**: `frontend/`
**Action**:
```bash
npm create vite@latest frontend -- --template vue-ts
cd frontend
npm install
npm install axios
```
**Verification**: `npm run dev` starts development server.
#### Task 7.2: Generate OpenAPI TypeScript Client
**File**: `frontend/src/services/api-client.ts`
**Action**: Use openapi-typescript-codegen or create manual client:
```typescript
import axios, { type AxiosInstance } from 'axios';
export interface RelayDto {
id: number;
state: 'on' | 'off';
label: string;
}
export interface RelayListResponse {
relays: RelayDto[];
}
export interface HealthResponse {
status: 'healthy' | 'unhealthy';
device_connected: boolean;
firmware_version?: string;
}
export class RelayApiClient {
private client: AxiosInstance;
constructor(baseURL: string = 'http://localhost:8000/api') {
this.client = axios.create({ baseURL });
}
async getAllRelays(): Promise<RelayListResponse> {
const response = await this.client.get<RelayListResponse>('/relays');
return response.data;
}
async getRelay(id: number): Promise<RelayDto> {
const response = await this.client.get<RelayDto>(`/relays/${id}`);
return response.data;
}
async toggleRelay(id: number): Promise<RelayDto> {
const response = await this.client.post<RelayDto>(`/relays/${id}/toggle`);
return response.data;
}
async allOn(): Promise<RelayListResponse> {
const response = await this.client.post<RelayListResponse>('/relays/bulk/on');
return response.data;
}
async allOff(): Promise<RelayListResponse> {
const response = await this.client.post<RelayListResponse>('/relays/bulk/off');
return response.data;
}
async updateLabel(id: number, label: string): Promise<RelayDto> {
const response = await this.client.patch<RelayDto>(`/relays/${id}/label`, { label });
return response.data;
}
async getHealth(): Promise<HealthResponse> {
const response = await this.client.get<HealthResponse>('/health');
return response.data;
}
}
export const apiClient = new RelayApiClient();
```
**Verification**: TypeScript compiles without errors.
#### Task 7.3: Implement HTTP Polling Composable
**File**: `frontend/src/composables/useRelayPolling.ts`
**Action**:
```typescript
import { ref, onMounted, onUnmounted } from 'vue';
import { apiClient, type RelayDto, type HealthResponse } from '@/services/api-client';
export function useRelayPolling(intervalMs: number = 2000) {
const relays = ref<RelayDto[]>([]);
const health = ref<HealthResponse | null>(null);
const isLoading = ref(true);
const error = ref<string | null>(null);
let pollingInterval: number | null = null;
const fetchData = async () => {
try {
const [relayData, healthData] = await Promise.all([
apiClient.getAllRelays(),
apiClient.getHealth(),
]);
relays.value = relayData.relays;
health.value = healthData;
error.value = null;
} catch (err: any) {
error.value = err.message || 'Failed to fetch data';
console.error('Polling error:', err);
} finally {
isLoading.value = false;
}
};
const startPolling = () => {
fetchData(); // Immediate fetch
pollingInterval = window.setInterval(fetchData, intervalMs);
};
const stopPolling = () => {
if (pollingInterval !== null) {
clearInterval(pollingInterval);
pollingInterval = null;
}
};
onMounted(startPolling);
onUnmounted(stopPolling);
return {
relays,
health,
isLoading,
error,
refresh: fetchData,
};
}
```
#### Task 7.4: Implement RelayCard Component
**File**: `frontend/src/components/RelayCard.vue`
**Action**:
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { apiClient, type RelayDto } from '@/services/api-client';
const props = defineProps<{
relay: RelayDto;
}>();
const emit = defineEmits<{
updated: [];
}>();
const isToggling = ref(false);
const isEditingLabel = ref(false);
const newLabel = ref(props.relay.label);
const handleToggle = async () => {
isToggling.value = true;
try {
await apiClient.toggleRelay(props.relay.id);
emit('updated');
} catch (error) {
console.error('Toggle failed:', error);
} finally {
isToggling.value = false;
}
};
const handleLabelUpdate = async () => {
try {
await apiClient.updateLabel(props.relay.id, newLabel.value);
isEditingLabel.value = false;
emit('updated');
} catch (error) {
console.error('Label update failed:', error);
}
};
</script>
<template>
<div class="relay-card" :class="{ 'relay-on': relay.state === 'on' }">
<div class="relay-header">
<span class="relay-id">Relay {{ relay.id }}</span>
<span class="relay-state">{{ relay.state.toUpperCase() }}</span>
</div>
<div class="relay-label">
<input
v-if="isEditingLabel"
v-model="newLabel"
@blur="handleLabelUpdate"
@keyup.enter="handleLabelUpdate"
class="label-input"
/>
<span v-else @dblclick="isEditingLabel = true">{{ relay.label }}</span>
</div>
<button
@click="handleToggle"
:disabled="isToggling"
class="toggle-btn"
>
{{ isToggling ? 'Toggling...' : 'Toggle' }}
</button>
</div>
</template>
<style scoped>
.relay-card {
border: 2px solid #ccc;
border-radius: 8px;
padding: 16px;
background: #f9f9f9;
transition: all 0.3s;
}
.relay-card.relay-on {
border-color: #4caf50;
background: #e8f5e9;
}
.relay-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.relay-state {
font-weight: bold;
padding: 4px 8px;
border-radius: 4px;
background: #fff;
}
.toggle-btn {
width: 100%;
padding: 10px;
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.toggle-btn:hover {
background: #1976d2;
}
.toggle-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
```
#### Task 7.5: Implement RelayGrid and App
**Files**:
- `frontend/src/components/RelayGrid.vue`
- `frontend/src/components/BulkControls.vue`
- `frontend/src/components/HealthStatus.vue`
- `frontend/src/App.vue`
**Action**: Create grid layout with bulk controls and health status display.
**Verification**: Frontend displays all 8 relays, polling works, toggles update state.
#### Task 7.6: Responsive Design and Cross-Browser Testing
**Action**: Test on Chrome, Firefox, Safari, Edge. Test mobile/tablet layouts.
**Acceptance Criteria**:
- [ ] HTTP polling every 2 seconds
- [ ] Relay state updates within 2 seconds
- [ ] Toggle actions complete within 1 second
- [ ] Label editing works (double-click, Enter/blur to save)
- [ ] Bulk controls work
- [ ] Health status displays correctly
- [ ] Responsive on mobile/tablet/desktop
- [ ] Works on Chrome, Firefox, Safari, Edge
**Deliverables**:
- Complete Vue 3 frontend
- HTTP polling implemented
- All user stories functional
- Responsive design
---
### Phase 8: Integration & Testing (0.5 days)
**Objective**: End-to-end testing and coverage verification.
#### Task 8.1: Manual Testing Against Real Hardware
**Action**: Test all user stories from spec.md with real device:
- [ ] US1: Monitor relay status
- [ ] US2: Toggle individual relay
- [ ] US3: Bulk relay control
- [ ] US4: System health monitoring
- [ ] US5: Relay labeling
#### Task 8.2: Load Testing
**Action**: Test with 10 concurrent users (use `wrk` or Apache Bench):
```bash
wrk -t10 -c10 -d30s http://localhost:8000/api/relays
```
**Acceptance Criteria**: <100ms API response under load.
#### Task 8.3: Coverage Verification
**Action**:
```bash
just coverage
```
**Acceptance Criteria**: >90% coverage for domain + application layers.
#### Task 8.4: Error Scenario Testing
**Action**: Test error handling:
- [ ] Device disconnected during operation
- [ ] Modbus timeout (simulate with blocked network)
- [ ] Invalid relay IDs via API
- [ ] Database file permissions issue
- [ ] Frontend error display when backend down
**Deliverables**:
- All user stories verified
- Load testing passed
- >90% coverage achieved
- Error handling verified
---
## Testing Strategy
### Test Coverage Targets
| Layer | Coverage Target | Test Type | Tooling |
|-------|----------------|-----------|---------|
| Domain | 100% | Unit tests | `cargo test` |
| Application | >95% | Unit tests with mocks | `mockall` |
| Infrastructure | >80% | Integration tests | mocks + real hardware |
| Presentation | >90% | Contract tests | `poem::test::TestClient` |
| Frontend | >80% | Component tests | Vitest |
### Mock Strategy
**When to use mocks**:
- All CI/CD tests (no hardware available)
- Unit tests for use cases
- Fast feedback during development
**When to use real hardware**:
- Integration tests (marked `#[ignore]`)
- Manual testing before deployment
- Debugging Modbus protocol issues
**Mock Implementation Locations**:
- `src/infrastructure/modbus/mock.rs` - MockRelayController
- Test files: Use `MockRelayController::new()` in tests
### Test Organization
```
tests/
├── unit/ # Fast, no I/O, use mocks
│ └── relay/
│ ├── domain_types_test.rs
│ ├── entity_test.rs
│ └── use_cases_test.rs
├── integration/ # I/O allowed, can use real resources
│ ├── modbus_mock_test.rs
│ ├── modbus_real_hardware_test.rs # cargo test --ignored
│ ├── sqlite_repository_test.rs
│ └── api_integration_test.rs
└── contract/ # API contract validation
└── relay_api_contract_test.rs
```
---
## Dependencies & Setup
### Rust Dependencies (Cargo.toml)
```toml
[dependencies]
# Existing dependencies...
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"
```
### Frontend Dependencies (package.json)
```json
{
"dependencies": {
"vue": "^3.4.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^1.0.0"
}
}
```
### Database Setup
**Location**: `relay_labels.db` (configurable in settings)
**Schema**: Auto-initialized by `SqliteRelayLabelRepository` on first run using `schema.sql`.
**Migrations**: Not needed for MVP (simple schema, auto-create).
### Environment Variables
Add to `settings/development.yaml`:
```yaml
modbus:
host: "192.168.1.100" # Update with actual IP
port: 502
slave_id: 1
timeout_secs: 3
database:
path: "relay_labels.db"
```
---
## Integration Points
### Dependency Injection in Startup
**File**: `src/startup.rs`
**Changes needed**:
1. Create Modbus controller based on settings
2. Create SQLite repository
3. Pass both to `RelayApi::new()`
4. Register `RelayApi` in route aggregator
Example:
```rust
// In Application::build()
let modbus_settings = &settings.modbus;
let controller: Arc<dyn RelayController> = if cfg!(test) {
Arc::new(MockRelayController::new())
} else {
Arc::new(
ModbusRelayController::new(
&modbus_settings.host,
modbus_settings.port,
modbus_settings.slave_id,
modbus_settings.timeout_secs,
)
.await
.expect("Failed to connect to Modbus device"),
)
};
let repository: Arc<dyn RelayLabelRepository> = Arc::new(
SqliteRelayLabelRepository::new(&settings.database.path)
.await
.expect("Failed to initialize database"),
);
let relay_api = RelayApi::new(controller, repository);
```
### Route Registration
**File**: `src/route/mod.rs`
Update `Api::apis()` return type to include `relay::RelayApi`.
### Graceful Degradation (FR-023)
Backend must start even when Modbus device unreachable. Implement connection retry logic in background task:
```rust
// Spawn background task in startup
tokio::spawn(async move {
loop {
if let Err(e) = controller.check_connection().await {
tracing::warn!("Modbus device unavailable: {}", e);
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
```
---
## Verification Checklist
Before marking implementation complete, verify:
### Backend
- [ ] All domain unit tests pass (`cargo test domain`)
- [ ] All application use case tests pass (`cargo test application`)
- [ ] All infrastructure tests pass (`cargo test infrastructure`)
- [ ] All API contract tests pass (`cargo test contract`)
- [ ] Real hardware integration tests pass (`cargo test --ignored`)
- [ ] `cargo clippy` shows no warnings
- [ ] `cargo fmt --check` passes
- [ ] Test coverage >90% (`just coverage`)
- [ ] OpenAPI spec available at `/specs`
- [ ] Swagger UI works at `/`
- [ ] Backend starts successfully when Modbus device unreachable
- [ ] Structured logging outputs to console
### Frontend
- [ ] `npm run build` succeeds
- [ ] All components render correctly
- [ ] HTTP polling updates state every 2 seconds
- [ ] Toggle actions complete within 1 second
- [ ] Bulk controls work (All ON, All OFF)
- [ ] Label editing works (double-click, save on Enter/blur)
- [ ] Health status displays correctly
- [ ] Error messages display when backend unavailable
- [ ] Responsive design works on mobile/tablet/desktop
- [ ] Cross-browser compatibility (Chrome, Firefox, Safari, Edge)
### Integration
- [ ] All user stories from spec.md verified with real hardware
- [ ] Load testing: 10 concurrent users, <100ms API response
- [ ] Error scenarios tested (disconnect, timeout, invalid input)
- [ ] Labels persist across backend restarts
- [ ] Firmware version displays (if available)
### Deployment Readiness
- [ ] Configuration documented in README
- [ ] Environment variables documented
- [ ] Database location configurable
- [ ] Systemd service files created for Raspberry Pi backend
- [ ] Traefik configuration documented (reverse proxy + HTTPS + Authelia)
- [ ] Frontend production build tested
- [ ] Cloudflare Pages deployment configuration ready
- [ ] Backend CORS settings configured for frontend origin
---
## Timeline Summary
| Phase | Duration | Deliverables |
|-------|----------|--------------|
| 0: Setup | 0.5 days | Dependencies, module structure, settings |
| 1: Domain Layer | 1 day | Types, entities, traits (100% coverage) |
| 2: Mock Infrastructure | 0.5 days | MockRelayController, integration tests |
| 3: SQLite Repository | 1 day | Database persistence, schema, tests |
| 4: Real Modbus Client | 1.5 days | tokio-modbus integration, hardware tests |
| 5: Application Use Cases | 1 day | Business logic orchestration, >95% coverage |
| 6: HTTP API | 1.5 days | Poem endpoints, OpenAPI, contract tests |
| 7: Frontend | 2 days | Vue 3 app, polling, responsive design |
| 8: Integration & Testing | 0.5 days | E2E testing, coverage verification |
| **TOTAL** | **9 days** | **Production-ready MVP** |
*Note: Original estimate was 7 days. Revised to 9 days accounting for real hardware integration testing and comprehensive coverage.*
---
## Next Steps After Implementation
Once this implementation plan is complete:
1. Deploy backend to Raspberry Pi 3B+ with Traefik reverse proxy and Authelia authentication
2. Deploy frontend to Cloudflare Pages with environment variable for backend API URL
3. Configure Traefik to handle HTTPS termination and route to backend
4. Consider future enhancements (P3 features):
- Scheduling (turn relay on/off at specific times)
- Automation rules (turn relay on if another relay state changes)
- Metrics and logging (relay toggle history)
- Multi-device support (control multiple 8-relay boards)
3. Monitor production performance and reliability
4. Gather user feedback for UX improvements
---
## References
- [Feature Specification](./spec.md) - Complete requirements and user stories
- [Architecture Decisions](./decisions.md) - Technical choices and rationale
- [Research Findings](./research.md) - tokio-modbus patterns, WebSocket vs polling
- [Type Design](./types-design.md) - Type-Driven Development (TyDD) details
- [Project Constitution](../constitution.md) - Hexagonal architecture, SOLID, TDD principles
- [Modbus Hardware Documentation](../../docs/Modbus_POE_ETH_Relay.md) - Device protocol details