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.
26 KiB
Data Model: Modbus Relay Control System
Created: 2025-01-09 Feature: spec.md Related: types-design.md - Domain type definitions Status: Design
Overview
This document defines the data model for the Modbus relay control system, including database schemas, API data transfer objects (DTOs), serialization formats, and persistence layer structures. This complements types-design.md which defines domain types and validation logic.
Scope
This document covers:
- SQLite database schemas for persistent storage
- API request/response DTOs (JSON contracts)
- Modbus protocol data structures
- Configuration file formats (YAML)
- Serialization/deserialization mappings
Out of scope (see types-design.md):
- Domain type validation logic
- Business rule enforcement
- Type safety guarantees
Database Schemas
SQLite Database: relay_labels.db
Purpose: Persist custom relay labels across application restarts.
Location: Configurable via settings.database.path (default: relay_labels.db)
Schema Version: 1.0
Table: relay_labels
Purpose: Store custom labels for each of the 8 relays.
CREATE TABLE IF NOT EXISTS relay_labels (
relay_id INTEGER PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Constraints
CHECK(relay_id >= 1 AND relay_id <= 8),
CHECK(length(label) >= 1 AND length(label) <= 50),
CHECK(label NOT GLOB '*[^a-zA-Z0-9 _-]*') -- Only alphanumeric, spaces, hyphens, underscores
);
-- Index for timestamp queries (future analytics)
CREATE INDEX IF NOT EXISTS idx_relay_labels_updated_at
ON relay_labels(updated_at);
Columns:
| Column | Type | Nullable | Description |
|---|---|---|---|
relay_id |
INTEGER | NO | Relay identifier (1-8), PRIMARY KEY |
label |
TEXT | NO | Custom label (1-50 characters) |
created_at |
TEXT | NO | ISO 8601 timestamp when label was first set |
updated_at |
TEXT | NO | ISO 8601 timestamp of last label update |
Constraints:
relay_idrange: 1-8 (enforced by CHECK constraint)labellength: 1-50 characters (enforced by CHECK constraint)labelcharacters: Alphanumeric, spaces, hyphens, underscores only (enforced by CHECK constraint)
Initial Data:
-- Pre-populate with default labels on database initialization
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');
Migration Strategy:
- For MVP: Schema auto-created by SQLx on first run
- For future versions: Use SQLx migrations with version tracking
Example Queries
Get label for relay 3:
SELECT label FROM relay_labels WHERE relay_id = 3;
Set/update label for relay 5:
INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at)
VALUES (5, 'Water Pump', datetime('now'));
Get all labels:
SELECT relay_id, label
FROM relay_labels
ORDER BY relay_id ASC;
Get labels modified in last 24 hours:
SELECT relay_id, label, updated_at
FROM relay_labels
WHERE updated_at >= datetime('now', '-1 day')
ORDER BY updated_at DESC;
API Data Transfer Objects (DTOs)
JSON Serialization Format
Content-Type: application/json
Character Encoding: UTF-8
Date Format: ISO 8601 (e.g., 2025-01-09T14:30:00Z)
RelayDto
Purpose: Represents a single relay's complete state for API responses.
Used in:
GET /api/relaysresponse (array)GET /api/relays/{id}response (single)POST /api/relays/{id}/toggleresponse (single)PATCH /api/relays/{id}/labelresponse (single)
JSON Schema:
{
"type": "object",
"required": ["id", "state"],
"properties": {
"id": {
"type": "integer",
"minimum": 1,
"maximum": 8,
"description": "Relay identifier (1-8)"
},
"state": {
"type": "string",
"enum": ["on", "off"],
"description": "Current relay state"
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"pattern": "^[a-zA-Z0-9 _-]+$",
"description": "Custom relay label (optional)"
}
}
}
Example:
{
"id": 3,
"state": "on",
"label": "Water Pump"
}
Rust Type Mapping:
use serde::{Deserialize, Serialize};
use poem_openapi::Object;
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct RelayDto {
/// Relay identifier (1-8)
pub id: u8,
/// Current relay state: "on" or "off"
pub state: String,
/// Custom relay label (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
Domain → DTO Conversion:
impl From<Relay> for RelayDto {
fn from(relay: Relay) -> Self {
Self {
id: relay.id().as_u8(),
state: match relay.state() {
RelayState::On => "on".to_string(),
RelayState::Off => "off".to_string(),
},
label: relay.label().map(|l| l.as_str().to_string()),
}
}
}
RelayListResponse
Purpose: Response for endpoints returning multiple relays.
Used in:
GET /api/relaysresponsePOST /api/relays/bulk/onresponsePOST /api/relays/bulk/offresponse
JSON Schema:
{
"type": "object",
"required": ["relays"],
"properties": {
"relays": {
"type": "array",
"minItems": 8,
"maxItems": 8,
"items": { "$ref": "#/components/schemas/RelayDto" },
"description": "Array of all 8 relays in order (IDs 1-8)"
}
}
}
Example:
{
"relays": [
{ "id": 1, "state": "off", "label": "Garage Light" },
{ "id": 2, "state": "on", "label": "Water Pump" },
{ "id": 3, "state": "off", "label": "Relay 3" },
{ "id": 4, "state": "off", "label": "Relay 4" },
{ "id": 5, "state": "on", "label": "Relay 5" },
{ "id": 6, "state": "off", "label": "Relay 6" },
{ "id": 7, "state": "off", "label": "Relay 7" },
{ "id": 8, "state": "off", "label": "Relay 8" }
]
}
Rust Type Mapping:
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct RelayListResponse {
/// Array of all 8 relays
pub relays: Vec<RelayDto>,
}
UpdateLabelRequest
Purpose: Request body for updating a relay label.
Used in:
PATCH /api/relays/{id}/labelrequest body
JSON Schema:
{
"type": "object",
"required": ["label"],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"pattern": "^[a-zA-Z0-9 _-]+$",
"description": "New label for the relay"
}
}
}
Example:
{
"label": "Office Fan"
}
Rust Type Mapping:
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct UpdateLabelRequest {
/// New label for the relay (1-50 characters)
pub label: String,
}
HealthResponse
Purpose: Device health and connectivity status.
Used in:
GET /api/healthresponse
JSON Schema:
{
"type": "object",
"required": ["status", "device_connected"],
"properties": {
"status": {
"type": "string",
"enum": ["healthy", "degraded", "unhealthy"],
"description": "Overall health status"
},
"device_connected": {
"type": "boolean",
"description": "Whether Modbus device is reachable"
},
"firmware_version": {
"type": "string",
"maxLength": 20,
"description": "Device firmware version (optional)"
},
"last_contact": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of last successful communication"
},
"consecutive_errors": {
"type": "integer",
"minimum": 0,
"description": "Number of consecutive errors (for degraded/unhealthy status)"
}
}
}
Examples:
Healthy:
{
"status": "healthy",
"device_connected": true,
"firmware_version": "v2.00",
"last_contact": "2025-01-09T14:30:00Z",
"consecutive_errors": 0
}
Degraded:
{
"status": "degraded",
"device_connected": true,
"firmware_version": "v2.00",
"last_contact": "2025-01-09T14:28:00Z",
"consecutive_errors": 3
}
Unhealthy:
{
"status": "unhealthy",
"device_connected": false,
"last_contact": "2025-01-09T14:00:00Z",
"consecutive_errors": 10
}
Rust Type Mapping:
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct HealthResponse {
/// Overall health status
pub status: String, // "healthy" | "degraded" | "unhealthy"
/// Whether Modbus device is currently reachable
pub device_connected: bool,
/// Device firmware version (if available)
#[serde(skip_serializing_if = "Option::is_none")]
pub firmware_version: Option<String>,
/// ISO 8601 timestamp of last successful communication
#[serde(skip_serializing_if = "Option::is_none")]
pub last_contact: Option<String>,
/// Number of consecutive errors
#[serde(skip_serializing_if = "Option::is_none")]
pub consecutive_errors: Option<u32>,
}
ErrorResponse
Purpose: Standardized error response for all API errors.
Used in:
- All API endpoints (4xx, 5xx responses)
JSON Schema:
{
"type": "object",
"required": ["error", "message"],
"properties": {
"error": {
"type": "string",
"description": "Error code or type"
},
"message": {
"type": "string",
"description": "Human-readable error message"
},
"details": {
"type": "object",
"description": "Additional error context (optional)"
}
}
}
Examples:
400 Bad Request (Invalid relay ID):
{
"error": "InvalidRelayId",
"message": "Relay ID 9 out of range (valid: 1-8)",
"details": {
"value": 9,
"min": 1,
"max": 8
}
}
400 Bad Request (Invalid label):
{
"error": "InvalidLabel",
"message": "Relay label too long (max: 50, got: 73)"
}
500 Internal Server Error (Modbus communication failure):
{
"error": "ModbusCommunicationError",
"message": "Failed to read relay state: Connection timeout after 3 seconds"
}
504 Gateway Timeout (Modbus timeout):
{
"error": "ModbusTimeout",
"message": "Modbus operation timed out after 3 seconds"
}
Rust Type Mapping:
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct ErrorResponse {
/// Error code or type
pub error: String,
/// Human-readable error message
pub message: String,
/// Additional error context (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
Modbus Protocol Data Structures
Modbus TCP Protocol
Protocol: Modbus TCP (not RTU over serial) Port: 502 (standard Modbus TCP port) Transport: TCP/IP Framing: MBAP header (no CRC validation needed)
Modbus Function Codes
| Function Code | Name | Purpose | Request | Response |
|---|---|---|---|---|
0x01 |
Read Coils | Read relay states | Start address (0-7), quantity (1-8) | Coil values (bit array) |
0x05 |
Write Single Coil | Toggle single relay | Address (0-7), value (0xFF00=ON, 0x0000=OFF) | Echo request |
0x0F |
Write Multiple Coils | Bulk relay control | Start address, quantity, byte count, values | Echo request |
0x03 |
Read Holding Registers | Read firmware version | Register address (0x8000), quantity (1) | Register value (version * 100) |
Coil Address Mapping
User-facing RelayId (1-8) → Modbus Address (0-7):
| Relay ID (User) | Modbus Coil Address | Description |
|---|---|---|
| 1 | 0x0000 (0) | Relay 1 |
| 2 | 0x0001 (1) | Relay 2 |
| 3 | 0x0002 (2) | Relay 3 |
| 4 | 0x0003 (3) | Relay 4 |
| 5 | 0x0004 (4) | Relay 5 |
| 6 | 0x0005 (5) | Relay 6 |
| 7 | 0x0006 (6) | Relay 7 |
| 8 | 0x0007 (7) | Relay 8 |
Conversion Formula:
- User → Modbus:
modbus_address = relay_id - 1 - Modbus → User:
relay_id = modbus_address + 1
Read Coils Request (0x01)
Purpose: Read current state of one or more relays.
Request Structure (tokio-modbus handles framing):
// Read all 8 relays
ctx.read_coils(0x0000, 8).await?
// Read single relay (ID 3 = address 2)
ctx.read_coils(0x0002, 1).await?
Response: Result<Vec<bool>, tokio_modbus::Error>
true= Relay ON (coil energized)false= Relay OFF (coil de-energized)
Example:
// Read all relays
let coils = client.read_coils(0x0000, 8).await?;
// coils = [false, true, false, false, true, false, false, false]
// Relays 2 and 5 are ON, others are OFF
Write Single Coil Request (0x05)
Purpose: Toggle a single relay on or off.
Request Structure (tokio-modbus handles framing):
// Turn relay 3 ON (address 2)
ctx.write_single_coil(0x0002, true).await?
// Turn relay 3 OFF
ctx.write_single_coil(0x0002, false).await?
Response: Result<(), tokio_modbus::Error>
Coil Value Encoding:
true= ON (0xFF00 in Modbus protocol)false= OFF (0x0000 in Modbus protocol)
Write Multiple Coils Request (0x0F)
Purpose: Set state of multiple relays in one operation (bulk control).
Request Structure (tokio-modbus handles framing):
// Turn all 8 relays ON
let all_on = vec![true; 8];
ctx.write_multiple_coils(0x0000, &all_on).await?
// Turn all 8 relays OFF
let all_off = vec![false; 8];
ctx.write_multiple_coils(0x0000, &all_off).await?
Response: Result<(), tokio_modbus::Error>
Read Holding Registers Request (0x03)
Purpose: Read firmware version from device.
Request Structure (tokio-modbus handles framing):
// Read firmware version register
let registers = ctx.read_holding_registers(0x8000, 1).await?;
let version_raw = registers[0];
let version = f32::from(version_raw) / 100.0;
let version_str = format!("v{:.2}", version);
// Example: version_raw = 200 → "v2.00"
Response: Result<Vec<u16>, tokio_modbus::Error>
Encoding:
- Firmware version is stored as
u16= version × 100 - Example:
200= version 2.00,157= version 1.57
Note: This may not be supported by all devices. Handle ModbusException gracefully.
Error Handling
tokio-modbus Error Types:
// Nested Result structure
Result<Result<T, Exception>, io::Error>
// Exception codes (from Modbus protocol)
pub enum Exception {
IllegalFunction = 0x01,
IllegalDataAddress = 0x02,
IllegalDataValue = 0x03,
ServerDeviceFailure = 0x04,
// ... (other codes)
}
Mapping to Domain Errors:
match result {
Ok(Ok(data)) => Ok(data), // Success
Ok(Err(Exception::IllegalDataAddress)) =>
Err(ControllerError::InvalidRelayId),
Ok(Err(exception)) =>
Err(ControllerError::ModbusException(format!("{:?}", exception))),
Err(io_error) =>
Err(ControllerError::ConnectionError(io_error.to_string())),
}
Configuration File Format (YAML)
settings/base.yaml
Purpose: Base configuration shared across all environments.
application:
name: "STA - Smart Temperature & Appliance Control"
version: "1.0.0"
host: "0.0.0.0"
port: 8080
modbus:
# Modbus device IP address (update for your network)
host: "192.168.0.200"
# Modbus TCP port (standard: 502)
port: 502
# Modbus slave/unit ID (typically 0 or 1)
slave_id: 0
# Operation timeout in seconds
timeout_secs: 5
# Number of retry attempts on failure
retry_attempts: 1
relay:
# Maximum label length (characters)
label_max_length: 50
database:
# SQLite database file path
path: "relay_labels.db"
rate_limit:
# Requests per minute per IP
requests_per_minute: 100
cors:
# CORS allowed origins (production should override)
allowed_origins: []
# Allow credentials (cookies, authorization headers)
allow_credentials: false
# Preflight cache duration (seconds)
max_age_secs: 3600
logging:
# Log level: trace, debug, info, warn, error
level: "info"
# Enable structured JSON logging
json: false
settings/development.yaml
Purpose: Development environment overrides.
application:
host: "127.0.0.1"
port: 8080
modbus:
# Local test device or mock
host: "192.168.0.200"
timeout_secs: 3
cors:
# Permissive CORS for local development
allowed_origins: ["*"]
allow_credentials: false
logging:
level: "debug"
json: false
settings/production.yaml
Purpose: Production environment configuration.
application:
host: "0.0.0.0"
port: 8080
modbus:
# Production device IP (configured during deployment)
host: "${MODBUS_DEVICE_IP}"
timeout_secs: 5
cors:
# Specific origin for production frontend
allowed_origins: ["https://sta.yourdomain.com"]
allow_credentials: true
database:
path: "/var/lib/sta/relay_labels.db"
logging:
level: "info"
json: true # Structured logging for production
Persistence Layer Repository Interface
RelayLabelRepository Trait
Purpose: Abstract interface for relay label persistence.
use async_trait::async_trait;
#[async_trait]
pub trait RelayLabelRepository: Send + Sync {
/// Get label for a specific relay
async fn get_label(&self, id: RelayId)
-> Result<Option<RelayLabel>, RepositoryError>;
/// Set label for a specific relay
async fn set_label(&self, id: RelayId, label: RelayLabel)
-> Result<(), RepositoryError>;
/// Get all relay labels (IDs 1-8)
async fn get_all_labels(&self)
-> Result<Vec<(RelayId, RelayLabel)>, RepositoryError>;
/// Delete label for a relay (revert to default)
async fn delete_label(&self, id: RelayId)
-> Result<(), RepositoryError>;
}
SQLite Repository Implementation
Implementation: SqliteRelayLabelRepository
use sqlx::{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?;
// Initialize schema
sqlx::query(include_str!("schema.sql"))
.execute(&pool)
.await?;
Ok(Self { pool })
}
pub async fn in_memory() -> Result<Self, RepositoryError> {
Self::new("sqlite::memory:").await
}
}
#[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 = ?"
)
.bind(id.as_u8())
.fetch_optional(&self.pool)
.await?;
match label_str {
Some(s) => Ok(Some(RelayLabel::new(s)?)),
None => Ok(None),
}
}
async fn set_label(&self, id: RelayId, label: RelayLabel)
-> Result<(), RepositoryError>
{
sqlx::query(
"INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at)
VALUES (?, ?, datetime('now'))"
)
.bind(id.as_u8())
.bind(label.as_str())
.execute(&self.pool)
.await?;
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?;
let mut result = Vec::new();
for row in rows {
let id_val: u8 = row.try_get("relay_id")?;
let label_str: String = row.try_get("label")?;
let id = RelayId::new(id_val)?;
let label = RelayLabel::new(label_str)?;
result.push((id, label));
}
Ok(result)
}
async fn delete_label(&self, id: RelayId)
-> Result<(), RepositoryError>
{
sqlx::query("DELETE FROM relay_labels WHERE relay_id = ?")
.bind(id.as_u8())
.execute(&self.pool)
.await?;
Ok(())
}
}
Domain to DTO Mapping Summary
| Domain Type | DTO Type | Mapping |
|---|---|---|
Relay |
RelayDto |
Direct field mapping with string conversion |
RelayState::On |
"on" |
Lowercase string |
RelayState::Off |
"off" |
Lowercase string |
RelayId(3) |
3 |
Extract inner u8 |
RelayLabel("Pump") |
"Pump" |
Extract inner String |
Vec<Relay> |
RelayListResponse |
Wrap in relays field |
HealthStatus |
HealthResponse |
Convert enum to status string + fields |
DeviceHealth |
HealthResponse |
Extract fields, format timestamps |
Serialization Examples
Relay State Transitions
Domain Event:
let mut relay = Relay::new(RelayId::new(3)?, RelayState::Off);
relay.set_label(Some(RelayLabel::new("Water Pump".to_string())?));
relay.toggle();
API Response:
{
"id": 3,
"state": "on",
"label": "Water Pump"
}
Database to Domain
SQLite Row:
relay_id | label | created_at | updated_at
---------|-------------|-------------------------|-------------------------
3 | Water Pump | 2025-01-08 10:00:00 | 2025-01-09 14:30:00
Domain Object:
let relay_id = RelayId::new(3)?;
let label = RelayLabel::new("Water Pump".to_string())?;
Modbus to Domain
Modbus Read Response (Function Code 0x01):
Read Coils(address=0, quantity=8)
Response: [false, true, false, false, true, false, false, false]
Domain Mapping:
let relay_states = vec![
(RelayId::new(1)?, RelayState::Off),
(RelayId::new(2)?, RelayState::On),
(RelayId::new(3)?, RelayState::Off),
(RelayId::new(4)?, RelayState::Off),
(RelayId::new(5)?, RelayState::On),
(RelayId::new(6)?, RelayState::Off),
(RelayId::new(7)?, RelayState::Off),
(RelayId::new(8)?, RelayState::Off),
];
Data Flow Summary
┌─────────────────────────────────────────────────────────────┐
│ API Layer (JSON) │
│ RelayDto, UpdateLabelRequest, HealthResponse, etc. │
└────────────────────────┬────────────────────────────────────┘
│ Parse/Validate
▼
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer (Types) │
│ RelayId, RelayState, Relay, RelayLabel, etc. │
└────────────┬────────────────────────────────┬───────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ Infrastructure │ │ Infrastructure │
│ (Modbus Protocol) │ │ (SQLite Persistence) │
│ │ │ │
│ - Coil addresses (0-7) │ │ - relay_labels table │
│ - Function codes │ │ - RelayLabelRepository │
│ - Read/Write ops │ │ - SQLx queries │
└────────────────────────┘ └────────────────────────────┘
References
- Feature Specification - Complete requirements and user stories
- Type Design - Domain type definitions and validation
- Implementation Plan - Technical architecture and implementation strategy
- Modbus Hardware Documentation - Device protocol details
Revision History
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2025-01-09 | Type Design Agent | Initial data model specification |