mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[modbus_server] Add coil/discrete-input support (#17464)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c8d2c3691a
commit
2a0f2d59f0
@@ -133,8 +133,8 @@ button:
|
||||
on_error:
|
||||
then:
|
||||
- lambda: "id(error_code).publish_state((int) exception_code);"
|
||||
# The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read
|
||||
# action's request PDU and its typed error delivery.
|
||||
# The mock server maps no bits, so it does not implement the coil function: a coil read draws
|
||||
# ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery.
|
||||
- modbus_client.read_coils:
|
||||
address: 1
|
||||
start_address: 0x00
|
||||
@@ -166,7 +166,7 @@ button:
|
||||
on_not_sent:
|
||||
then:
|
||||
- lambda: "id(not_sent_flag).publish_state(1);"
|
||||
# Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION.
|
||||
# Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION.
|
||||
- modbus_client.write_multiple_coils:
|
||||
address: 1
|
||||
start_address: 0x00
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-bits
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_bit_2
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
- id: stored_bit_3
|
||||
type: bool
|
||||
initial_value: "true"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
update_interval: 1s
|
||||
id: modbus_controller_1
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
bits:
|
||||
- address: 0x00
|
||||
read_lambda: return true;
|
||||
- address: 0x01
|
||||
read_lambda: return false;
|
||||
- address: 0x02
|
||||
read_lambda: return id(stored_bit_2);
|
||||
write_lambda: id(stored_bit_2) = x; return true;
|
||||
- address: 0x03
|
||||
read_lambda: return id(stored_bit_3);
|
||||
write_lambda: id(stored_bit_3) = x; return true;
|
||||
|
||||
# The same four bits are read both as coils (FC 0x01) and as discrete inputs
|
||||
# (FC 0x02): the server serves both from one shared bit table, so the two
|
||||
# views must always agree.
|
||||
binary_sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_0"
|
||||
address: 0x00
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_1"
|
||||
address: 0x01
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_0"
|
||||
address: 0x00
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_1"
|
||||
address: 0x01
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_2"
|
||||
address: 0x02
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_3"
|
||||
address: 0x03
|
||||
register_type: discrete_input
|
||||
|
||||
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
|
||||
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "write_bit_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "write_bit_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
use_write_multiple: true
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -387,8 +387,9 @@ class SensorStateCollector:
|
||||
class SensorTracker:
|
||||
"""Data-driven sensor state tracker with expected-value futures.
|
||||
|
||||
Tracks sensor state updates and resolves futures when sensors report
|
||||
specific expected values. Eliminates per-sensor future boilerplate.
|
||||
Tracks sensor and binary sensor state updates and resolves futures when
|
||||
they report specific expected values. Eliminates per-sensor future
|
||||
boilerplate.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -421,7 +422,10 @@ class SensorTracker:
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
"""State callback suitable for ``subscribe_states``."""
|
||||
if not isinstance(state, SensorState) or state.missing_state:
|
||||
if (
|
||||
not isinstance(state, (SensorState, BinarySensorState))
|
||||
or state.missing_state
|
||||
):
|
||||
return
|
||||
sensor_name = self.key_to_sensor.get(state.key)
|
||||
if not sensor_name or sensor_name not in self.sensor_states:
|
||||
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioesphomeapi import ButtonInfo, NumberInfo
|
||||
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
|
||||
import pytest
|
||||
|
||||
from .state_utils import SensorTracker, find_entity
|
||||
@@ -411,6 +411,68 @@ async def test_uart_mock_modbus_server_controller_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_bits(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test coil/discrete-input round trips between controller and server bits.
|
||||
|
||||
The server serves four bits from one shared table. The controller reads
|
||||
each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02),
|
||||
so the two views must always agree. Two bits are then written back, one
|
||||
via the single-coil write (FC 0x05) and one via the multiple-coils write
|
||||
(FC 0x0F), and the new values must show up in both read views.
|
||||
"""
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
initial_values = {
|
||||
"bit_coil_0": True,
|
||||
"bit_coil_1": False,
|
||||
"bit_coil_2": False,
|
||||
"bit_coil_3": True,
|
||||
"bit_di_0": True,
|
||||
"bit_di_1": False,
|
||||
"bit_di_2": False,
|
||||
"bit_di_3": True,
|
||||
}
|
||||
tracker = SensorTracker(list(initial_values.keys()))
|
||||
|
||||
# Phase 1: expect initial baseline values in both read views
|
||||
initial_futures = tracker.expect_all(initial_values)
|
||||
# Phase 2: expect post-write values (registered now so on_state can match them)
|
||||
written_futures = tracker.expect_all(
|
||||
{
|
||||
"bit_coil_2": True,
|
||||
"bit_di_2": True,
|
||||
"bit_coil_3": False,
|
||||
"bit_di_3": False,
|
||||
}
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities = await tracker.setup_and_start_scenario(client)
|
||||
|
||||
# Wait for initial baseline values to confirm the controller <-> server
|
||||
# connection is working before issuing writes
|
||||
await tracker.await_all(initial_futures, timeout=4.0)
|
||||
|
||||
# Flip both writable bits: 0x02 false -> true, 0x03 true -> false
|
||||
for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)):
|
||||
entity = find_entity(entities, switch_name, SwitchInfo)
|
||||
assert entity is not None, f"{switch_name} switch entity not found"
|
||||
client.switch_command(entity.key, value)
|
||||
|
||||
# Wait for both read views to reflect the written values
|
||||
await tracker.await_all(written_futures, timeout=4.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_multiple(
|
||||
yaml_config: str,
|
||||
@@ -447,10 +509,11 @@ async def test_uart_mock_modbus_client_typed(
|
||||
with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value);
|
||||
a read of unserved register 0x99 resolves via on_error with the device's exception code
|
||||
(ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via
|
||||
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error
|
||||
delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12
|
||||
chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from
|
||||
the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
|
||||
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not
|
||||
implement the coil function - proving the bit-read request and typed error delivery. A multi-register
|
||||
write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler
|
||||
(-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server
|
||||
(-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
|
||||
builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent
|
||||
(-> not_sent_flag).
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user