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.
65 KiB
Implementation Plan: Modbus Relay Control System
Branch: 001-modbus-relay-control | Date: 2025-12-29 | Spec: 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)
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)
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 (
starepository) - Rust toolchain 1.75+
- Node.js 18+ (for frontend)
Tasks:
Task 0.1: Add Rust Dependencies
File: Cargo.toml
Action: Add the following dependencies:
[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.rssrc/domain/relay/mod.rs
Action: Create empty module files with proper visibility:
// src/domain/mod.rs
pub mod relay;
// 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:
#[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:
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:
#[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:
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 clippyshows 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:
#[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:
#[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:
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):
// 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>;
}
// 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:
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:
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)
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:
-- 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:
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:
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:
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):
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 testpasses (hardware tests skipped)cargo test --ignoredpasses 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:
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:
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.rssrc/application/relay/bulk_control.rssrc/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:
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:
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:
#[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:
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 contractpasses- 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:
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:
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:
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:
<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.vuefrontend/src/components/BulkControls.vuefrontend/src/components/HealthStatus.vuefrontend/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):
wrk -t10 -c10 -d30s http://localhost:8000/api/relays
Acceptance Criteria: <100ms API response under load.
Task 8.3: Coverage Verification
Action:
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)
[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)
{
"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:
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:
- Create Modbus controller based on settings
- Create SQLite repository
- Pass both to
RelayApi::new() - Register
RelayApiin route aggregator
Example:
// 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:
// 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 clippyshows no warningscargo fmt --checkpasses- 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 buildsucceeds- 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:
- Deploy backend to Raspberry Pi 3B+ with Traefik reverse proxy and Authelia authentication
- Deploy frontend to Cloudflare Pages with environment variable for backend API URL
- Configure Traefik to handle HTTPS termination and route to backend
- 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)
- Monitor production performance and reliability
- Gather user feedback for UX improvements
References
- Feature Specification - Complete requirements and user stories
- Architecture Decisions - Technical choices and rationale
- Research Findings - tokio-modbus patterns, WebSocket vs polling
- Type Design - Type-Driven Development (TyDD) details
- Project Constitution - Hexagonal architecture, SOLID, TDD principles
- Modbus Hardware Documentation - Device protocol details