mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: J. Nick Koston <nick@koston.org>
483 lines
19 KiB
Python
483 lines
19 KiB
Python
import binascii
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from esphome import automation
|
|
import esphome.codegen as cg
|
|
from esphome.components import modbus
|
|
from esphome.components.modbus.helpers import (
|
|
MODBUS_REGISTER_TYPE,
|
|
TYPE_REGISTER_MAP,
|
|
EntityType,
|
|
)
|
|
import esphome.config_validation as cv
|
|
from esphome.const import (
|
|
CONF_ADDRESS,
|
|
CONF_CONTINUOUS,
|
|
CONF_ID,
|
|
CONF_LAMBDA,
|
|
CONF_NAME,
|
|
CONF_OFFSET,
|
|
)
|
|
from esphome.core import CORE
|
|
from esphome.cpp_helpers import logging
|
|
import esphome.final_validate as fv
|
|
from esphome.types import ConfigType
|
|
|
|
from .const import (
|
|
CONF_ALLOW_DUPLICATE_COMMANDS,
|
|
CONF_BITMASK,
|
|
CONF_BYTE_OFFSET,
|
|
CONF_COMMAND_THROTTLE,
|
|
CONF_CUSTOM_COMMAND,
|
|
CONF_CUSTOM_PDU,
|
|
CONF_FORCE_NEW_RANGE,
|
|
CONF_MAX_CMD_RETRIES,
|
|
CONF_MODBUS_CONTROLLER_ID,
|
|
CONF_OFFLINE_SKIP_UPDATES,
|
|
CONF_ON_COMMAND_SENT,
|
|
CONF_ON_OFFLINE,
|
|
CONF_ON_ONLINE,
|
|
CONF_REGISTER_COUNT,
|
|
CONF_REGISTER_TYPE,
|
|
CONF_RESPONSE_SIZE,
|
|
CONF_REUSE_PREVIOUS_RANGE,
|
|
CONF_SERVER_COURTESY_RESPONSE,
|
|
CONF_SERVER_REGISTERS,
|
|
CONF_SKIP_UPDATES,
|
|
CONF_VALUE_TYPE,
|
|
)
|
|
|
|
CODEOWNERS = ["@martgras"]
|
|
|
|
AUTO_LOAD = ["modbus"]
|
|
|
|
MULTI_CONF = True
|
|
|
|
DOMAIN = "modbus_controller"
|
|
|
|
modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller")
|
|
ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingComponent)
|
|
|
|
SensorItem = modbus_controller_ns.struct("SensorItem")
|
|
|
|
RangeReuse = modbus_controller_ns.enum("RangeReuse", is_class=True)
|
|
RANGE_REUSE = {
|
|
"auto": RangeReuse.AUTO,
|
|
True: RangeReuse.ALWAYS,
|
|
False: RangeReuse.NEVER,
|
|
}
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ModbusControllerData:
|
|
# Set once the deprecated 'skip_updates' warning has been emitted so we warn only once total.
|
|
skip_updates_warned: bool = False
|
|
|
|
|
|
def _get_data() -> ModbusControllerData:
|
|
if DOMAIN not in CORE.data:
|
|
CORE.data[DOMAIN] = ModbusControllerData()
|
|
return CORE.data[DOMAIN]
|
|
|
|
|
|
# Remove before 2027.2.0
|
|
_REMOVED_OPTIONS = {
|
|
CONF_COMMAND_THROTTLE: "Command spacing is handled by the 'modbus' component - use 'turnaround_time' there instead.",
|
|
CONF_ALLOW_DUPLICATE_COMMANDS: "Polling commands are deduplicated by the modbus hub; one-shot commands (writes) are always transmitted.",
|
|
}
|
|
|
|
|
|
def _warn_removed_options(config: ConfigType) -> ConfigType:
|
|
"""Warn about options that no longer do anything, but let the config compile."""
|
|
for option, replacement in _REMOVED_OPTIONS.items():
|
|
if option in config:
|
|
_LOGGER.warning(
|
|
"[modbus_controller] '%s' no longer has any effect and will be removed in 2027.2.0. %s",
|
|
option,
|
|
replacement,
|
|
)
|
|
return config
|
|
|
|
|
|
def _reject_broadcast_address(config: ConfigType) -> ConfigType:
|
|
"""A modbus_controller polls one device, so its address cannot be the broadcast address (0):
|
|
a broadcast is never answered (Modbus 4.1), so no register could ever read back."""
|
|
modbus.reject_broadcast_address(
|
|
config.get(CONF_ADDRESS),
|
|
"a modbus_controller device address",
|
|
"Assign the unit address of the device you want to poll.",
|
|
[CONF_ADDRESS],
|
|
)
|
|
return config
|
|
|
|
|
|
# Remove before 2027.3.0. skip_updates (a per-sensor option) no longer does anything: every range is
|
|
# polled each update_interval. The key is still accepted so existing configs keep working, with a warning.
|
|
def validate_skip_updates_deprecated(value: Any) -> int:
|
|
data = _get_data()
|
|
if not data.skip_updates_warned:
|
|
_LOGGER.warning(
|
|
"[modbus_controller] 'skip_updates' no longer has any effect and will be removed in 2027.3.0. "
|
|
"To poll some registers less often, add a second modbus_controller with the same address and a "
|
|
"slower update_interval, and attach the slow sensors to it."
|
|
)
|
|
data.skip_updates_warned = True
|
|
return cv.positive_int(value)
|
|
|
|
|
|
CONFIG_SCHEMA = cv.All(
|
|
cv.Schema(
|
|
{
|
|
cv.GenerateID(): cv.declare_id(ModbusController),
|
|
# Removed options: accepted (and ignored) until 2027.2.0 so existing configs keep building.
|
|
cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS): cv.boolean,
|
|
cv.Optional(CONF_COMMAND_THROTTLE): cv.positive_time_period_milliseconds,
|
|
cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid(
|
|
"This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/"
|
|
),
|
|
cv.Optional(CONF_MAX_CMD_RETRIES, default=4): cv.positive_int,
|
|
cv.Optional(CONF_OFFLINE_SKIP_UPDATES, default=0): cv.positive_int,
|
|
**modbus.command_options_schema(direction="read"),
|
|
cv.Optional(
|
|
CONF_SERVER_REGISTERS,
|
|
): cv.invalid(
|
|
"This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/"
|
|
),
|
|
cv.Optional(CONF_ON_COMMAND_SENT): automation.validate_automation({}),
|
|
cv.Optional(CONF_ON_ONLINE): automation.validate_automation({}),
|
|
cv.Optional(CONF_ON_OFFLINE): automation.validate_automation({}),
|
|
}
|
|
)
|
|
.extend(cv.polling_component_schema("60s"))
|
|
.extend(modbus.modbus_device_schema(0x01)),
|
|
_warn_removed_options,
|
|
_reject_broadcast_address,
|
|
)
|
|
|
|
ModbusItemBaseSchema = cv.Schema(
|
|
{
|
|
cv.GenerateID(CONF_MODBUS_CONTROLLER_ID): cv.use_id(ModbusController),
|
|
cv.Optional(CONF_ADDRESS): cv.positive_int,
|
|
cv.Exclusive(
|
|
CONF_CUSTOM_PDU,
|
|
"custom_source",
|
|
f"{CONF_CUSTOM_PDU} and {CONF_CUSTOM_COMMAND} can't be used together",
|
|
): cv.All(
|
|
cv.ensure_list(cv.hex_uint8_t),
|
|
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
|
|
),
|
|
# Deprecated: takes a raw frame with a leading device address byte. Auto-migrated to
|
|
# custom_pdu in migrate_custom_command (final validate). Remove before 2027.3.0. The upper
|
|
# bound is MAX_PDU_SIZE + 1: the extra byte is the address the migration strips.
|
|
cv.Exclusive(
|
|
CONF_CUSTOM_COMMAND,
|
|
"custom_source",
|
|
f"{CONF_CUSTOM_PDU} and {CONF_CUSTOM_COMMAND} can't be used together",
|
|
): cv.All(
|
|
cv.ensure_list(cv.hex_uint8_t),
|
|
cv.Length(min=2, max=modbus.MAX_PDU_SIZE + 1),
|
|
),
|
|
cv.Exclusive(
|
|
CONF_OFFSET,
|
|
"offset",
|
|
f"{CONF_OFFSET} and {CONF_BYTE_OFFSET} can't be used together",
|
|
): cv.positive_int,
|
|
cv.Exclusive(
|
|
CONF_BYTE_OFFSET,
|
|
"offset",
|
|
f"{CONF_OFFSET} and {CONF_BYTE_OFFSET} can't be used together",
|
|
): cv.positive_int,
|
|
cv.Optional(CONF_BITMASK, default=0xFFFFFFFF): cv.hex_uint32_t,
|
|
cv.Optional(CONF_SKIP_UPDATES): validate_skip_updates_deprecated,
|
|
cv.Optional(CONF_REUSE_PREVIOUS_RANGE, default="auto"): cv.Any(
|
|
cv.boolean, cv.one_of("auto", lower=True)
|
|
),
|
|
# Deprecated options, migrated by validate_range_reuse_migration(). Remove before 2027.3.0
|
|
cv.Optional(CONF_FORCE_NEW_RANGE): cv.boolean,
|
|
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
|
|
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
|
|
cv.Optional(CONF_RESPONSE_SIZE, default=0): cv.int_range(min=0, max=250),
|
|
},
|
|
)
|
|
|
|
|
|
def _derived_register_widths(config: ConfigType) -> set[int]:
|
|
"""Register widths an item derives on its own; a matching register_count is redundant."""
|
|
response_size = config.get(CONF_RESPONSE_SIZE, 0)
|
|
if (value_type := config.get(CONF_VALUE_TYPE)) is not None:
|
|
widths = {TYPE_REGISTER_MAP[value_type]}
|
|
if value_type == "RAW" and response_size > 0:
|
|
widths.add((response_size + 1) // 2)
|
|
return widths
|
|
if response_size > 0:
|
|
# text sensors: the old default was floor(response_size / 2); the derived width is now ceil
|
|
return {response_size // 2, (response_size + 1) // 2}
|
|
return {1}
|
|
|
|
|
|
def entity_label(config: ConfigType) -> str:
|
|
"""The entity's name or id, so migration messages say which entry to edit."""
|
|
label = config.get(CONF_NAME) or config.get(CONF_ID)
|
|
return str(label) if label is not None else "<unnamed>"
|
|
|
|
|
|
# Remove before 2027.3.0
|
|
def validate_range_reuse_migration(config: ConfigType) -> ConfigType:
|
|
"""Migrate the removed force_new_range/register_count options to reuse_previous_range."""
|
|
if (force_new_range := config.pop(CONF_FORCE_NEW_RANGE, None)) is not None:
|
|
if config[CONF_REUSE_PREVIOUS_RANGE] != "auto":
|
|
raise cv.Invalid(
|
|
f"'{CONF_FORCE_NEW_RANGE}' and '{CONF_REUSE_PREVIOUS_RANGE}' can't be used together; "
|
|
f"remove '{CONF_FORCE_NEW_RANGE}'"
|
|
)
|
|
if force_new_range:
|
|
_LOGGER.warning(
|
|
"%s: '%s' is deprecated; '%s: false' replaces it but only stops this entity joining "
|
|
"the PREVIOUS range - set it on the following entity too if the range must stay "
|
|
"isolated. Removed in 2027.3.0",
|
|
entity_label(config),
|
|
CONF_FORCE_NEW_RANGE,
|
|
CONF_REUSE_PREVIOUS_RANGE,
|
|
)
|
|
config[CONF_REUSE_PREVIOUS_RANGE] = False
|
|
else:
|
|
_LOGGER.warning(
|
|
"%s: '%s: false' has no effect; remove it. Removed in 2027.3.0",
|
|
entity_label(config),
|
|
CONF_FORCE_NEW_RANGE,
|
|
)
|
|
if (register_count := config.pop(CONF_REGISTER_COUNT, None)) is not None:
|
|
if (
|
|
register_count not in _derived_register_widths(config)
|
|
and register_count != 0
|
|
):
|
|
raise cv.Invalid(
|
|
f"'{CONF_REGISTER_COUNT}' has been removed; the number of registers to read is now "
|
|
f"derived from '{CONF_VALUE_TYPE}' (or '{CONF_RESPONSE_SIZE}' for RAW values and text "
|
|
f"sensors). To make one request span extra registers up to the next sensor, set "
|
|
f"'{CONF_REUSE_PREVIOUS_RANGE}: true' on the NEXT sensor instead; for RAW or text block "
|
|
f"reads set '{CONF_RESPONSE_SIZE}' to the byte count; to force multi-register writes set "
|
|
f"'use_write_multiple: true'. See "
|
|
"https://esphome.io/components/modbus_controller/"
|
|
)
|
|
_LOGGER.warning(
|
|
"%s: '%s' is now derived from '%s' (or '%s' for RAW values and text sensors) and has no "
|
|
"effect; remove it. Removed in 2027.3.0",
|
|
entity_label(config),
|
|
CONF_REGISTER_COUNT,
|
|
CONF_VALUE_TYPE,
|
|
CONF_RESPONSE_SIZE,
|
|
)
|
|
return config
|
|
|
|
|
|
def validate_modbus_register(config: ConfigType) -> ConfigType:
|
|
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat
|
|
# either as "a custom frame is configured" so the address/register_type rules match.
|
|
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
|
|
if not has_custom and CONF_ADDRESS not in config:
|
|
raise cv.Invalid(
|
|
f" {CONF_ADDRESS} is a required property if '{CONF_CUSTOM_PDU}:' isn't used"
|
|
)
|
|
if has_custom and CONF_REGISTER_TYPE in config:
|
|
raise cv.Invalid(
|
|
f"can't use '{CONF_REGISTER_TYPE}:' together with '{CONF_CUSTOM_PDU}:'",
|
|
)
|
|
|
|
if not has_custom and CONF_REGISTER_TYPE not in config:
|
|
raise cv.Invalid(
|
|
f" {CONF_REGISTER_TYPE} is a required property if '{CONF_CUSTOM_PDU}:' isn't used"
|
|
)
|
|
return config
|
|
|
|
|
|
def migrate_custom_command(config: ConfigType) -> None:
|
|
"""Final-validate: auto-migrate the deprecated custom_command (raw frame incl. device address)
|
|
to custom_pdu (PDU only). custom_pdu is always sent to the controller's own address, so a frame
|
|
whose address byte does not match the controller's address is a hard error (it targeted a
|
|
different unit). Mutates config in place; final validate discards the return value."""
|
|
frame = config.get(CONF_CUSTOM_COMMAND)
|
|
if frame is None:
|
|
return
|
|
fconf = fv.full_config.get()
|
|
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
|
|
controller = fconf.get_config_for_path(path)
|
|
# the controller's DEVICE address (from modbus_device_schema)
|
|
address = controller[CONF_ADDRESS]
|
|
if frame[0] != address:
|
|
raise cv.Invalid(
|
|
f"'custom_command' begins with device address {frame[0]:#04x}, but this sensor's "
|
|
f"modbus_controller uses address {address:#04x}. 'custom_command' is renamed to "
|
|
f"'custom_pdu', which is always sent to the controller's own address. Drop the leading "
|
|
f"address byte and use 'custom_pdu' if {address:#04x} is correct, or move this sensor to "
|
|
f"the modbus_controller for device {frame[0]:#04x}.",
|
|
[CONF_CUSTOM_COMMAND],
|
|
)
|
|
_LOGGER.warning(
|
|
"[modbus_controller] 'custom_command' is deprecated and will be removed in 2027.3.0; "
|
|
"auto-migrated to 'custom_pdu' (dropped the leading device address byte). Rename the key "
|
|
"and drop that byte to silence this warning."
|
|
)
|
|
config[CONF_CUSTOM_PDU] = list(frame[1:])
|
|
del config[CONF_CUSTOM_COMMAND]
|
|
|
|
|
|
def _reject_continuous_write_custom_pdu(config: ConfigType) -> None:
|
|
"""Final-validate: a custom_pdu whose function code writes (e.g. 0x17 read/write-multiple) cannot be
|
|
polled continuously - the hub ignores continuous for mutating codes and would warn on every update
|
|
while that range silently does not stream. Reject the combination instead. Runs after
|
|
migrate_custom_command, so it sees custom_pdu whether written directly or migrated from
|
|
custom_command."""
|
|
pdu = config.get(CONF_CUSTOM_PDU)
|
|
if pdu is None or not modbus.is_function_code_write(pdu[0]):
|
|
return
|
|
fconf = fv.full_config.get()
|
|
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
|
|
controller = fconf.get_config_for_path(path)
|
|
if controller.get(CONF_CONTINUOUS) is True:
|
|
raise cv.Invalid(
|
|
f"a '{CONF_CUSTOM_PDU}' with a write function code (0x{pdu[0] & 0x7F:02X}) can't be polled "
|
|
f"continuously: the hub ignores 'continuous' for mutating codes. Remove 'continuous: true' "
|
|
f"from the '{controller[CONF_ID]}' modbus_controller, or use a read function code.",
|
|
[CONF_CUSTOM_PDU],
|
|
)
|
|
|
|
|
|
def validate_custom_pdu_item(config: ConfigType) -> None:
|
|
"""Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor,
|
|
text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a
|
|
continuously-polling controller."""
|
|
migrate_custom_command(config)
|
|
_reject_continuous_write_custom_pdu(config)
|
|
|
|
|
|
def _final_validate(config: ConfigType) -> None:
|
|
modbus.final_validate_modbus_device("modbus_controller", role="client")(config)
|
|
|
|
|
|
FINAL_VALIDATE_SCHEMA = _final_validate
|
|
|
|
|
|
def reject_odd_holding_write_offset(config: ConfigType) -> ConfigType:
|
|
"""Reject an odd byte offset on a holding-register write entity.
|
|
|
|
A 16-bit register write cannot target half a register, so the residual byte is inexpressible.
|
|
"""
|
|
key = CONF_BYTE_OFFSET if CONF_BYTE_OFFSET in config else CONF_OFFSET
|
|
if config.get(key, 0) % 2:
|
|
raise cv.Invalid(
|
|
f"An odd '{key}' cannot be used with holding-register writes: a 16-bit register "
|
|
"write cannot target half a register. Use an even offset, or fold it into 'address'",
|
|
path=[key],
|
|
)
|
|
return config
|
|
|
|
|
|
def modbus_calc_properties(config: ConfigType) -> int:
|
|
byte_offset = 0
|
|
if CONF_OFFSET in config:
|
|
byte_offset = config[CONF_OFFSET]
|
|
# A CONF_BYTE_OFFSET setting overrides CONF_OFFSET
|
|
if CONF_BYTE_OFFSET in config:
|
|
byte_offset = config[CONF_BYTE_OFFSET]
|
|
if CONF_CUSTOM_PDU in config:
|
|
if CONF_ADDRESS not in config:
|
|
# generate a unique modbus address using the hash of the name
|
|
# CONF_NAME set even if only CONF_ID is used.
|
|
# a modbus register address is required to add the item to sensormap
|
|
value = config[CONF_NAME]
|
|
if isinstance(value, str):
|
|
value = value.encode()
|
|
config[CONF_ADDRESS] = binascii.crc_hqx(value, 0)
|
|
config[CONF_REGISTER_TYPE] = cv.enum(MODBUS_REGISTER_TYPE)("custom")
|
|
return byte_offset
|
|
|
|
|
|
async def add_modbus_base_properties(
|
|
var: cg.MockObj,
|
|
config: ConfigType,
|
|
sensor_type: cg.MockObjClass,
|
|
lambda_param_type: cg.MockObj = cg.float_,
|
|
lambda_return_type: Any = float,
|
|
) -> None:
|
|
if CONF_CUSTOM_PDU in config:
|
|
cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU]))
|
|
|
|
if config[CONF_RESPONSE_SIZE] > 0:
|
|
cg.add(var.set_register_size(config[CONF_RESPONSE_SIZE]))
|
|
|
|
if CONF_LAMBDA in config:
|
|
template_ = await cg.process_lambda(
|
|
config[CONF_LAMBDA],
|
|
[
|
|
(sensor_type.operator("ptr"), "item"),
|
|
(lambda_param_type, "x"),
|
|
(cg.std_span.template(cg.uint8.operator("const")), "data"),
|
|
],
|
|
return_type=cg.optional.template(lambda_return_type),
|
|
)
|
|
cg.add(var.set_template(template_))
|
|
|
|
|
|
_CALLBACK_AUTOMATIONS = (
|
|
automation.CallbackAutomation(
|
|
CONF_ON_COMMAND_SENT,
|
|
"add_on_command_sent_callback",
|
|
[(cg.int_, "function_code"), (cg.int_, "address")],
|
|
),
|
|
automation.CallbackAutomation(
|
|
CONF_ON_ONLINE,
|
|
"add_on_online_callback",
|
|
[(cg.int_, "function_code"), (cg.int_, "address")],
|
|
),
|
|
automation.CallbackAutomation(
|
|
CONF_ON_OFFLINE,
|
|
"add_on_offline_callback",
|
|
[(cg.int_, "function_code"), (cg.int_, "address")],
|
|
),
|
|
)
|
|
|
|
|
|
async def to_code(config: ConfigType) -> None:
|
|
# Await the hub first, so no entity can bind to a controller that doesn't have one yet.
|
|
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
|
|
var = cg.new_Pvariable(config[CONF_ID], hub, config[CONF_ADDRESS])
|
|
await cg.register_component(var, config)
|
|
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
|
|
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
|
|
cg.add(
|
|
var.set_read_options(
|
|
modbus.command_options_expression(config, direction="read")
|
|
)
|
|
)
|
|
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
|
|
|
|
|
async def register_modbus_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
|
# Remove before 2027.3.0
|
|
_LOGGER.warning(
|
|
"'modbus_controller.register_modbus_device' is deprecated, use "
|
|
"'modbus.register_modbus_client_device' and set the address on your own "
|
|
"class instead. Will be removed in 2027.3.0"
|
|
)
|
|
cg.add(var.set_address(config[CONF_ADDRESS]))
|
|
await cg.register_component(var, config)
|
|
return await modbus.register_modbus_client_device(var, config)
|
|
|
|
|
|
def function_code_to_register(function_code: str) -> cg.MockObj:
|
|
FUNCTION_CODE_TYPE_MAP = {
|
|
"read_coils": EntityType.COIL,
|
|
"read_discrete_inputs": EntityType.DISCRETE_INPUT,
|
|
"read_holding_registers": EntityType.HOLDING,
|
|
"read_input_registers": EntityType.INPUT_REGISTER,
|
|
"write_single_coil": EntityType.COIL,
|
|
"write_single_register": EntityType.HOLDING,
|
|
"write_multiple_coils": EntityType.COIL,
|
|
"write_multiple_registers": EntityType.HOLDING,
|
|
}
|
|
return FUNCTION_CODE_TYPE_MAP[function_code]
|