Files

1036 lines
24 KiB
Markdown
Raw Permalink Normal View History

# Type Design: Modbus Relay Control System
**Created**: 2025-12-28
**Feature**: [spec.md](./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**:
```rust
impl RelayId {
pub fn new(value: u8) -> Result<Self, RelayIdError>;
}
```
**Error Cases**:
- `RelayIdError::OutOfRange { value, min: 1, max: 8 }`: Value outside valid range
**Methods**:
```rust
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**:
```rust
#[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**:
```rust
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**:
```rust
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**:
```rust
#[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**:
```rust
impl ModbusAddress {
pub fn new(value: u16) -> Result<Self, ModbusAddressError>;
}
```
**Error Cases**:
- `ModbusAddressError::OutOfRange { value, max: 7 }`: Address exceeds device capacity
**Methods**:
```rust
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**:
```rust
#[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**:
```rust
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**:
```rust
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**:
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct FirmwareVersion(String);
```
---
## Sum Types / Enums
### RelayState
**Purpose**: Explicit representation of relay physical state.
**Variants**:
```rust
pub enum RelayState {
On,
Off,
}
```
**Pattern Matching**: Required for exhaustiveness
**Compiler Enforcement**: Yes
**Methods**:
```rust
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**:
```rust
pub enum BulkOperation {
AllOn,
AllOff,
}
```
**Pattern Matching**: Required
**Compiler Enforcement**: Yes
**Methods**:
```rust
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**:
```rust
pub enum HealthStatus {
Healthy,
Degraded { error_count: u32 },
Unhealthy { reason: UnhealthyReason },
}
```
**Sub-type**:
```rust
pub enum UnhealthyReason {
DeviceUnreachable,
ConnectionLost,
TimeoutExceeded,
ProtocolError { details: String },
}
```
**Pattern Matching**: Required for handling different health states
**Compiler Enforcement**: Yes
**Methods**:
```rust
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**:
```rust
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**:
```rust
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**:
```rust
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**:
```rust
impl Relay {
pub fn new(id: RelayId, state: RelayState) -> Self;
pub fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self;
}
```
**Methods**:
```rust
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**:
```rust
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**:
```rust
impl DeviceHealth {
pub fn new() -> Self; // Starts as Unhealthy (not yet connected)
pub fn healthy(firmware: FirmwareVersion) -> Self;
}
```
**Methods**:
```rust
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**:
```rust
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**:
```rust
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**:
```rust
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**:
```rust
// ✅ 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**:
```rust
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.
```rust
// ✅ 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)**:
```rust
// ✅ 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)**:
```rust
// ✅ 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)**:
```rust
// ✅ 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.
```rust
#[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**:
```rust
RelayId::new(9) // Err(RelayIdError::OutOfRange { value: 9, min: 1, max: 8 })
```
---
### RelayLabelError
**Purpose**: Validation errors for RelayLabel construction.
```rust
#[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.
```rust
#[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.
```rust
#[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.
```rust
#[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**:
```rust
#[repr(transparent)] // Guarantee no runtime overhead
pub struct RelayId(u8);
```
**Infallible Conversions** (when source is already validated):
```rust
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**:
```rust
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**:
```rust
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<String>)
- `DeviceHealth` (contains String in enum)
**Fixed-Size Types** (stack allocation, no heap):
- `RelayCollection` ([Relay; 8] - stack allocated)
---
### Testing Strategy
**Type Construction Tests**:
```rust
#[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**:
```rust
#[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**:
```rust
#[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**:
```rust
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**:
```rust
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**:
```rust
#[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.