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.
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
- Make illegal states unrepresentable: Invalid relay IDs (0, 9+) cannot be constructed
- Validate at boundaries, trust internally: Parse once at API/Modbus boundaries, trust types everywhere else
- Zero-cost abstractions: Use
#[repr(transparent)]for single-field newtypes - Clear error messages: Validation errors provide actionable context
- 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
thiserrorfor error typesserdefor 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 whitespaceRelayLabelError::TooLong { max: 50, actual }: Exceeds 50 charactersRelayLabelError::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-isSerialize, Deserialize- Serialize as stringAsRef<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 emptyFirmwareVersionError::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 stringSerialize, 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, HashDisplay- "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, EqDisplay- "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, EqDisplay- Human-readable statusSerialize, 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:
idis always valid (enforced by RelayId type)stateis always valid (enforced by RelayState enum)labelis 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, EqSerialize, 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
statusisHealthy,last_contactshould beSome - If
statusisUnhealthy,last_contactmay beNone(never connected) or stale consecutive_errorsresets 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, CloneSerialize, 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, CloneIndex<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 toRelayId - 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
- Review this design: Validate type hierarchy meets all requirements
- Run
/tydd:implement-types: Generate Rust implementations - Run
/tdd:write-tests: Create comprehensive test suite - 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 | OffBulkOperation- AllOn | AllOffHealthStatus- Healthy | Degraded | UnhealthyModbusCommand- ReadCoils | WriteSingleCoil | WriteMultipleCoils
Composite Types (3):
Relay- Complete relay representationDeviceHealth- Device health informationRelayCollection- Type-safe collection of 8 relays
Error Types (5):
RelayIdErrorRelayLabelErrorModbusAddressErrorFirmwareVersionErrorRelayCollectionError
Total: 19 carefully designed types ensuring compile-time correctness.