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