[modbus_controller] Add continuous polling option (#18080)

This commit is contained in:
Bonne Eggleston
2026-08-24 13:26:26 -05:00
committed by GitHub
parent 49fc4be861
commit c2a153a946
14 changed files with 345 additions and 31 deletions
@@ -1,19 +1,27 @@
"""Schema-level config validation for custom_pdu and the deprecated custom_command alias.
"""Config validation for custom_pdu and the deprecated custom_command alias.
custom_command took a raw frame with a leading device address byte; custom_pdu takes the PDU only.
The old key is still accepted at the schema level and auto-migrated later in final validate (which a
bare-schema test can't reach), so these tests only cover what the schema itself enforces: the two keys
are mutually exclusive, and custom_pdu takes byte-sized values.
Most of these tests cover what the schema itself enforces (the two keys are mutually exclusive, and
custom_pdu takes byte-sized values). The last two reach the final-validate step that a bare-schema
test cannot: a write-coded custom_pdu polled continuously is rejected there.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller import ModbusItemBaseSchema
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
)
from esphome.components.modbus_controller.const import (
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
CONF_MODBUS_CONTROLLER_ID,
)
from esphome.config import Config
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_ID
from esphome.core import ID
import esphome.final_validate as fv
def test_custom_command_accepted_at_schema_level() -> None:
@@ -45,3 +53,48 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
def _controller_full_config(*, continuous: bool) -> Config:
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
final-validate to resolve the controller (and its continuous flag) from an item's
modbus_controller_id."""
ctl_id = ID("ctl", is_declaration=True)
config = Config()
config["modbus_controller"] = [
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@pytest.fixture
def reset_full_config():
token = fv.full_config.set(Config())
yield
fv.full_config.reset(token)
def test_continuous_write_custom_pdu_rejected(reset_full_config) -> None:
"""A write-coded custom_pdu (0x17 = read/write-multiple) under a continuous controller is
rejected at final validate: the hub would strip continuous from the mutating code and warn on
every update."""
fv.full_config.set(_controller_full_config(continuous=True))
with pytest.raises(Invalid, match="can't be polled continuously"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x17, 0x00, 0x03, 0x00, 0x01],
}
)
def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) under a continuous controller is fine - only writes stream."""
fv.full_config.set(_controller_full_config(continuous=True))
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
@@ -2,6 +2,7 @@ modbus_controller:
- id: modbus_controller1
address: 0x2
modbus_id: modbus_bus
continuous: true
on_online:
then:
logger.log: "Module Online"
@@ -0,0 +1,115 @@
esphome:
name: uart-mock-modbus-continuous
host:
api:
logger:
level: VERBOSE
# When set, the mock server stops forwarding its replies to the controller, so the controller sees
# timeouts - used by the recovery test to drive a live continuous poll offline and back.
globals:
- id: silence_server
type: bool
initial_value: "false"
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: true
debug:
on_tx:
- then:
- if:
condition:
lambda: "return !id(silence_server);"
then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
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
# Short timeout so the recovery test drives the poll offline quickly; when the server answers,
# replies arrive within turnaround_time, so this does not slow the streaming path.
send_wait_time: 100ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
# A long update_interval means that without continuous polling only the boot poll would run in the
# test window. continuous: true re-queues the read after each success, so it streams as fast as the
# bus allows.
update_interval: 30s
continuous: true
# One retry so a silenced device trips offline fast (initial send + 1 retry, each 100ms).
max_cmd_retries: 1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
# Each read returns the next counter value, so every poll publishes a distinct state the test can
# count (proving the read actually ran, not just that the state changed once).
- address: 0x01
value_type: U_WORD
read_lambda: |-
static uint16_t counter = 0;
return counter++;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "continuous_reg"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Trigger the first poll deterministically. PollingComponent's first update() would otherwise land
# somewhere in the 30s update_interval; once this one read completes, continuous re-queuing takes over.
on_press:
- lambda: "id(modbus_controller_1)->update();"
switch:
# Toggles whether the mock server forwards its replies. On = silence (controller sees timeouts);
# off = answer again. The recovery test uses it to drive a live continuous poll offline and back.
- platform: template
name: "Silence Server"
id: silence_server_switch
optimistic: true
turn_on_action:
- lambda: "id(silence_server) = true;"
turn_off_action:
- lambda: "id(silence_server) = false;"
@@ -736,6 +736,68 @@ async def test_uart_mock_modbus_custom_pdu(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_continuous(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that `continuous: true` polls faster than the update_interval.
The controller's update_interval is 30s, so without continuous polling only the boot poll would
run during the short test window. With continuous the read is re-queued after each success, filling
idle bus time, so many reads arrive. The server returns an incrementing counter, so every read is a
distinct published state the tracker can count. (Bus warnings are not asserted here: continuous
polling deliberately saturates the bus, so the occasional timing hiccup is expected and off-topic;
the other tests cover clean operation at normal poll rates.)
"""
tracker = SensorTracker(["continuous_reg"])
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# setup_and_start_scenario presses the Start Scenario button, whose on_press triggers the
# controller's first update(). With continuous that one read re-queues and streams; without it
# the next poll would not run until the 30s update_interval elapses.
entities = await tracker.setup_and_start_scenario(client)
# Count reads over a window far shorter than the update_interval. Absent continuous polling we
# would see ~1 (the triggered poll); continuous re-queues, so the bus fills with reads.
await asyncio.sleep(3.0)
reads = len(tracker.sensor_states["continuous_reg"])
assert reads >= 5, (
"expected many continuous reads within the window (update_interval is 30s, so absent "
f"continuous polling we would see ~1), got {reads}"
)
# Recovery path: a live continuous poll that starts failing goes offline, and the next update()
# re-arms it once the device answers again. Silence the server so the poll's reads time out; with
# max_cmd_retries=1 and send_wait_time=100ms the device trips offline quickly and streaming stops.
silence = find_entity(entities, "silence_server", SwitchInfo)
assert silence is not None, "Silence Server switch not found"
start = find_entity(entities, "start_scenario", ButtonInfo)
assert start is not None, "Start Scenario button not found"
client.switch_command(silence.key, True)
await asyncio.sleep(1.0) # let the poll fail and the device trip offline
plateau = len(tracker.sensor_states["continuous_reg"])
await asyncio.sleep(1.0) # offline: no polls should land
assert len(tracker.sensor_states["continuous_reg"]) == plateau, (
"reads kept arriving after the server was silenced - the failed continuous poll did not stop"
)
# Answer again and trigger update(): the offline probe recovers the device and the continuous
# poll re-arms, so streaming resumes.
client.switch_command(silence.key, False)
client.button_command(start.key)
await asyncio.sleep(3.0)
resumed = len(tracker.sensor_states["continuous_reg"]) - plateau
assert resumed >= 5, (
f"continuous polling did not resume after the device recovered (got {resumed} new reads)"
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_offline(
yaml_config: str,