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

26 KiB
Raw Blame History

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_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:

-- 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/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:

{
  "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/relays response
  • POST /api/relays/bulk/on response
  • POST /api/relays/bulk/off response

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}/label request 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/health response

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


Revision History

Version Date Author Changes
1.0 2025-01-09 Type Design Agent Initial data model specification