Fixing reconnect issues after outage
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
# Modbus RTU Monitor Integration - Development Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This integration provides **passive monitoring** of Modbus RTU communication over TCP connections. It listens to existing Modbus traffic between a gateway and slave devices (e.g., thermostats) and creates Home Assistant entities based on discovered data.
|
||||
|
||||
**Key Feature**: Non-intrusive monitoring - the integration does not initiate Modbus requests, it only observes traffic. The exception is the climate entity's setpoint write functionality.
|
||||
|
||||
## Use Case & Background
|
||||
|
||||
This integration was developed to enable remote control and monitoring of proprietary thermostats that are integrated into a complex PLC-based heat automation system via Modbus RTU.
|
||||
|
||||
### Real-World Scenario
|
||||
|
||||
**Current Setup**:
|
||||
- Multiple proprietary thermostats connected via 2 Modbus RTU lines
|
||||
- Complex PLC (Programmable Logic Controller) manages heat automation
|
||||
- PLC writes configuration registers to thermostats (time, mode, pump status)
|
||||
- PLC reads sensor data from thermostats (temperature, humidity, setpoint)
|
||||
|
||||
**Challenges**:
|
||||
- Proprietary thermostats with no native Home Assistant support
|
||||
- Cannot interrupt or interfere with critical PLC communication
|
||||
- Need to read temperature/humidity and set setpoint remotely
|
||||
- Existing automation must continue functioning during transition
|
||||
|
||||
**Solution Approach**:
|
||||
- **Passive monitoring**: Observe PLC ↔ thermostat communication without disruption
|
||||
- **Selective writes**: Only write setpoint when user changes it in HA
|
||||
- **Gradual migration path**: Enables Home Assistant integration while PLC continues operating
|
||||
- **Future goal**: Eventually replace PLC entirely with Home Assistant automations
|
||||
|
||||
### Why Passive Monitoring Matters
|
||||
|
||||
The PLC continuously communicates with thermostats to:
|
||||
- Synchronize time (hour/minute registers)
|
||||
- Set operating modes (summer/winter mode)
|
||||
- Control heat pump status (zone pump register 211)
|
||||
- Read temperature, humidity, and manual mode setpoint
|
||||
|
||||
**Critical**: Any polling or active queries from Home Assistant could:
|
||||
- Disrupt time-sensitive PLC communication
|
||||
- Cause Modbus bus contention
|
||||
- Potentially confuse the PLC or thermostats
|
||||
- Create unpredictable heating behavior
|
||||
|
||||
**Passive monitoring solves this** by only observing existing traffic, making the integration completely invisible to the PLC and thermostats (except for explicit setpoint writes initiated by users).
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Phase 1** (Current): Passive monitoring + manual setpoint control
|
||||
- Read all sensor data (temp, humidity, registers)
|
||||
- Allow manual temperature adjustment from HA
|
||||
- PLC continues all automation logic
|
||||
|
||||
2. **Phase 2** (Future): Gradual automation takeover
|
||||
- Start replacing PLC automations with HA automations
|
||||
- Monitor additional registers to understand PLC logic
|
||||
- Test HA automations alongside PLC
|
||||
|
||||
3. **Phase 3** (Goal): Full HA control
|
||||
- HA handles all heating automation
|
||||
- PLC decommissioned or relegated to backup role
|
||||
- Complete integration with HA ecosystem
|
||||
|
||||
This integration is the foundation for this migration path.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **hub.py** - Central communication hub
|
||||
- Manages TCP connection to Modbus gateway
|
||||
- Passively decodes Modbus RTU frames
|
||||
- Handles reconnection logic for extended outages
|
||||
- Provides active write capability for setpoint changes
|
||||
|
||||
2. **coordinator.py** - Data update coordinator
|
||||
- Manages entity updates efficiently
|
||||
- Uses throttled updates (5-second minimum interval)
|
||||
- Coordinates between hub and entity platforms
|
||||
|
||||
3. **Entity Platforms**:
|
||||
- **climate.py** - Thermostat control with HVAC action display
|
||||
- **sensor.py** - Humidity and register monitoring
|
||||
- **binary_sensor.py** - Coil states and zone pump status
|
||||
|
||||
4. **const.py** - Constants and data models
|
||||
- Register addresses and ranges
|
||||
- SlaveData and ModbusRTUFrame dataclasses
|
||||
- User-friendly register name mappings
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### Reconnection Logic (CRITICAL)
|
||||
|
||||
The integration must handle extended server outages (1-4+ hours) gracefully:
|
||||
|
||||
**Key Requirements**:
|
||||
1. **Monitor task is always created** - even if initial connection fails (hub.py lines 198-220)
|
||||
2. Always null `_reader` and `_writer` after failed reconnection attempts
|
||||
3. Check `if self._reader is None:` before attempting to read
|
||||
4. Exception handler must detect connection errors and trigger reconnection
|
||||
5. Buffer must be cleared after reconnection attempts
|
||||
6. Reconnection delay is 30 seconds (prevents DDOS during outages)
|
||||
|
||||
**Files**: hub.py lines 198-333
|
||||
|
||||
**Why This Matters**:
|
||||
- Without the monitor task being always created, integrations loaded while server is down never attempt reconnection
|
||||
- Without proper state management, the integration enters an infinite error loop after the first failed reconnection
|
||||
- Both scenarios require manual reload without these fixes
|
||||
|
||||
**Critical Design Decision**:
|
||||
- The monitor task handles ALL connection logic (hub.py:254-285)
|
||||
- `async_setup()` only creates tasks, never attempts connection directly (hub.py:198-212)
|
||||
- This prevents race conditions between setup and reconnection logic
|
||||
- First connection attempt is immediate (no delay)
|
||||
- Subsequent reconnection attempts use 30-second delay to prevent DDOS
|
||||
|
||||
### Passive Monitoring Pattern
|
||||
|
||||
The integration monitors three types of Modbus traffic:
|
||||
|
||||
1. **Temperature/Humidity** (Register 0x83, 4 registers)
|
||||
- Detected when request matches TEMP_HUMIDITY_REGISTER
|
||||
- Response contains temperature (index 2) and humidity (index 3)
|
||||
- Values divided by 10 for actual reading
|
||||
|
||||
2. **Coils** (Function code 0x01, 40 coils starting at address 1)
|
||||
- Binary states (ON/OFF)
|
||||
- Disabled by default (user can enable)
|
||||
|
||||
3. **Register Ranges** (Function code 0x03):
|
||||
- Range 1: 165-184 (20 registers) - REGISTER_START_ADDRESS
|
||||
- Range 2: 210-217 (8 registers) - REGISTER_START_ADDRESS_2
|
||||
- Range 3: 140-162 (23 registers) - REGISTER_START_ADDRESS_3
|
||||
|
||||
**Pattern**: Hub detects request frames, stores pending request data, then matches response frames to extract values.
|
||||
|
||||
### Register Name Mapping
|
||||
|
||||
User-friendly names are maintained in `const.py` REGISTER_NAMES dictionary:
|
||||
|
||||
```python
|
||||
REGISTER_NAMES: dict[int, str] = {
|
||||
144: "Setpoint", # Target temperature (written by PLC or HA)
|
||||
145: "Max Setpoint", # Maximum allowed temperature
|
||||
146: "Min Setpoint", # Minimum allowed temperature
|
||||
154: "Hour", # Current hour (synchronized by PLC)
|
||||
155: "Minute", # Current minute (synchronized by PLC)
|
||||
156: "Day of week", # Current day (synchronized by PLC)
|
||||
157: "Current temperature", # Actual temperature reading
|
||||
179: "Outside temperature", # External temperature sensor
|
||||
211: "Zone pump", # Heat pump/zone valve status (1=ON, 0=OFF)
|
||||
}
|
||||
```
|
||||
|
||||
**PLC-Written Registers**: The PLC writes registers 154-156 (time sync) and 211 (pump control). These are monitored to understand system state but should not be written by HA while the PLC is active.
|
||||
|
||||
**HA-Writable Register**: Register 144 (Setpoint) can be safely written by Home Assistant when users adjust temperature, as this mimics manual thermostat adjustment.
|
||||
|
||||
**To add new mappings**: Update REGISTER_NAMES in const.py - sensor entities will automatically use friendly names. As you discover more registers through monitoring, document their purpose here.
|
||||
|
||||
### Climate Entity Bidirectional Sync
|
||||
|
||||
The climate entity maintains setpoint synchronization in both directions:
|
||||
|
||||
1. **User sets temperature in HA**:
|
||||
- Climate entity writes to device via hub.async_write_setpoint()
|
||||
- Immediately updates coordinator data to prevent UI flicker
|
||||
- Scaled value (temp × 10) written to register 144
|
||||
|
||||
2. **Physical thermostat changes setpoint**:
|
||||
- Hub passively monitors register 144
|
||||
- Climate entity reads from coordinator.data[slave_id].registers[144]
|
||||
- Scales value (÷ 10) for display
|
||||
|
||||
**Key Detail**: Register 144 value is immediately updated after write to prevent UI from showing stale value until next monitoring cycle.
|
||||
|
||||
### HVAC Action Display
|
||||
|
||||
Climate entity shows heating status based on register 211:
|
||||
- `HVACAction.HEATING` when register 211 = 1
|
||||
- `HVACAction.IDLE` when register 211 = 0 or unavailable
|
||||
|
||||
**Dual Representation**: Register 211 exists as both:
|
||||
- Regular sensor showing raw integer value (disabled by default)
|
||||
- Binary sensor "Zone pump" showing ON/OFF state (enabled by default)
|
||||
|
||||
This allows users to choose their preferred representation.
|
||||
|
||||
### Entity Default States
|
||||
|
||||
- **Enabled by default**: Humidity sensor, zone pump binary sensor, climate entity
|
||||
- **Disabled by default**: All register sensors (except humidity), all coil sensors
|
||||
|
||||
Users can selectively enable the entities they need.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Modbus Gateway ←→ TCP Connection ←→ Hub (passive monitoring)
|
||||
↓ (frames decoded)
|
||||
discovered_slaves dict
|
||||
↓ (throttled updates)
|
||||
Coordinator
|
||||
↓
|
||||
Entity Platforms (climate, sensor, binary_sensor)
|
||||
```
|
||||
|
||||
### Update Throttling
|
||||
|
||||
**Coordinator Updates**: Minimum 5-second interval between updates
|
||||
- Defined in COORDINATOR_UPDATE_INTERVAL
|
||||
- Prevents overwhelming Home Assistant with high-frequency Modbus traffic
|
||||
- Hub calls `_update_coordinator_throttled()` which checks timing
|
||||
|
||||
**Why**: Modbus traffic can be very frequent (multiple times per second). Without throttling, HA UI becomes sluggish.
|
||||
|
||||
## Connection Handling
|
||||
|
||||
### Multiple Servers
|
||||
|
||||
Users can configure multiple Modbus gateways as separate config entries. Each entry creates its own:
|
||||
- Hub instance with dedicated TCP connection
|
||||
- Monitor task
|
||||
- Set of discovered slaves and entities
|
||||
|
||||
**Important**: 30-second reconnection delay prevents DDOS when multiple servers are offline simultaneously.
|
||||
|
||||
### Availability Tracking
|
||||
|
||||
Slaves are marked unavailable if no data received for 5 minutes (AVAILABILITY_TIMEOUT).
|
||||
|
||||
**Implementation**: Separate `_availability_check_loop()` runs every 60 seconds and compares `last_seen` timestamp.
|
||||
|
||||
## Modbus Protocol Details
|
||||
|
||||
### RTU Frame Structure
|
||||
|
||||
```
|
||||
[Slave ID][Function Code][Data...][CRC Low][CRC High]
|
||||
```
|
||||
|
||||
**CRC Calculation**: Uses Modbus RTU CRC-16 algorithm (polynomial 0xA001)
|
||||
|
||||
### Supported Function Codes
|
||||
|
||||
- **0x01**: Read Coils (request + response with bitmap)
|
||||
- **0x03**: Read Holding Registers (request + response with values)
|
||||
- **0x06**: Write Single Register (used for setpoint writes)
|
||||
|
||||
### Signed vs Unsigned Integers
|
||||
|
||||
Modbus uses unsigned 16-bit values, but physical readings can be negative (e.g., temperatures).
|
||||
|
||||
**Conversion** (hub.py):
|
||||
```python
|
||||
if value > 32767:
|
||||
signed_value = value - 65536
|
||||
else:
|
||||
signed_value = value
|
||||
```
|
||||
|
||||
This converts unsigned to signed int16 representation.
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding New Register Mappings
|
||||
|
||||
1. Identify register address and purpose through monitoring
|
||||
2. Update `REGISTER_NAMES` in const.py:
|
||||
```python
|
||||
REGISTER_NAMES: dict[int, str] = {
|
||||
# ... existing mappings ...
|
||||
XXX: "Your Register Name",
|
||||
}
|
||||
```
|
||||
3. Sensors automatically display friendly name
|
||||
|
||||
### Extending Monitored Register Ranges
|
||||
|
||||
To monitor additional register ranges:
|
||||
|
||||
1. Add constants to const.py:
|
||||
```python
|
||||
REGISTER_START_ADDRESS_4 = 0xYY # Your start address
|
||||
REGISTER_MONITOR_COUNT_4 = ZZ # Number of registers
|
||||
```
|
||||
|
||||
2. Update hub.py `_handle_frame()` to detect requests/responses for the new range
|
||||
- Follow pattern from existing ranges (1, 2, 3)
|
||||
- Use unique pending request key (e.g., `f"{slave_id}_registers4"`)
|
||||
|
||||
3. Update sensor.py `async_setup_entry()` to create sensors for new range
|
||||
- Add tracking set (e.g., `added_slaves_registers4`)
|
||||
- Add sensor creation logic in `_async_add_sensor_entities()`
|
||||
|
||||
### Testing Reconnection Logic
|
||||
|
||||
**Critical Test**: Long outage scenario
|
||||
```bash
|
||||
# 1. Start integration with working server
|
||||
# 2. Stop Modbus server
|
||||
# 3. Wait 2+ hours
|
||||
# 4. Check logs - should see reconnection attempts every ~30 seconds
|
||||
# 5. Start Modbus server
|
||||
# 6. Verify automatic reconnection within 30 seconds
|
||||
# 7. Verify data flow resumes without manual reload
|
||||
```
|
||||
|
||||
## Known Issues and Solutions
|
||||
|
||||
### Issue: UI Flicker When Setting Temperature
|
||||
|
||||
**Symptom**: After setting temperature, UI briefly reverts to old value before updating.
|
||||
|
||||
**Solution**: Immediately update `slave_data.registers[SETPOINT_REGISTER]` after successful write (climate.py line 178).
|
||||
|
||||
### Issue: Integration Won't Reconnect After Long Outage
|
||||
|
||||
**Symptom**: After server outage >1 hour, integration requires manual reload.
|
||||
|
||||
**Root Cause**: Reader/writer not properly nulled on failed reconnection.
|
||||
|
||||
**Solution**: Implemented in hub.py lines 222-333 (see Reconnection Logic section above).
|
||||
|
||||
### Issue: Too Many Connection Attempts During Outage
|
||||
|
||||
**Symptom**: Server logs show connection spam when offline.
|
||||
|
||||
**Solution**: 30-second reconnection delay (hub.py line 237).
|
||||
|
||||
## Code Patterns and Conventions
|
||||
|
||||
### Async Safety
|
||||
|
||||
- All I/O operations are async
|
||||
- Write operations use `self._write_lock` to prevent concurrent access
|
||||
- Coordinator updates use `@callback` decorator where appropriate
|
||||
|
||||
### Logging
|
||||
|
||||
- Debug level: Frame processing details, non-critical events
|
||||
- Info level: Connection status, slave discovery, successful operations
|
||||
- Warning level: Connection loss, reconnection attempts
|
||||
- Error level: Failed reconnections, unexpected errors
|
||||
|
||||
**Format**: Always include `self.host:self.port` in connection-related messages.
|
||||
|
||||
### Entity Registration
|
||||
|
||||
Entities are dynamically created when slaves are discovered:
|
||||
- Use tracking sets (e.g., `added_slaves`, `added_slaves_registers`)
|
||||
- Subscribe to coordinator updates via `async_add_listener()`
|
||||
- Only create entities when new slaves/data detected
|
||||
|
||||
### Device Info
|
||||
|
||||
All entities for a slave share the same device:
|
||||
```python
|
||||
DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{slave_id}")},
|
||||
name=f"Modbus Slave {slave_id}",
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODEL,
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
**No YAML configuration** - integration uses config flow only.
|
||||
|
||||
**Config Entry Data**:
|
||||
- `host`: Modbus gateway IP/hostname
|
||||
- `port`: TCP port (default 502)
|
||||
|
||||
**Runtime Data**: Hub instance stored in `entry.runtime_data`
|
||||
|
||||
## Files Overview
|
||||
|
||||
- **manifest.json** - Integration metadata and dependencies
|
||||
- **const.py** - Constants, register addresses, data models
|
||||
- **hub.py** - TCP connection, frame decoding, reconnection logic
|
||||
- **coordinator.py** - Data update coordination
|
||||
- **config_flow.py** - UI configuration
|
||||
- **climate.py** - Thermostat entity
|
||||
- **sensor.py** - Humidity and register sensors
|
||||
- **binary_sensor.py** - Coil and zone pump sensors
|
||||
- **__init__.py** - Integration setup/unload
|
||||
|
||||
## Important Notes for AI Assistants
|
||||
|
||||
1. **Never remove the 30-second reconnection delay** - it prevents DDOS during outages
|
||||
2. **Always clear buffer after reconnection** - prevents processing stale frames
|
||||
3. **Maintain throttling** - Modbus traffic is high-frequency, HA needs protection
|
||||
4. **Preserve passive monitoring** - don't add active polling unless specifically requested
|
||||
5. **Keep register sensors disabled by default** - there are many, most users don't need them
|
||||
6. **Test with extended outages** - this is the most common production issue
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements to consider:
|
||||
- Support for Modbus RTU over serial (currently TCP only)
|
||||
- Configurable register ranges via UI
|
||||
- Entity auto-discovery based on register schema files
|
||||
- Diagnostic sensors for connection statistics
|
||||
- Service to manually trigger reconnection
|
||||
Reference in New Issue
Block a user