1036 lines
24 KiB
Markdown
1036 lines
24 KiB
Markdown
|
|
# 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.
|