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

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

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

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

1032 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |