Files
sta/specs/001-modbus-relay-control/plan.md
Lucien Cartier-Tilet a683810bdc docs: add project specs and documentation for Modbus relay control
Initialize project documentation structure:
- Add CLAUDE.md with development guidelines and architecture principles
- Add project constitution (v1.1.0) with hexagonal architecture and SOLID principles
- Add MCP server configuration for Context7 integration

Feature specification (001-modbus-relay-control):
- Complete feature spec for web-based Modbus relay control system
- Implementation plan with TDD approach using SQLx for persistence
- Type-driven development design for domain types
- Technical decisions document (SQLx over rusqlite, SQLite persistence)
- Detailed task breakdown (94 tasks across 8 phases)
- Specification templates for future features

Documentation:
- Modbus POE ETH Relay hardware documentation
- Modbus Application Protocol specification (PDF)

Project uses SQLx for compile-time verified SQL queries, aligned with
type-driven development principles.
2026-01-22 00:57:10 +01:00

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 RTU over 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 (Modbus RTU over TCP)
  • Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI)
  • 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
└── 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 (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:

[dependencies]
tokio-modbus = { version = "0.17.0", features = ["rtu", "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:

// 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 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:

#[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 RTU over 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 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:

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.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:

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 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:

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.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):

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", features = ["rtu", "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:

  1. Create Modbus controller based on settings
  2. Create SQLite repository
  3. Pass both to RelayApi::new()
  4. Register RelayApi in 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 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)
  5. Monitor production performance and reliability
  6. Gather user feedback for UX improvements

References