diff --git a/Cargo.lock b/Cargo.lock index bb679f8..1aa919b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2870,7 +2870,6 @@ dependencies = [ "futures-core", "futures-util", "log", - "smallvec", "thiserror", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 4ecc462..871d2f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ serde_json = "1.0.148" sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "derive", "migrate"] } thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } -tokio-modbus = "0.17.0" +tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] } diff --git a/specs/001-modbus-relay-control/plan.md b/specs/001-modbus-relay-control/plan.md index 164065c..dbe6f93 100644 --- a/specs/001-modbus-relay-control/plan.md +++ b/specs/001-modbus-relay-control/plan.md @@ -8,7 +8,7 @@ **Technical Approach**: - **Architecture**: Pragmatic Balance (Service Layer Pattern) - Hexagonal architecture with domain/application/infrastructure/presentation layers -- **Backend**: Rust with tokio-modbus 0.17.0 for Modbus RTU over TCP, Poem 3.1 for HTTP API with OpenAPI +- **Backend**: Rust with tokio-modbus 0.17.0 for Modbus TCP, Poem 3.1 for HTTP API with OpenAPI - **Frontend**: Vue 3 + TypeScript with HTTP polling (2-second intervals), deployed to Cloudflare Pages - **Reverse Proxy**: Traefik on Raspberry Pi with Authelia middleware for authentication and HTTPS termination - **Persistence**: SQLite for relay labels @@ -19,7 +19,7 @@ **Language/Version**: Rust 1.75+ **Primary Dependencies**: -- tokio-modbus 0.17.0 (Modbus RTU over TCP) +- tokio-modbus 0.17.0 with TCP feature only (Modbus TCP protocol) - Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI) - Tokio 1.48 (async runtime) - sqlx 0.8 (SQLite persistence with compile-time verification) @@ -184,7 +184,7 @@ sta/ (repository root) **Action**: Add the following dependencies: ```toml [dependencies] -tokio-modbus = { version = "0.17.0", features = ["rtu", "tcp"] } +tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } mockall = "0.13" async-trait = "0.1" @@ -904,7 +904,7 @@ impl RelayLabelRepository for SqliteRelayLabelRepository { ### Phase 4: Infrastructure - Real Modbus Client (1.5 days) -**Objective**: Implement real Modbus RTU over TCP communication using tokio-modbus. +**Objective**: Implement real Modbus TCP communication using tokio-modbus. **Prerequisites**: Phase 1 complete (controller trait), hardware available for testing @@ -2014,7 +2014,7 @@ tests/ ```toml [dependencies] # Existing dependencies... -tokio-modbus = { version = "0.17.0", features = ["rtu", "tcp"] } +tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } mockall = "0.13" async-trait = "0.1" diff --git a/specs/001-modbus-relay-control/research.md b/specs/001-modbus-relay-control/research.md index 7973e97..47ade9b 100644 --- a/specs/001-modbus-relay-control/research.md +++ b/specs/001-modbus-relay-control/research.md @@ -67,10 +67,11 @@ **Version**: tokio-modbus 0.17.0 (latest stable, released 2025-10-22) -**Protocol**: Modbus RTU over TCP (NOT Modbus TCP) -- Hardware uses RTU protocol tunneled over TCP -- Includes CRC16 validation -- Different from native Modbus TCP (no CRC, different framing) +**Protocol**: Modbus TCP (native TCP protocol) +- Hardware configured to use native Modbus TCP protocol +- Uses MBAP (Modbus Application Protocol) header +- No CRC16 validation (TCP/IP handles error detection) +- Standard Modbus TCP protocol on port 502 **Connection Strategy**: - Shared `Arc>` for simplicity @@ -92,11 +93,13 @@ ### Critical Gotchas -1. **Device Gateway Configuration**: Hardware MUST be set to "Multi-host non-storage type" - default storage type sends spurious queries causing failures +1. **Device Protocol Configuration**: Hardware MUST be configured to use Modbus TCP protocol (not RTU over TCP) via VirCom software + - Set "Transfer Protocol" to "Modbus TCP protocol" in Advanced Settings + - Device automatically switches to port 502 when TCP protocol is selected -2. **No Built-in Timeouts**: tokio-modbus has NO automatic timeouts - must wrap every operation with `tokio::time::timeout` +2. **Device Gateway Configuration**: Hardware MUST be set to "Multi-host non-storage type" - default storage type sends spurious queries causing failures -3. **RTU vs TCP Confusion**: Device uses Modbus RTU protocol over TCP (with CRC), not native Modbus TCP protocol +3. **No Built-in Timeouts**: tokio-modbus has NO automatic timeouts - must wrap every operation with `tokio::time::timeout` 4. **Address Indexing**: Relays labeled 1-8, but Modbus addresses are 0-7 (use newtype pattern with conversion methods) @@ -111,8 +114,8 @@ use tokio_modbus::prelude::*; use tokio::time::{timeout, Duration}; -// Connect to device -let socket_addr = "192.168.1.200:8234".parse()?; +// Connect to device using Modbus TCP on standard port 502 +let socket_addr = "192.168.1.200:502".parse()?; let mut ctx = tcp::connect(socket_addr).await?; // Set slave ID (unit identifier) @@ -125,6 +128,8 @@ let states = timeout( ).await???; // Triple-? handles timeout + transport + exception errors ``` +**Note**: Modbus TCP uses the standard MBAP header and does not require CRC16 validation. The protocol is cleaner and more standardized than RTU over TCP. + **Toggle Relay with Retry**: ```rust async fn toggle_relay( diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index f946ba7..346076b 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -13,25 +13,25 @@ **Purpose**: Initialize project dependencies and directory structure - [x] **T001** [Setup] [TDD] Add Rust dependencies to Cargo.toml - - Add: tokio-modbus = "0.17.0", sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }, mockall = "0.13", async-trait = "0.1" + - Add: tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] }, sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }, mockall = "0.13", async-trait = "0.1" - **Test**: cargo check passes - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T002** [P] [Setup] [TDD] Create module structure in src/ +- [x] **T002** [P] [Setup] [TDD] Create module structure in src/ - Create: src/domain/, src/application/, src/infrastructure/, src/presentation/ - **Test**: Module declarations compile without errors - **Complexity**: Low | **Uncertainty**: Low - [ ] **T003** [P] [Setup] [TDD] Update settings.rs with Modbus configuration - - Add ModbusSettings struct with host, port, slave_id, timeout_secs fields - - Add RelaySettings struct with label_max_length field + - Add ModbusSettings struct with `host`, `port`, `slave_id`, `timeout_secs` fields + - Add RelaySettings struct with `label_max_length` field - Update Settings struct to include modbus and relay fields - **Test**: Settings loads from settings/base.yaml with test Modbus config - **Complexity**: Low | **Uncertainty**: Low - [ ] **T004** [P] [Setup] [TDD] Create settings/base.yaml with Modbus defaults - - Add modbus section: host: "192.168.1.100", port: 502, slave_id: 1, timeout_secs: 3 - - Add relay section: label_max_length: 50 + - Add modbus section: host: "192.168.0.200", port: 502, slave_id: 0, timeout_secs: 5 + - Add relay section: label_max_length: 8 - **Test**: Settings::new() loads config without errors - **Complexity**: Low | **Uncertainty**: Low @@ -184,9 +184,9 @@ - [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController - **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities - - Test: Connection succeeds with valid config + - Test: Connection succeeds with valid config (Modbus TCP on port 502) - Test: read_state() returns correct coil value - - Test: write_state() sends correct Modbus command + - Test: write_state() sends correct Modbus TCP command (no CRC needed) - **File**: src/infrastructure/modbus/modbus_controller.rs - **Complexity**: High → DECOMPOSED below - **Uncertainty**: High @@ -198,6 +198,7 @@ **Complexity**: High → Broken into 6 sub-tasks **Uncertainty**: High **Rationale**: Nested Result handling, Arc synchronization, timeout wrapping +**Protocol**: Native Modbus TCP (MBAP header, no CRC16 validation) - [ ] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup - Struct: ModbusRelayController { ctx: Arc>, timeout_duration: Duration } @@ -219,6 +220,7 @@ { use tokio_modbus::prelude::*; + // Connect using native Modbus TCP protocol (port 502) let socket_addr = format!("{}:{}", host, port) .parse() .map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?; @@ -244,6 +246,7 @@ - Private method: read_coils_with_timeout(addr: u16, count: u16) → Result, ControllerError> - Wrap ctx.read_coils() with tokio::time::timeout() - Handle nested Result: timeout → io::Error → Modbus Exception + - **Note**: Modbus TCP uses MBAP header (no CRC validation needed) - **File**: src/infrastructure/modbus/modbus_controller.rs - **Complexity**: Medium | **Uncertainty**: Medium