[modbus_controller] Apply the write offset byte-accurately on switch and output (#18787)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Bonne Eggleston
2026-08-27 08:43:51 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent 51105a25db
commit 18b7002604
8 changed files with 130 additions and 28 deletions
@@ -0,0 +1,74 @@
"""Config validation for the byte offset on holding-register write entities.
A 16-bit register write cannot target half a register, so an odd offset (or byte_offset) is
rejected for holding-register switches and outputs; even offsets and coil offsets pass.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller.output import (
CONFIG_SCHEMA as OUTPUT_CONFIG_SCHEMA,
)
from esphome.components.modbus_controller.switch import (
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_NAME, CONF_OFFSET
def _switch_config(register_type: str, offset: int) -> dict:
return {
CONF_NAME: "test switch",
CONF_ADDRESS: 0x10,
"register_type": register_type,
CONF_OFFSET: offset,
}
def _output_config(register_type: str, offset: int) -> dict:
return {
CONF_ID: "test_output",
CONF_ADDRESS: 0x10,
"register_type": register_type,
CONF_OFFSET: offset,
}
def test_odd_offset_on_holding_switch_rejected() -> None:
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
SWITCH_CONFIG_SCHEMA(_switch_config("holding", 3))
def test_even_offset_on_holding_switch_accepted() -> None:
config = SWITCH_CONFIG_SCHEMA(_switch_config("holding", 2))
assert config[CONF_OFFSET] == 2
def test_odd_offset_on_coil_switch_accepted() -> None:
"""A coil offset is a coil count, so odd values are fine."""
config = SWITCH_CONFIG_SCHEMA(_switch_config("coil", 3))
assert config[CONF_OFFSET] == 3
def test_odd_byte_offset_on_holding_switch_rejected() -> None:
"""byte_offset is the alias the validator must also catch."""
config = _switch_config("holding", 0)
del config[CONF_OFFSET]
config["byte_offset"] = 3
with pytest.raises((Invalid, MultipleInvalid), match="byte_offset"):
SWITCH_CONFIG_SCHEMA(config)
def test_odd_offset_on_holding_output_rejected() -> None:
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
OUTPUT_CONFIG_SCHEMA(_output_config("holding", 3))
def test_even_offset_on_holding_output_accepted() -> None:
config = OUTPUT_CONFIG_SCHEMA(_output_config("holding", 2))
assert config[CONF_OFFSET] == 2
def test_odd_offset_on_coil_output_accepted() -> None:
config = OUTPUT_CONFIG_SCHEMA(_output_config("coil", 3))
assert config[CONF_OFFSET] == 3
@@ -100,7 +100,7 @@ switch:
offset: 2
assumed_state: true
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
# the switch itself resolves to 0x13 (whole registers fold into the address, residual byte stays) and
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
- platform: modbus_controller
@@ -967,13 +967,6 @@ async def test_uart_mock_modbus_client_read_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.xfail(
strict=True,
reason="Byte-accurate register-offset writes land in the follow-up offset fix; "
"until then the byte offset is folded into the address (writes 0x12 instead of "
"0x11). The write and read assertions both flip via the same switch-constructor "
"fold. Remove this marker when that change merges.",
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset(
yaml_config: str,