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.
1032 lines
26 KiB
Markdown
1032 lines
26 KiB
Markdown
# Data Model: Modbus Relay Control System
|
||
|
||
**Created**: 2025-01-09
|
||
**Feature**: [spec.md](./spec.md)
|
||
**Related**: [types-design.md](./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.
|
||
|
||
```sql
|
||
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_id` range: 1-8 (enforced by CHECK constraint)
|
||
- `label` length: 1-50 characters (enforced by CHECK constraint)
|
||
- `label` characters: Alphanumeric, spaces, hyphens, underscores only (enforced by CHECK constraint)
|
||
|
||
**Initial Data**:
|
||
```sql
|
||
-- 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**:
|
||
```sql
|
||
SELECT label FROM relay_labels WHERE relay_id = 3;
|
||
```
|
||
|
||
**Set/update label for relay 5**:
|
||
```sql
|
||
INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at)
|
||
VALUES (5, 'Water Pump', datetime('now'));
|
||
```
|
||
|
||
**Get all labels**:
|
||
```sql
|
||
SELECT relay_id, label
|
||
FROM relay_labels
|
||
ORDER BY relay_id ASC;
|
||
```
|
||
|
||
**Get labels modified in last 24 hours**:
|
||
```sql
|
||
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/relays` response (array)
|
||
- `GET /api/relays/{id}` response (single)
|
||
- `POST /api/relays/{id}/toggle` response (single)
|
||
- `PATCH /api/relays/{id}/label` response (single)
|
||
|
||
**JSON Schema**:
|
||
```json
|
||
{
|
||
"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**:
|
||
```json
|
||
{
|
||
"id": 3,
|
||
"state": "on",
|
||
"label": "Water Pump"
|
||
}
|
||
```
|
||
|
||
**Rust Type Mapping**:
|
||
```rust
|
||
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**:
|
||
```rust
|
||
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/relays` response
|
||
- `POST /api/relays/bulk/on` response
|
||
- `POST /api/relays/bulk/off` response
|
||
|
||
**JSON Schema**:
|
||
```json
|
||
{
|
||
"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**:
|
||
```json
|
||
{
|
||
"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**:
|
||
```rust
|
||
#[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}/label` request body
|
||
|
||
**JSON Schema**:
|
||
```json
|
||
{
|
||
"type": "object",
|
||
"required": ["label"],
|
||
"properties": {
|
||
"label": {
|
||
"type": "string",
|
||
"minLength": 1,
|
||
"maxLength": 50,
|
||
"pattern": "^[a-zA-Z0-9 _-]+$",
|
||
"description": "New label for the relay"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Example**:
|
||
```json
|
||
{
|
||
"label": "Office Fan"
|
||
}
|
||
```
|
||
|
||
**Rust Type Mapping**:
|
||
```rust
|
||
#[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/health` response
|
||
|
||
**JSON Schema**:
|
||
```json
|
||
{
|
||
"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**:
|
||
```json
|
||
{
|
||
"status": "healthy",
|
||
"device_connected": true,
|
||
"firmware_version": "v2.00",
|
||
"last_contact": "2025-01-09T14:30:00Z",
|
||
"consecutive_errors": 0
|
||
}
|
||
```
|
||
|
||
**Degraded**:
|
||
```json
|
||
{
|
||
"status": "degraded",
|
||
"device_connected": true,
|
||
"firmware_version": "v2.00",
|
||
"last_contact": "2025-01-09T14:28:00Z",
|
||
"consecutive_errors": 3
|
||
}
|
||
```
|
||
|
||
**Unhealthy**:
|
||
```json
|
||
{
|
||
"status": "unhealthy",
|
||
"device_connected": false,
|
||
"last_contact": "2025-01-09T14:00:00Z",
|
||
"consecutive_errors": 10
|
||
}
|
||
```
|
||
|
||
**Rust Type Mapping**:
|
||
```rust
|
||
#[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**:
|
||
```json
|
||
{
|
||
"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):
|
||
```json
|
||
{
|
||
"error": "InvalidRelayId",
|
||
"message": "Relay ID 9 out of range (valid: 1-8)",
|
||
"details": {
|
||
"value": 9,
|
||
"min": 1,
|
||
"max": 8
|
||
}
|
||
}
|
||
```
|
||
|
||
**400 Bad Request** (Invalid label):
|
||
```json
|
||
{
|
||
"error": "InvalidLabel",
|
||
"message": "Relay label too long (max: 50, got: 73)"
|
||
}
|
||
```
|
||
|
||
**500 Internal Server Error** (Modbus communication failure):
|
||
```json
|
||
{
|
||
"error": "ModbusCommunicationError",
|
||
"message": "Failed to read relay state: Connection timeout after 3 seconds"
|
||
}
|
||
```
|
||
|
||
**504 Gateway Timeout** (Modbus timeout):
|
||
```json
|
||
{
|
||
"error": "ModbusTimeout",
|
||
"message": "Modbus operation timed out after 3 seconds"
|
||
}
|
||
```
|
||
|
||
**Rust Type Mapping**:
|
||
```rust
|
||
#[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):
|
||
```rust
|
||
// 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**:
|
||
```rust
|
||
// 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):
|
||
```rust
|
||
// 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):
|
||
```rust
|
||
// 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):
|
||
```rust
|
||
// 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**:
|
||
```rust
|
||
// 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**:
|
||
```rust
|
||
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.
|
||
|
||
```yaml
|
||
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.
|
||
|
||
```yaml
|
||
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.
|
||
|
||
```yaml
|
||
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.
|
||
|
||
```rust
|
||
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`
|
||
|
||
```rust
|
||
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**:
|
||
```rust
|
||
let mut relay = Relay::new(RelayId::new(3)?, RelayState::Off);
|
||
relay.set_label(Some(RelayLabel::new("Water Pump".to_string())?));
|
||
relay.toggle();
|
||
```
|
||
|
||
**API Response**:
|
||
```json
|
||
{
|
||
"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**:
|
||
```rust
|
||
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**:
|
||
```rust
|
||
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](./spec.md) - Complete requirements and user stories
|
||
- [Type Design](./types-design.md) - Domain type definitions and validation
|
||
- [Implementation Plan](./plan.md) - Technical architecture and implementation strategy
|
||
- [Modbus Hardware Documentation](../../docs/Modbus_POE_ETH_Relay.md) - Device protocol details
|
||
|
||
---
|
||
|
||
## Revision History
|
||
|
||
| Version | Date | Author | Changes |
|
||
|---------|------------|-------------------|----------------------------------|
|
||
| 1.0 | 2025-01-09 | Type Design Agent | Initial data model specification |
|