refactor(modbus): switch to native Modbus TCP protocol
Switch from Modbus RTU over TCP to native Modbus TCP based on hardware testing. Uses standard MBAP header (no CRC16), port 502, and TCP-only tokio-modbus feature for simpler implementation. Updated: Cargo.toml, plan.md, research.md, tasks.md
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2870,7 +2870,6 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"smallvec",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ serde_json = "1.0.148"
|
|||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "derive", "migrate"] }
|
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "derive", "migrate"] }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
|
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 = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**Technical Approach**:
|
**Technical Approach**:
|
||||||
- **Architecture**: Pragmatic Balance (Service Layer Pattern) - Hexagonal architecture with domain/application/infrastructure/presentation layers
|
- **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
|
- **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
|
- **Reverse Proxy**: Traefik on Raspberry Pi with Authelia middleware for authentication and HTTPS termination
|
||||||
- **Persistence**: SQLite for relay labels
|
- **Persistence**: SQLite for relay labels
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
**Language/Version**: Rust 1.75+
|
**Language/Version**: Rust 1.75+
|
||||||
**Primary Dependencies**:
|
**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)
|
- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI)
|
||||||
- Tokio 1.48 (async runtime)
|
- Tokio 1.48 (async runtime)
|
||||||
- sqlx 0.8 (SQLite persistence with compile-time verification)
|
- sqlx 0.8 (SQLite persistence with compile-time verification)
|
||||||
@@ -184,7 +184,7 @@ sta/ (repository root)
|
|||||||
**Action**: Add the following dependencies:
|
**Action**: Add the following dependencies:
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[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"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||||
mockall = "0.13"
|
mockall = "0.13"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
@@ -904,7 +904,7 @@ impl RelayLabelRepository for SqliteRelayLabelRepository {
|
|||||||
|
|
||||||
### Phase 4: Infrastructure - Real Modbus Client (1.5 days)
|
### 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
|
**Prerequisites**: Phase 1 complete (controller trait), hardware available for testing
|
||||||
|
|
||||||
@@ -2014,7 +2014,7 @@ tests/
|
|||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Existing 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"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||||
mockall = "0.13"
|
mockall = "0.13"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|||||||
@@ -67,10 +67,11 @@
|
|||||||
|
|
||||||
**Version**: tokio-modbus 0.17.0 (latest stable, released 2025-10-22)
|
**Version**: tokio-modbus 0.17.0 (latest stable, released 2025-10-22)
|
||||||
|
|
||||||
**Protocol**: Modbus RTU over TCP (NOT Modbus TCP)
|
**Protocol**: Modbus TCP (native TCP protocol)
|
||||||
- Hardware uses RTU protocol tunneled over TCP
|
- Hardware configured to use native Modbus TCP protocol
|
||||||
- Includes CRC16 validation
|
- Uses MBAP (Modbus Application Protocol) header
|
||||||
- Different from native Modbus TCP (no CRC, different framing)
|
- No CRC16 validation (TCP/IP handles error detection)
|
||||||
|
- Standard Modbus TCP protocol on port 502
|
||||||
|
|
||||||
**Connection Strategy**:
|
**Connection Strategy**:
|
||||||
- Shared `Arc<Mutex<Context>>` for simplicity
|
- Shared `Arc<Mutex<Context>>` for simplicity
|
||||||
@@ -92,11 +93,13 @@
|
|||||||
|
|
||||||
### Critical Gotchas
|
### 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)
|
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_modbus::prelude::*;
|
||||||
use tokio::time::{timeout, Duration};
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
// Connect to device
|
// Connect to device using Modbus TCP on standard port 502
|
||||||
let socket_addr = "192.168.1.200:8234".parse()?;
|
let socket_addr = "192.168.1.200:502".parse()?;
|
||||||
let mut ctx = tcp::connect(socket_addr).await?;
|
let mut ctx = tcp::connect(socket_addr).await?;
|
||||||
|
|
||||||
// Set slave ID (unit identifier)
|
// Set slave ID (unit identifier)
|
||||||
@@ -125,6 +128,8 @@ let states = timeout(
|
|||||||
).await???; // Triple-? handles timeout + transport + exception errors
|
).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**:
|
**Toggle Relay with Retry**:
|
||||||
```rust
|
```rust
|
||||||
async fn toggle_relay(
|
async fn toggle_relay(
|
||||||
|
|||||||
@@ -13,25 +13,25 @@
|
|||||||
**Purpose**: Initialize project dependencies and directory structure
|
**Purpose**: Initialize project dependencies and directory structure
|
||||||
|
|
||||||
- [x] **T001** [Setup] [TDD] Add Rust dependencies to Cargo.toml
|
- [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
|
- **Test**: cargo check passes
|
||||||
- **Complexity**: Low | **Uncertainty**: Low
|
- **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/
|
- Create: src/domain/, src/application/, src/infrastructure/, src/presentation/
|
||||||
- **Test**: Module declarations compile without errors
|
- **Test**: Module declarations compile without errors
|
||||||
- **Complexity**: Low | **Uncertainty**: Low
|
- **Complexity**: Low | **Uncertainty**: Low
|
||||||
|
|
||||||
- [ ] **T003** [P] [Setup] [TDD] Update settings.rs with Modbus configuration
|
- [ ] **T003** [P] [Setup] [TDD] Update settings.rs with Modbus configuration
|
||||||
- Add ModbusSettings struct with host, port, slave_id, timeout_secs fields
|
- Add ModbusSettings struct with `host`, `port`, `slave_id`, `timeout_secs` fields
|
||||||
- Add RelaySettings struct with label_max_length field
|
- Add RelaySettings struct with `label_max_length` field
|
||||||
- Update Settings struct to include modbus and relay fields
|
- Update Settings struct to include modbus and relay fields
|
||||||
- **Test**: Settings loads from settings/base.yaml with test Modbus config
|
- **Test**: Settings loads from settings/base.yaml with test Modbus config
|
||||||
- **Complexity**: Low | **Uncertainty**: Low
|
- **Complexity**: Low | **Uncertainty**: Low
|
||||||
|
|
||||||
- [ ] **T004** [P] [Setup] [TDD] Create settings/base.yaml with Modbus defaults
|
- [ ] **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 modbus section: host: "192.168.0.200", port: 502, slave_id: 0, timeout_secs: 5
|
||||||
- Add relay section: label_max_length: 50
|
- Add relay section: label_max_length: 8
|
||||||
- **Test**: Settings::new() loads config without errors
|
- **Test**: Settings::new() loads config without errors
|
||||||
- **Complexity**: Low | **Uncertainty**: Low
|
- **Complexity**: Low | **Uncertainty**: Low
|
||||||
|
|
||||||
@@ -184,9 +184,9 @@
|
|||||||
|
|
||||||
- [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController
|
- [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController
|
||||||
- **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities
|
- **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: 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
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
||||||
- **Complexity**: High → DECOMPOSED below
|
- **Complexity**: High → DECOMPOSED below
|
||||||
- **Uncertainty**: High
|
- **Uncertainty**: High
|
||||||
@@ -198,6 +198,7 @@
|
|||||||
**Complexity**: High → Broken into 6 sub-tasks
|
**Complexity**: High → Broken into 6 sub-tasks
|
||||||
**Uncertainty**: High
|
**Uncertainty**: High
|
||||||
**Rationale**: Nested Result handling, Arc<Mutex> synchronization, timeout wrapping
|
**Rationale**: Nested Result handling, Arc<Mutex> synchronization, timeout wrapping
|
||||||
|
**Protocol**: Native Modbus TCP (MBAP header, no CRC16 validation)
|
||||||
|
|
||||||
- [ ] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup
|
- [ ] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup
|
||||||
- Struct: ModbusRelayController { ctx: Arc<Mutex<Context>>, timeout_duration: Duration }
|
- Struct: ModbusRelayController { ctx: Arc<Mutex<Context>>, timeout_duration: Duration }
|
||||||
@@ -219,6 +220,7 @@
|
|||||||
{
|
{
|
||||||
use tokio_modbus::prelude::*;
|
use tokio_modbus::prelude::*;
|
||||||
|
|
||||||
|
// Connect using native Modbus TCP protocol (port 502)
|
||||||
let socket_addr = format!("{}:{}", host, port)
|
let socket_addr = format!("{}:{}", host, port)
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?;
|
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?;
|
||||||
@@ -244,6 +246,7 @@
|
|||||||
- Private method: read_coils_with_timeout(addr: u16, count: u16) → Result<Vec<bool>, ControllerError>
|
- Private method: read_coils_with_timeout(addr: u16, count: u16) → Result<Vec<bool>, ControllerError>
|
||||||
- Wrap ctx.read_coils() with tokio::time::timeout()
|
- Wrap ctx.read_coils() with tokio::time::timeout()
|
||||||
- Handle nested Result: timeout → io::Error → Modbus Exception
|
- 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
|
- **File**: src/infrastructure/modbus/modbus_controller.rs
|
||||||
- **Complexity**: Medium | **Uncertainty**: Medium
|
- **Complexity**: Medium | **Uncertainty**: Medium
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user