# 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, } ``` **Domain → DTO Conversion**: ```rust impl From 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, } ``` --- ### 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, /// ISO 8601 timestamp of last successful communication #[serde(skip_serializing_if = "Option::is_none")] pub last_contact: Option, /// Number of consecutive errors #[serde(skip_serializing_if = "Option::is_none")] pub consecutive_errors: Option, } ``` --- ### 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, } ``` --- ## 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, 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, 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, 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, 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, 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 { 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::new("sqlite::memory:").await } } #[async_trait] impl RelayLabelRepository for SqliteRelayLabelRepository { async fn get_label(&self, id: RelayId) -> Result, RepositoryError> { let label_str: Option = 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, 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` | `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 |