Files
sta/specs/001-modbus-relay-control/types-design.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

24 KiB

Type Design: Modbus Relay Control System

Created: 2025-12-28 Feature: spec.md Language: Rust Status: Design

Overview

This document defines the type hierarchy for a Modbus relay control system that manages 8 relay channels via Modbus RTU over TCP. The design enforces compile-time guarantees for relay identifiers, states, and labels while preventing common errors like invalid relay IDs or malformed labels.

Design Principles

  1. Make illegal states unrepresentable: Invalid relay IDs (0, 9+) cannot be constructed
  2. Validate at boundaries, trust internally: Parse once at API/Modbus boundaries, trust types everywhere else
  3. Zero-cost abstractions: Use #[repr(transparent)] for single-field newtypes
  4. Clear error messages: Validation errors provide actionable context
  5. Type safety over convenience: Prevent mixing RelayId with raw integers

Language

Target: Rust (edition 2021)

Key features used:

  • Newtype pattern with #[repr(transparent)]
  • Derive macros for common traits
  • thiserror for error types
  • serde for serialization (API boundaries)

Domain Primitives

RelayId

Purpose: Type-safe relay identifier preventing out-of-range errors and accidental mixing with other integer types.

Wraps: u8

Invariants:

  • Value MUST be in range 1-8 (inclusive)
  • Represents user-facing relay number (not Modbus address)
  • MUST NOT be constructed with value 0 or > 8

Constructor Signature:

impl RelayId {
    pub fn new(value: u8) -> Result<Self, RelayIdError>;
}

Error Cases:

  • RelayIdError::OutOfRange { value, min: 1, max: 8 }: Value outside valid range

Methods:

impl RelayId {
    // Access raw value (for display, logging)
    pub fn as_u8(&self) -> u8;

    // Convert to Modbus address (0-7)
    pub fn to_modbus_address(&self) -> u16;

    // Convert from Modbus address (0-7) to RelayId (1-8)
    pub fn from_modbus_address(address: u16) -> Result<Self, RelayIdError>;
}

Traits to Implement:

  • Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord (value semantics)
  • Display - Format as "Relay {id}"
  • Serialize, Deserialize - For API JSON (serialize as u8)

Rust-Specific:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(transparent)]
pub struct RelayId(u8);

RelayLabel

Purpose: Validated custom label for relays with length and character constraints.

Wraps: String

Invariants:

  • Length MUST be 1-50 characters (inclusive)
  • MUST contain only alphanumeric characters, spaces, hyphens, underscores
  • MUST NOT be empty
  • MUST NOT consist only of whitespace
  • Leading/trailing whitespace is trimmed on construction

Constructor Signature:

impl RelayLabel {
    pub fn new(value: String) -> Result<Self, RelayLabelError>;
}

Error Cases:

  • RelayLabelError::Empty: String is empty or only whitespace
  • RelayLabelError::TooLong { max: 50, actual }: Exceeds 50 characters
  • RelayLabelError::InvalidCharacters { position, char }: Contains disallowed character

Methods:

impl RelayLabel {
    // Access inner string
    pub fn as_str(&self) -> &str;

    // Default label for unlabeled relays
    pub fn default_for_relay(id: RelayId) -> Self;
}

Traits to Implement:

  • Debug, Clone, PartialEq, Eq, Hash (no Copy - owns String)
  • Display - Return inner string as-is
  • Serialize, Deserialize - Serialize as string
  • AsRef<str> - Allow ergonomic string access

Rust-Specific:

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct RelayLabel(String);

ModbusAddress

Purpose: Type-safe Modbus coil address preventing confusion with RelayId.

Wraps: u16

Invariants:

  • Value MUST be in range 0-7 (for 8-channel relay)
  • Represents Modbus protocol address (0-indexed)
  • MUST NOT be confused with RelayId (which is 1-indexed)

Constructor Signature:

impl ModbusAddress {
    pub fn new(value: u16) -> Result<Self, ModbusAddressError>;
}

Error Cases:

  • ModbusAddressError::OutOfRange { value, max: 7 }: Address exceeds device capacity

Methods:

impl ModbusAddress {
    pub fn as_u16(&self) -> u16;

    // Convert to RelayId (add 1)
    pub fn to_relay_id(&self) -> RelayId;

    // Convert from RelayId (subtract 1)
    pub fn from_relay_id(id: RelayId) -> Self;
}

Traits to Implement:

  • Debug, Clone, Copy, PartialEq, Eq, Hash (value type)
  • Display - Format as "Address {value}"

Rust-Specific:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct ModbusAddress(u16);

Design Note: This type prevents the common error of using RelayId (1-8) directly as Modbus address (0-7).


FirmwareVersion

Purpose: Validated firmware version string with format constraints.

Wraps: String

Invariants:

  • MUST NOT be empty
  • MUST be max 20 characters
  • Format: semantic versioning (e.g., "1.2.3") or vendor-specific
  • Leading/trailing whitespace trimmed

Constructor Signature:

impl FirmwareVersion {
    pub fn new(value: String) -> Result<Self, FirmwareVersionError>;
}

Error Cases:

  • FirmwareVersionError::Empty: Version string is empty
  • FirmwareVersionError::TooLong { max: 20, actual }: Exceeds length limit

Methods:

impl FirmwareVersion {
    pub fn as_str(&self) -> &str;
}

Traits to Implement:

  • Debug, Clone, PartialEq, Eq (no Hash - version comparison)
  • Display - Return version string
  • Serialize, Deserialize - As string

Rust-Specific:

#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct FirmwareVersion(String);

Sum Types / Enums

RelayState

Purpose: Explicit representation of relay physical state.

Variants:

pub enum RelayState {
    On,
    Off,
}

Pattern Matching: Required for exhaustiveness Compiler Enforcement: Yes

Methods:

impl RelayState {
    // Toggle state
    pub fn toggle(&self) -> Self;

    // Convert to boolean (On = true, Off = false)
    pub fn as_bool(&self) -> bool;

    // Convert from boolean
    pub fn from_bool(value: bool) -> Self;

    // Convert to Modbus coil value (On = 0xFF00, Off = 0x0000)
    pub fn to_modbus_coil(&self) -> u16;

    // Convert from Modbus coil value
    pub fn from_modbus_coil(value: u16) -> Self;
}

Traits to Implement:

  • Debug, Clone, Copy, PartialEq, Eq, Hash
  • Display - "ON" or "OFF"
  • Serialize, Deserialize - As lowercase string "on"/"off" for API

Design Note: Explicit enum prevents boolean blindness and makes code self-documenting.


BulkOperation

Purpose: Explicit representation of bulk relay operations.

Variants:

pub enum BulkOperation {
    AllOn,
    AllOff,
}

Pattern Matching: Required Compiler Enforcement: Yes

Methods:

impl BulkOperation {
    // Convert to target RelayState
    pub fn target_state(&self) -> RelayState;
}

Traits to Implement:

  • Debug, Clone, Copy, PartialEq, Eq
  • Display - "All ON" or "All OFF"
  • Serialize, Deserialize - As "all_on" or "all_off" for API

HealthStatus

Purpose: Explicit device health state with degradation levels.

Variants:

pub enum HealthStatus {
    Healthy,
    Degraded { error_count: u32 },
    Unhealthy { reason: UnhealthyReason },
}

Sub-type:

pub enum UnhealthyReason {
    DeviceUnreachable,
    ConnectionLost,
    TimeoutExceeded,
    ProtocolError { details: String },
}

Pattern Matching: Required for handling different health states Compiler Enforcement: Yes

Methods:

impl HealthStatus {
    pub fn is_healthy(&self) -> bool;
    pub fn can_perform_operations(&self) -> bool;
}

Traits to Implement:

  • Debug, Clone, PartialEq, Eq
  • Display - Human-readable status
  • Serialize, Deserialize - Tagged union for API

ModbusCommand

Purpose: Type-safe representation of Modbus function codes and operations.

Variants:

pub enum ModbusCommand {
    ReadCoils {
        starting_address: ModbusAddress,
        quantity: u16,
    },
    WriteSingleCoil {
        address: ModbusAddress,
        state: RelayState,
    },
    WriteMultipleCoils {
        starting_address: ModbusAddress,
        states: Vec<RelayState>,
    },
}

Pattern Matching: Required Compiler Enforcement: Yes

Methods:

impl ModbusCommand {
    // Get Modbus function code
    pub fn function_code(&self) -> u8;

    // Create command to read all relays
    pub fn read_all_relays() -> Self;

    // Create command to toggle single relay
    pub fn toggle_relay(id: RelayId, state: RelayState) -> Self;

    // Create command for bulk operation
    pub fn bulk_operation(op: BulkOperation) -> Self;
}

Traits to Implement:

  • Debug, Clone, PartialEq, Eq
  • No Serialize/Deserialize - internal representation only

Design Note: Encapsulates Modbus protocol details, preventing direct manipulation of function codes.


Composite Types

Relay

Purpose: Complete representation of a relay with all its properties.

Fields:

pub struct Relay {
    id: RelayId,
    state: RelayState,
    label: Option<RelayLabel>,
}

Invariants:

  • id is always valid (enforced by RelayId type)
  • state is always valid (enforced by RelayState enum)
  • label is either None or a valid RelayLabel

Construction:

impl Relay {
    pub fn new(id: RelayId, state: RelayState) -> Self;
    pub fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self;
}

Methods:

impl Relay {
    pub fn id(&self) -> RelayId;
    pub fn state(&self) -> RelayState;
    pub fn label(&self) -> Option<&RelayLabel>;

    pub fn set_state(&mut self, state: RelayState);
    pub fn set_label(&mut self, label: Option<RelayLabel>);

    pub fn toggle(&mut self);

    // Get display name (label or default)
    pub fn display_name(&self) -> String;
}

Traits to Implement:

  • Debug, Clone, PartialEq, Eq
  • Serialize, Deserialize - For API responses

DeviceHealth

Purpose: Complete health information about Modbus device.

Fields:

pub struct DeviceHealth {
    status: HealthStatus,
    firmware_version: Option<FirmwareVersion>,
    last_contact: Option<DateTime<Utc>>,
    consecutive_errors: u32,
}

Invariants:

  • If status is Healthy, last_contact should be Some
  • If status is Unhealthy, last_contact may be None (never connected) or stale
  • consecutive_errors resets to 0 on successful operation

Construction:

impl DeviceHealth {
    pub fn new() -> Self; // Starts as Unhealthy (not yet connected)
    pub fn healthy(firmware: FirmwareVersion) -> Self;
}

Methods:

impl DeviceHealth {
    pub fn status(&self) -> &HealthStatus;
    pub fn firmware_version(&self) -> Option<&FirmwareVersion>;
    pub fn last_contact(&self) -> Option<DateTime<Utc>>;

    pub fn mark_successful_contact(&mut self, firmware: Option<FirmwareVersion>);
    pub fn mark_error(&mut self, reason: UnhealthyReason);
    pub fn mark_degraded(&mut self);

    pub fn is_operational(&self) -> bool;
}

Traits to Implement:

  • Debug, Clone
  • Serialize, Deserialize - For API health endpoint

RelayCollection

Purpose: Type-safe collection of exactly 8 relays with indexed access.

Fields:

pub struct RelayCollection {
    relays: [Relay; 8],
}

Invariants:

  • MUST contain exactly 8 relays
  • Relays MUST have IDs 1-8 in order
  • Array index corresponds to RelayId - 1

Construction:

impl RelayCollection {
    // Create with all relays OFF and no labels
    pub fn new() -> Self;

    // Create from array (validates IDs are 1-8)
    pub fn from_array(relays: [Relay; 8]) -> Result<Self, RelayCollectionError>;
}

Methods:

impl RelayCollection {
    // Get relay by ID (infallible - ID is validated)
    pub fn get(&self, id: RelayId) -> &Relay;
    pub fn get_mut(&mut self, id: RelayId) -> &mut Relay;

    // Iterate over all relays
    pub fn iter(&self) -> impl Iterator<Item = &Relay>;
    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Relay>;

    // Bulk operations
    pub fn set_all_states(&mut self, state: RelayState);
    pub fn apply_bulk_operation(&mut self, op: BulkOperation);
}

Traits to Implement:

  • Debug, Clone
  • Index<RelayId> - Ergonomic access: collection[relay_id]
  • Serialize, Deserialize - For API responses

Design Note: Fixed-size array prevents runtime length checks and makes the 8-relay constraint compile-time enforced.


Validation Boundaries

API Layer (Parse, Don't Validate)

Entry Points:

  • HTTP request path parameters: /api/relays/{id} → parse to RelayId
  • JSON request bodies: deserialize with validated newtypes
  • Query parameters: validate and convert to domain types

Validation Strategy:

// ✅ API handler (parsing boundary)
async fn toggle_relay(
    Path(id): Path<u8>,  // Raw input
) -> Result<Json<Relay>, ApiError> {
    let relay_id = RelayId::new(id)
        .map_err(|e| ApiError::InvalidRelayId(e))?;  // Validate once

    relay_service.toggle(relay_id).await  // Pass validated type
}

Error Mapping:

impl From<RelayIdError> for ApiError {
    fn from(err: RelayIdError) -> Self {
        match err {
            RelayIdError::OutOfRange { value, min, max } =>
                ApiError::BadRequest(format!(
                    "Relay ID {} out of range (valid: {}-{})",
                    value, min, max
                ))
        }
    }
}

Domain Layer (Trust Types)

Strategy: Accept only validated types, perform no redundant validation.

// ✅ Domain service (trusts types)
impl RelayService {
    pub async fn toggle(&self, id: RelayId) -> Result<Relay, DomainError> {
        // No validation needed - RelayId is already valid
        let current_state = self.get_state(id).await?;
        let new_state = current_state.toggle();
        self.set_state(id, new_state).await?;
        Ok(self.get_relay(id).await?)
    }
}

No Validation Inside Domain:

  • RelayId is guaranteed valid by type system
  • RelayState is guaranteed valid by enum
  • RelayLabel is guaranteed valid by constructor

Infrastructure Layer (Modbus Boundary)

Modbus → Domain (Parse):

// ✅ Parse Modbus response to domain types
impl ModbusClient {
    async fn read_coils(&self) -> Result<Vec<RelayState>, ModbusError> {
        let raw_coils = self.raw_read_coils(0, 8).await?;

        // Convert raw Modbus values to validated domain types
        let states = raw_coils.iter()
            .map(|&coil| RelayState::from_modbus_coil(coil))
            .collect();

        Ok(states)  // Return validated types
    }
}

Domain → Modbus (Convert):

// ✅ Convert validated types to Modbus protocol
impl ModbusClient {
    async fn write_single_coil(
        &self,
        id: RelayId,  // Validated type
        state: RelayState,
    ) -> Result<(), ModbusError> {
        let address = id.to_modbus_address();  // Infallible conversion
        let coil_value = state.to_modbus_coil();  // Infallible conversion

        self.raw_write_coil(address, coil_value).await
    }
}

Persistence Layer (Configuration YAML)

File → Domain (Parse):

// ✅ Load relay labels from YAML
impl ConfigLoader {
    fn load_labels(&self) -> Result<HashMap<RelayId, RelayLabel>, ConfigError> {
        let raw: HashMap<u8, String> = self.read_yaml()?;

        raw.into_iter()
            .map(|(id, label)| {
                let relay_id = RelayId::new(id)?;
                let relay_label = RelayLabel::new(label)?;
                Ok((relay_id, relay_label))
            })
            .collect()
    }
}

Error Types

RelayIdError

Purpose: Validation errors for RelayId construction.

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RelayIdError {
    #[error("Relay ID {value} out of range (valid: {min}-{max})")]
    OutOfRange {
        value: u8,
        min: u8,
        max: u8,
    },
}

Display Messages:

  • OutOfRange: "Relay ID 9 out of range (valid: 1-8)"

Usage:

RelayId::new(9) // Err(RelayIdError::OutOfRange { value: 9, min: 1, max: 8 })

RelayLabelError

Purpose: Validation errors for RelayLabel construction.

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RelayLabelError {
    #[error("Relay label cannot be empty")]
    Empty,

    #[error("Relay label too long (max: {max}, got: {actual})")]
    TooLong {
        max: usize,
        actual: usize,
    },

    #[error("Invalid character '{char}' at position {position} (only alphanumeric, spaces, hyphens, underscores allowed)")]
    InvalidCharacters {
        position: usize,
        char: char,
    },
}

Display Messages:

  • Empty: "Relay label cannot be empty"
  • TooLong: "Relay label too long (max: 50, got: 73)"
  • InvalidCharacters: "Invalid character '!' at position 5 (only alphanumeric, spaces, hyphens, underscores allowed)"

ModbusAddressError

Purpose: Validation errors for ModbusAddress.

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ModbusAddressError {
    #[error("Modbus address {value} exceeds device capacity (max: {max})")]
    OutOfRange {
        value: u16,
        max: u16,
    },
}

FirmwareVersionError

Purpose: Validation errors for FirmwareVersion.

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FirmwareVersionError {
    #[error("Firmware version cannot be empty")]
    Empty,

    #[error("Firmware version too long (max: {max}, got: {actual})")]
    TooLong {
        max: usize,
        actual: usize,
    },
}

RelayCollectionError

Purpose: Errors when constructing RelayCollection.

#[derive(Debug, thiserror::Error)]
pub enum RelayCollectionError {
    #[error("Relay IDs must be 1-8 in order, found ID {found} at index {index}")]
    InvalidIdOrdering {
        index: usize,
        found: u8,
    },
}

Implementation Notes

Rust-Specific Patterns

Zero-Cost Abstractions:

#[repr(transparent)]  // Guarantee no runtime overhead
pub struct RelayId(u8);

Infallible Conversions (when source is already validated):

impl RelayId {
    // Infallible because RelayId is guaranteed valid (1-8)
    pub fn to_modbus_address(&self) -> u16 {
        (self.0 - 1) as u16  // Always produces 0-7
    }
}

impl ModbusAddress {
    // Infallible because ModbusAddress is guaranteed valid (0-7)
    pub fn to_relay_id(&self) -> RelayId {
        RelayId(self.0 as u8 + 1)  // Always produces 1-8
    }
}

Serde Integration:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
#[serde(try_from = "u8", into = "u8")]  // Parse from JSON number
pub struct RelayId(u8);

impl TryFrom<u8> for RelayId {
    type Error = RelayIdError;
    fn try_from(value: u8) -> Result<Self, Self::Error> {
        RelayId::new(value)
    }
}

impl From<RelayId> for u8 {
    fn from(id: RelayId) -> u8 {
        id.0
    }
}

Array Indexing:

impl std::ops::Index<RelayId> for RelayCollection {
    type Output = Relay;

    fn index(&self, id: RelayId) -> &Self::Output {
        // Safe: RelayId is 1-8, array index is 0-7
        &self.relays[(id.0 - 1) as usize]
    }
}

Performance Considerations

Copy Types (cheap to copy, no allocation):

  • RelayId (u8)
  • RelayState (enum)
  • BulkOperation (enum)
  • ModbusAddress (u16)

Clone Types (heap allocation, clone when needed):

  • RelayLabel (String)
  • FirmwareVersion (String)
  • Relay (contains Option)
  • DeviceHealth (contains String in enum)

Fixed-Size Types (stack allocation, no heap):

  • RelayCollection ([Relay; 8] - stack allocated)

Testing Strategy

Type Construction Tests:

#[cfg(test)]
mod tests {
    #[test]
    fn relay_id_valid_range() {
        assert!(RelayId::new(1).is_ok());
        assert!(RelayId::new(8).is_ok());
    }

    #[test]
    fn relay_id_rejects_zero() {
        assert!(matches!(
            RelayId::new(0),
            Err(RelayIdError::OutOfRange { value: 0, .. })
        ));
    }

    #[test]
    fn relay_id_rejects_out_of_range() {
        assert!(matches!(
            RelayId::new(9),
            Err(RelayIdError::OutOfRange { value: 9, .. })
        ));
    }
}

Conversion Tests:

#[test]
fn relay_id_modbus_address_roundtrip() {
    let id = RelayId::new(5).unwrap();
    let address = id.to_modbus_address();
    assert_eq!(address, 4);  // 5 - 1 = 4

    let modbus_addr = ModbusAddress::new(address).unwrap();
    let back_to_id = modbus_addr.to_relay_id();
    assert_eq!(id, back_to_id);
}

Integration with Existing Code

Settings (Configuration)

Configuration Structure:

#[derive(Deserialize)]
pub struct ModbusSettings {
    pub host: String,
    pub port: u16,
    pub device_address: u8,  // Parsed to ModbusAddress at startup
    pub timeout_secs: u64,
}

#[derive(Deserialize)]
pub struct RelaySettings {
    pub labels: HashMap<u8, String>,  // Parsed to HashMap<RelayId, RelayLabel>
}

Startup Validation:

impl Settings {
    pub fn validated_modbus_address(&self) -> Result<ModbusAddress, ConfigError> {
        ModbusAddress::new(self.modbus.device_address as u16)
            .map_err(ConfigError::InvalidModbusAddress)
    }

    pub fn validated_relay_labels(&self) -> Result<HashMap<RelayId, RelayLabel>, ConfigError> {
        self.relays.labels.iter()
            .map(|(id, label)| {
                let relay_id = RelayId::new(*id)?;
                let relay_label = RelayLabel::new(label.clone())?;
                Ok((relay_id, relay_label))
            })
            .collect()
    }
}

API Routes (Poem Integration)

Path Parameter Parsing:

use poem::web::Path;
use poem_openapi::{param::Path as OpenApiPath, payload::Json, OpenApi};

#[derive(Debug, Deserialize)]
struct RelayIdParam(u8);

#[OpenApi]
impl RelayApi {
    #[oai(path = "/relays/:id/toggle", method = "post")]
    async fn toggle_relay(
        &self,
        #[oai(name = "id")] id: OpenApiPath<u8>,
    ) -> Result<Json<Relay>, ApiError> {
        let relay_id = RelayId::new(id.0)
            .map_err(ApiError::from)?;

        let relay = self.service.toggle(relay_id).await?;
        Ok(Json(relay))
    }
}

Request/Response DTOs:

#[derive(Serialize, Deserialize, Object)]
pub struct RelayResponse {
    id: u8,  // Serialized from RelayId
    state: String,  // "on" or "off" from RelayState
    label: Option<String>,  // From RelayLabel
}

impl From<Relay> for RelayResponse {
    fn from(relay: Relay) -> Self {
        Self {
            id: relay.id().as_u8(),
            state: relay.state().to_string().to_lowercase(),
            label: relay.label().map(|l| l.as_str().to_string()),
        }
    }
}

Next Steps

  1. Review this design: Validate type hierarchy meets all requirements
  2. Run /tydd:implement-types: Generate Rust implementations
  3. Run /tdd:write-tests: Create comprehensive test suite
  4. Integration: Update architecture plan with concrete type definitions

Type Summary

Domain Primitives (7):

  • RelayId - Validated relay identifier (1-8)
  • RelayLabel - Validated custom label (1-50 chars, alphanumeric)
  • ModbusAddress - Validated Modbus address (0-7)
  • FirmwareVersion - Validated version string

Sum Types (4):

  • RelayState - On | Off
  • BulkOperation - AllOn | AllOff
  • HealthStatus - Healthy | Degraded | Unhealthy
  • ModbusCommand - ReadCoils | WriteSingleCoil | WriteMultipleCoils

Composite Types (3):

  • Relay - Complete relay representation
  • DeviceHealth - Device health information
  • RelayCollection - Type-safe collection of 8 relays

Error Types (5):

  • RelayIdError
  • RelayLabelError
  • ModbusAddressError
  • FirmwareVersionError
  • RelayCollectionError

Total: 19 carefully designed types ensuring compile-time correctness.