# 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; } ``` **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; } ``` **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; } ``` **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` - 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; } ``` **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; } ``` **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, }, } ``` **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, } ``` **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); 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, last_contact: Option>, 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>; pub fn mark_successful_contact(&mut self, firmware: Option); 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; } ``` **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; pub fn iter_mut(&mut self) -> impl Iterator; // 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` - 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, // Raw input ) -> Result, 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 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 { // 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, 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, ConfigError> { let raw: HashMap = 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 for RelayId { type Error = RelayIdError; fn try_from(value: u8) -> Result { RelayId::new(value) } } impl From for u8 { fn from(id: RelayId) -> u8 { id.0 } } ``` **Array Indexing**: ```rust impl std::ops::Index 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**: ```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, // Parsed to HashMap } ``` **Startup Validation**: ```rust impl Settings { pub fn validated_modbus_address(&self) -> Result { ModbusAddress::new(self.modbus.device_address as u16) .map_err(ConfigError::InvalidModbusAddress) } pub fn validated_relay_labels(&self) -> Result, 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, ) -> Result, 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, // From RelayLabel } impl From 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.