mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
[modbus_controller] Rename custom_command to custom_pdu; reject address 0; retire skip_updates (#18652)
This commit is contained in:
@@ -175,15 +175,34 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
|
||||
|
||||
|
||||
# The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1),
|
||||
# so it cannot identify an individual device or read anything back.
|
||||
BROADCAST_ADDRESS = 0
|
||||
|
||||
|
||||
def reject_broadcast_address(
|
||||
address: int, usage: str, guidance: str, path: list[str] | None = None
|
||||
) -> None:
|
||||
"""Raise cv.Invalid if `address` is the Modbus broadcast address (0).
|
||||
|
||||
`usage` names how the address is being used (e.g. "a server device address") and `guidance`
|
||||
is a sentence telling the user what to do instead. Sharing the leading sentence here keeps the
|
||||
call sites (server device, modbus_controller) from drifting apart.
|
||||
"""
|
||||
if address == BROADCAST_ADDRESS:
|
||||
raise cv.Invalid(
|
||||
f"Address 0 is the Modbus broadcast address and cannot be used as {usage}. {guidance}",
|
||||
path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_server_address(value: Any) -> int:
|
||||
address = cv.hex_uint8_t(value)
|
||||
# The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1),
|
||||
# so it cannot identify an individual server device.
|
||||
if address == 0:
|
||||
raise cv.Invalid(
|
||||
"Address 0 is the Modbus broadcast address and cannot be used as a "
|
||||
"server device address. Assign a unique unit address instead."
|
||||
)
|
||||
reject_broadcast_address(
|
||||
address,
|
||||
"a server device address",
|
||||
"Assign a unique unit address instead.",
|
||||
)
|
||||
return address
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
@@ -10,7 +12,9 @@ from esphome.components.modbus.helpers import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, 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 (
|
||||
@@ -19,6 +23,7 @@ from .const import (
|
||||
CONF_BYTE_OFFSET,
|
||||
CONF_COMMAND_THROTTLE,
|
||||
CONF_CUSTOM_COMMAND,
|
||||
CONF_CUSTOM_PDU,
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
CONF_MAX_CMD_RETRIES,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
@@ -41,6 +46,8 @@ 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)
|
||||
|
||||
@@ -48,6 +55,19 @@ SensorItem = modbus_controller_ns.struct("SensorItem")
|
||||
|
||||
_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.",
|
||||
@@ -67,6 +87,32 @@ def _warn_removed_options(config: ConfigType) -> ConfigType:
|
||||
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(
|
||||
{
|
||||
@@ -92,13 +138,32 @@ CONFIG_SCHEMA = cv.All(
|
||||
.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.Optional(CONF_CUSTOM_COMMAND): cv.ensure_list(cv.hex_uint8_t),
|
||||
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",
|
||||
@@ -110,7 +175,7 @@ ModbusItemBaseSchema = cv.Schema(
|
||||
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, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_SKIP_UPDATES): validate_skip_updates_deprecated,
|
||||
cv.Optional(CONF_FORCE_NEW_RANGE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_RESPONSE_SIZE, default=0): cv.positive_int,
|
||||
@@ -119,22 +184,56 @@ ModbusItemBaseSchema = cv.Schema(
|
||||
|
||||
|
||||
def validate_modbus_register(config):
|
||||
if CONF_CUSTOM_COMMAND not in config and CONF_ADDRESS not in config:
|
||||
# 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_COMMAND}:' isn't used"
|
||||
f" {CONF_ADDRESS} is a required property if '{CONF_CUSTOM_PDU}:' isn't used"
|
||||
)
|
||||
if CONF_CUSTOM_COMMAND in config and CONF_REGISTER_TYPE in config:
|
||||
if has_custom and CONF_REGISTER_TYPE in config:
|
||||
raise cv.Invalid(
|
||||
f"can't use '{CONF_REGISTER_TYPE}:' together with '{CONF_CUSTOM_COMMAND}:'",
|
||||
f"can't use '{CONF_REGISTER_TYPE}:' together with '{CONF_CUSTOM_PDU}:'",
|
||||
)
|
||||
|
||||
if CONF_CUSTOM_COMMAND not in config and CONF_REGISTER_TYPE not in config:
|
||||
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_COMMAND}:' isn't used"
|
||||
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 _final_validate(config: ConfigType) -> None:
|
||||
modbus.final_validate_modbus_device("modbus_controller", role="client")(config)
|
||||
|
||||
@@ -156,7 +255,7 @@ def modbus_calc_properties(config):
|
||||
value_type = config[CONF_VALUE_TYPE]
|
||||
if reg_count == 0:
|
||||
reg_count = TYPE_REGISTER_MAP[value_type]
|
||||
if CONF_CUSTOM_COMMAND in config:
|
||||
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.
|
||||
@@ -173,8 +272,8 @@ def modbus_calc_properties(config):
|
||||
async def add_modbus_base_properties(
|
||||
var, config, sensor_type, lambda_param_type=cg.float_, lambda_return_type=float
|
||||
):
|
||||
if CONF_CUSTOM_COMMAND in config:
|
||||
cg.add(var.set_custom_data(config[CONF_CUSTOM_COMMAND]))
|
||||
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]))
|
||||
|
||||
@@ -8,6 +8,7 @@ from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
SensorItem,
|
||||
add_modbus_base_properties,
|
||||
migrate_custom_command,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
validate_modbus_register,
|
||||
@@ -17,7 +18,6 @@ from ..const import (
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
CONF_REGISTER_TYPE,
|
||||
CONF_SKIP_UPDATES,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["modbus_controller"]
|
||||
@@ -40,6 +40,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_modbus_register,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = migrate_custom_command
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
byte_offset, _ = modbus_calc_properties(config)
|
||||
@@ -49,7 +51,6 @@ async def to_code(config):
|
||||
config[CONF_ADDRESS],
|
||||
byte_offset,
|
||||
config[CONF_BITMASK],
|
||||
config[CONF_SKIP_UPDATES],
|
||||
config[CONF_FORCE_NEW_RANGE],
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -11,13 +11,12 @@ namespace esphome::modbus_controller {
|
||||
class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem {
|
||||
public:
|
||||
ModbusBinarySensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
uint16_t skip_updates, bool force_new_range) {
|
||||
bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->bitmask = bitmask;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->skip_updates = skip_updates;
|
||||
this->force_new_range = force_new_range;
|
||||
|
||||
if (modbus::helpers::is_entity_type_binary(register_type)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ CONF_BYTE_OFFSET = "byte_offset"
|
||||
CONF_COMMAND_THROTTLE = "command_throttle"
|
||||
CONF_OFFLINE_SKIP_UPDATES = "offline_skip_updates"
|
||||
CONF_CUSTOM_COMMAND = "custom_command"
|
||||
CONF_CUSTOM_PDU = "custom_pdu"
|
||||
CONF_FORCE_NEW_RANGE = "force_new_range"
|
||||
CONF_MAX_CMD_RETRIES = "max_cmd_retries"
|
||||
CONF_MODBUS_CONTROLLER_ID = "modbus_controller_id"
|
||||
|
||||
@@ -14,7 +14,6 @@ ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::Modbu
|
||||
RegisterRange &&range)
|
||||
: modbus::ModbusClientDevice(parent, address),
|
||||
sensors(std::move(range.sensors)),
|
||||
skip_updates(range.skip_updates),
|
||||
register_type_(range.register_type),
|
||||
start_address_(range.start_address),
|
||||
register_count_(range.register_count),
|
||||
@@ -24,12 +23,14 @@ ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::Modbu
|
||||
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
|
||||
SensorItem *sensor)
|
||||
: modbus::ModbusClientDevice(parent, address),
|
||||
skip_updates(sensor->skip_updates),
|
||||
start_address_(sensor->start_address),
|
||||
register_count_(sensor->register_count),
|
||||
function_code_(FunctionCode::CUSTOM),
|
||||
custom_data_(&sensor->custom_data),
|
||||
custom_pdu_(&sensor->custom_pdu),
|
||||
controller_(&controller) {
|
||||
// The PDU's first byte is its real function code; carry it so dump_config, the on_command_sent
|
||||
// trigger and the response callbacks report the actual code instead of CUSTOM.
|
||||
if (!sensor->custom_pdu.empty())
|
||||
this->function_code_ = static_cast<FunctionCode>(sensor->custom_pdu.data()[0]);
|
||||
this->sensors.insert(sensor);
|
||||
}
|
||||
|
||||
@@ -40,13 +41,12 @@ ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::Modbu
|
||||
ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other)
|
||||
: modbus::ModbusClientDevice(other.parent_, other.address_),
|
||||
sensors(other.sensors),
|
||||
skip_updates(other.skip_updates),
|
||||
on_data_func(other.on_data_func),
|
||||
register_type_(other.register_type_),
|
||||
start_address_(other.start_address_),
|
||||
register_count_(other.register_count_),
|
||||
function_code_(other.function_code_),
|
||||
custom_data_(other.custom_data_),
|
||||
custom_pdu_(other.custom_pdu_),
|
||||
controller_(other.controller_) {
|
||||
// SmallInlineBuffer is move-only, so deep-copy the bytes explicitly.
|
||||
this->payload.set(other.payload.data(), other.payload.size());
|
||||
@@ -55,14 +55,13 @@ ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other)
|
||||
ModbusCommandItem::ModbusCommandItem(ModbusCommandItem &&other) noexcept
|
||||
: modbus::ModbusClientDevice(other.parent_, other.address_),
|
||||
sensors(std::move(other.sensors)),
|
||||
skip_updates(other.skip_updates),
|
||||
on_data_func(std::move(other.on_data_func)),
|
||||
payload(std::move(other.payload)),
|
||||
register_type_(other.register_type_),
|
||||
start_address_(other.start_address_),
|
||||
register_count_(other.register_count_),
|
||||
function_code_(other.function_code_),
|
||||
custom_data_(other.custom_data_),
|
||||
custom_pdu_(other.custom_pdu_),
|
||||
controller_(other.controller_) {
|
||||
other.parent_ = nullptr;
|
||||
}
|
||||
@@ -74,11 +73,14 @@ void ModbusCommandItem::on_response(std::span<const uint8_t> request_pdu, std::s
|
||||
auto data = modbus::helpers::server_pdu_payload(response_pdu);
|
||||
if (this->on_data_func) {
|
||||
this->on_data_func(this->register_type_, this->start_address_, data);
|
||||
} else if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
|
||||
// write acknowledgement - nothing to publish
|
||||
} else {
|
||||
} else if (!this->sensors.empty()) {
|
||||
// A polling command always has sensors; a factory/write command never does. Test this before the
|
||||
// write-code branch so a custom_pdu whose function code is a write (e.g. 0x17, whose response
|
||||
// carries read data) still reaches its sensor instead of being treated as a bare write ack.
|
||||
for (auto *sensor : this->sensors)
|
||||
sensor->parse_and_publish(data);
|
||||
} else if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
|
||||
// write acknowledgement - nothing to publish
|
||||
}
|
||||
if (this->controller_ != nullptr)
|
||||
this->controller_->unqueue_command(this);
|
||||
@@ -116,13 +118,11 @@ void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
// on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak.
|
||||
// Test the address the frame went to, not address_: a custom command's frame carries its own address
|
||||
// (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
|
||||
// A custom polling command sends its PDU to this controller's own address, so only a factory custom
|
||||
// command (a raw frame staged in payload) can carry a different address byte.
|
||||
uint8_t wire_address = this->address_;
|
||||
if (this->function_code_ == FunctionCode::CUSTOM) {
|
||||
std::span<const uint8_t> frame =
|
||||
this->custom_data_ != nullptr ? std::span<const uint8_t>(*this->custom_data_) : this->payload;
|
||||
if (!frame.empty())
|
||||
wire_address = frame[0];
|
||||
}
|
||||
if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty())
|
||||
wire_address = this->payload.data()[0];
|
||||
if (wire_address == modbus::BROADCAST_ADDRESS)
|
||||
this->controller_->unqueue_command(this);
|
||||
}
|
||||
@@ -194,23 +194,11 @@ void ModbusController::sweep_completed_one_shots_() {
|
||||
[](const std::unique_ptr<ModbusCommandItem> &item) { return item->pending_removal; });
|
||||
}
|
||||
|
||||
void ModbusController::update_range_(ModbusCommandItem &cmd) {
|
||||
if (this->update_counter_ % (cmd.skip_updates + 1) != 0) {
|
||||
ESP_LOGVV(TAG, "Skipping update for range 0x%X", cmd.register_address());
|
||||
return;
|
||||
}
|
||||
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
|
||||
if (!cmd.send()) {
|
||||
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address());
|
||||
}
|
||||
}
|
||||
|
||||
void ModbusController::update() {
|
||||
this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks
|
||||
if (this->module_offline_) {
|
||||
// Offline probing follows the offline cadence alone; per-range skip_updates resumes once the
|
||||
// device is back online. Requiring both cadences to coincide would leave phase combinations
|
||||
// where a probe never goes out.
|
||||
// Offline probing follows the offline cadence alone; regular every-update polling resumes once
|
||||
// the device is back online.
|
||||
if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) {
|
||||
ESP_LOGV(TAG, "Module offline - retrying");
|
||||
this->cmd_non_responses_ = 0; // allow the probe through can_send()
|
||||
@@ -229,7 +217,9 @@ void ModbusController::update() {
|
||||
if (this->can_send()) {
|
||||
for (auto &cmd : this->polling_command_items_) {
|
||||
ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address());
|
||||
this->update_range_(cmd);
|
||||
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
|
||||
if (!cmd.send())
|
||||
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address());
|
||||
}
|
||||
}
|
||||
this->update_counter_++;
|
||||
@@ -264,8 +254,8 @@ void ModbusController::create_polling_commands_() {
|
||||
bool range_custom_size = false;
|
||||
SensorItem *prev = nullptr;
|
||||
for (SensorItem *curr : this->sensorset_) {
|
||||
ESP_LOGV(TAG, "Register: 0x%X count=%d size=%zu offset=%u skip=%u addr=%p", curr->start_address,
|
||||
curr->register_count, curr->get_register_size(), curr->offset, curr->skip_updates, curr);
|
||||
ESP_LOGV(TAG, "Register: 0x%X count=%d size=%zu offset=%u addr=%p", curr->start_address, curr->register_count,
|
||||
curr->get_register_size(), curr->offset, curr);
|
||||
|
||||
const bool custom_size = curr->get_register_size() != static_cast<size_t>(curr->register_count) * 2;
|
||||
|
||||
@@ -296,14 +286,12 @@ void ModbusController::create_polling_commands_() {
|
||||
ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address);
|
||||
} else if (range_shared && !range_forced && curr->start_address >= r.start_address &&
|
||||
curr->start_address + curr->register_count <= r.start_address + r.register_count &&
|
||||
!range_custom_size && !custom_size && curr->skip_updates == r.skip_updates) {
|
||||
!range_custom_size && !custom_size) {
|
||||
// The registers already fall inside a range that a shared-address join widened, so this sensor
|
||||
// reads its slice of that response instead of adding an overlapping second poll. The guards keep
|
||||
// it narrow: only a widened range, never a force-isolated one; only where every register in the
|
||||
// range returns two bytes, so interior positions follow from the addresses; only sensors genuinely
|
||||
// inside it, which is why the lower bound is needed given the walk is not address-ordered; and
|
||||
// only where the polling rates already match, since joining runs this sensor through the rate
|
||||
// merge below and would otherwise change one of them.
|
||||
// inside it, which is why the lower bound is needed given the walk is not address-ordered.
|
||||
const uint16_t addr_delta = curr->start_address - r.start_address;
|
||||
curr->offset = static_cast<uint8_t>((curr->addresses_bits() ? addr_delta : addr_delta * 2) +
|
||||
curr->offset_from_start_address);
|
||||
@@ -329,7 +317,7 @@ void ModbusController::create_polling_commands_() {
|
||||
|
||||
if (!join) {
|
||||
if (have_range) {
|
||||
ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates);
|
||||
ESP_LOGV(TAG, "Add range 0x%X %d", r.start_address, r.register_count);
|
||||
this->create_polling_command_(std::move(r));
|
||||
}
|
||||
r = {};
|
||||
@@ -341,11 +329,7 @@ void ModbusController::create_polling_commands_() {
|
||||
r.start_address = curr->start_address;
|
||||
r.register_count = curr->register_count;
|
||||
r.register_type = curr->register_type;
|
||||
r.skip_updates = curr->skip_updates;
|
||||
have_range = true;
|
||||
} else if (curr->skip_updates != 0) {
|
||||
// use the lowest non-zero skip_updates for the whole range (0 is the default and is excluded)
|
||||
r.skip_updates = (r.skip_updates != 0) ? std::min(r.skip_updates, curr->skip_updates) : curr->skip_updates;
|
||||
}
|
||||
|
||||
// Every member records its range's first register. The resolved offset is relative to it, so the
|
||||
@@ -355,7 +339,7 @@ void ModbusController::create_polling_commands_() {
|
||||
prev = curr;
|
||||
}
|
||||
if (have_range) {
|
||||
ESP_LOGV(TAG, "Add last range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates);
|
||||
ESP_LOGV(TAG, "Add last range 0x%X %d", r.start_address, r.register_count);
|
||||
this->create_polling_command_(std::move(r));
|
||||
}
|
||||
// Reclaim growth slack; safe here because nothing has registered with the hub yet (see the
|
||||
@@ -380,8 +364,8 @@ void ModbusController::dump_config() {
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, "ranges");
|
||||
for (auto &it : this->polling_command_items_) {
|
||||
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast<uint8_t>(it.register_type()),
|
||||
it.register_address(), it.register_count(), it.skip_updates);
|
||||
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d", static_cast<uint8_t>(it.register_type()),
|
||||
it.register_address(), it.register_count());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -513,17 +497,19 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
|
||||
|
||||
bool ModbusCommandItem::send() {
|
||||
bool accepted;
|
||||
if (this->function_code_ != FunctionCode::CUSTOM) {
|
||||
if (this->custom_pdu_ != nullptr) {
|
||||
// Custom polling command: send the sensor's ready-made PDU (function code + data, no address byte)
|
||||
// to this controller's own device address; the hub prepends the address and appends the CRC.
|
||||
accepted = modbus::ModbusClientDevice::queue_pdu(std::span<const uint8_t>(*this->custom_pdu_));
|
||||
} else if (this->function_code_ != FunctionCode::CUSTOM) {
|
||||
accepted = this->queue_pdu(modbus::helpers::create_client_pdu(
|
||||
this->function_code_, this->start_address_, this->register_count_,
|
||||
this->payload.empty() ? nullptr : this->payload.data(), this->payload.size()));
|
||||
} else {
|
||||
// Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own
|
||||
// address (which may differ from this controller's); the hub appends the CRC and routes the response
|
||||
// back to this item by pointer. (send_raw() is deprecated, so queue_pdu() is called with the extracted
|
||||
// address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.)
|
||||
std::span<const uint8_t> frame =
|
||||
this->custom_data_ != nullptr ? std::span<const uint8_t>(*this->custom_data_) : this->payload;
|
||||
// Factory custom command: payload holds a complete raw frame (address + PDU). Send the PDU to the
|
||||
// frame's own address (which may differ from this controller's); the hub appends the CRC and routes
|
||||
// the response back to this item by pointer.
|
||||
std::span<const uint8_t> frame = this->payload;
|
||||
if (frame.empty()) {
|
||||
ESP_LOGW(TAG, "Empty custom command frame, not sent");
|
||||
accepted = false;
|
||||
|
||||
@@ -157,7 +157,7 @@ class SensorItem {
|
||||
this->range_start_address = address;
|
||||
}
|
||||
|
||||
void set_custom_data(const std::vector<uint8_t> &data) { custom_data = data; }
|
||||
void set_custom_pdu(std::initializer_list<uint8_t> pdu) { this->custom_pdu.set(pdu.begin(), pdu.size()); }
|
||||
size_t virtual get_register_size() const {
|
||||
if (this->addresses_bits()) {
|
||||
return 1;
|
||||
@@ -186,8 +186,7 @@ class SensorItem {
|
||||
uint8_t offset_from_start_address{0};
|
||||
/// First register of the range this sensor is polled in; equals start_address for an unpolled item.
|
||||
uint16_t range_start_address{0};
|
||||
uint16_t skip_updates{0};
|
||||
std::vector<uint8_t> custom_data{};
|
||||
SmallInlineBuffer<8> custom_pdu{};
|
||||
bool force_new_range{false};
|
||||
};
|
||||
|
||||
@@ -230,8 +229,7 @@ struct RegisterRange {
|
||||
uint16_t start_address;
|
||||
modbus::EntityType register_type;
|
||||
uint8_t register_count;
|
||||
uint16_t skip_updates; // the config value
|
||||
SensorSet sensors; // all sensors of this range
|
||||
SensorSet sensors; // all sensors of this range
|
||||
};
|
||||
|
||||
/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub
|
||||
@@ -257,7 +255,6 @@ class ModbusCommandItem : public modbus::ModbusClientDevice {
|
||||
ModbusCommandItem &operator=(ModbusCommandItem &&) = delete;
|
||||
|
||||
SensorSet sensors; // sensors served by this command (empty for factory/write commands)
|
||||
uint16_t skip_updates{0};
|
||||
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> on_data_func;
|
||||
/// Write data bytes for the command (register/coil values), or the raw frame of a one-shot custom
|
||||
/// command; reads leave it empty. Small-buffer optimized: fixed-size commands (single-register/coil
|
||||
@@ -378,7 +375,7 @@ class ModbusCommandItem : public modbus::ModbusClientDevice {
|
||||
uint16_t register_count_{0};
|
||||
FunctionCode function_code_{FunctionCode::CUSTOM};
|
||||
/// Custom polling commands reference the PDU bytes owned by their SensorItem instead of copying them.
|
||||
const std::vector<uint8_t> *custom_data_{nullptr};
|
||||
const SmallInlineBuffer<8> *custom_pdu_{nullptr};
|
||||
ModbusController *controller_{nullptr};
|
||||
};
|
||||
|
||||
@@ -462,19 +459,15 @@ class ModbusController final : public PollingComponent {
|
||||
void create_polling_commands_();
|
||||
/// build one persistent polling command from a range and add it to polling_command_items_
|
||||
void create_polling_command_(RegisterRange &&range) {
|
||||
// A custom range polls the first sensor's custom_data (a ready-made raw frame); it needs the
|
||||
// sensor constructor so the command references those bytes and decodes the real function code.
|
||||
// The response still dispatches to every sensor in the range.
|
||||
// A custom range polls the first sensor's custom_pdu (referenced, not copied); the sensor constructor
|
||||
// decodes the real function code. The response still dispatches to every sensor in the range.
|
||||
if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) {
|
||||
auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin());
|
||||
cmd.sensors = std::move(range.sensors);
|
||||
cmd.skip_updates = range.skip_updates; // the range's merged rate, not the first sensor's
|
||||
} else {
|
||||
this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range));
|
||||
}
|
||||
}
|
||||
/// send a range's polling command if it is due this update
|
||||
void update_range_(ModbusCommandItem &cmd);
|
||||
/// The hub this controller's commands/entities send through, and the modbus address they target.
|
||||
modbus::ModbusClientHub *hub_{nullptr};
|
||||
uint8_t address_{0};
|
||||
@@ -496,7 +489,7 @@ class ModbusController final : public PollingComponent {
|
||||
bool module_offline_{false};
|
||||
/// update_counter_ value at which the module went offline (for offline_skip_updates timing)
|
||||
uint16_t module_offline_at_{0};
|
||||
/// counts update() cycles; drives skip_updates and offline timing
|
||||
/// counts update() cycles; drives the offline-retry cadence
|
||||
uint16_t update_counter_{0};
|
||||
/// consecutive non-responses; drives can_send() and offline detection
|
||||
uint8_t cmd_non_responses_{0};
|
||||
|
||||
@@ -18,16 +18,17 @@ from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
SensorItem,
|
||||
add_modbus_base_properties,
|
||||
migrate_custom_command,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_BITMASK,
|
||||
CONF_CUSTOM_COMMAND,
|
||||
CONF_CUSTOM_PDU,
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
CONF_REGISTER_TYPE,
|
||||
CONF_SKIP_UPDATES,
|
||||
CONF_USE_WRITE_MULTIPLE,
|
||||
CONF_VALUE_TYPE,
|
||||
CONF_WRITE_LAMBDA,
|
||||
@@ -53,9 +54,11 @@ def validate_min_max(config):
|
||||
|
||||
|
||||
def validate_modbus_number(config):
|
||||
if CONF_CUSTOM_COMMAND not in config and CONF_ADDRESS not in config:
|
||||
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate).
|
||||
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_COMMAND}:' isn't used"
|
||||
f" {CONF_ADDRESS} is a required property if '{CONF_CUSTOM_PDU}:' isn't used"
|
||||
)
|
||||
return config
|
||||
|
||||
@@ -83,6 +86,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_modbus_number,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = migrate_custom_command
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
@@ -94,7 +99,6 @@ async def to_code(config):
|
||||
config[CONF_BITMASK],
|
||||
config[CONF_VALUE_TYPE],
|
||||
reg_count,
|
||||
config[CONF_SKIP_UPDATES],
|
||||
config[CONF_FORCE_NEW_RANGE],
|
||||
)
|
||||
|
||||
|
||||
@@ -13,14 +13,13 @@ using value_to_data_t = std::function<float>(float);
|
||||
class ModbusNumber final : public number::Number, public Component, public SensorItem {
|
||||
public:
|
||||
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) {
|
||||
SensorValueType value_type, int register_count, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->bitmask = bitmask;
|
||||
this->sensor_value_type = value_type;
|
||||
this->register_count = register_count;
|
||||
this->skip_updates = skip_updates;
|
||||
this->force_new_range = force_new_range;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from .. import (
|
||||
)
|
||||
from ..const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
CONF_CUSTOM_PDU,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
CONF_REGISTER_TYPE,
|
||||
CONF_USE_WRITE_MULTIPLE,
|
||||
@@ -37,8 +38,11 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ModbusBinaryOutput),
|
||||
cv.Required(CONF_ADDRESS): cv.positive_int,
|
||||
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
|
||||
"custom_pdu is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
|
||||
"custom_command is not supported for outputs"
|
||||
"custom_command is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
@@ -48,8 +52,11 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
|
||||
cv.Required(CONF_ADDRESS): cv.positive_int,
|
||||
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
|
||||
"custom_pdu is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
|
||||
"custom_command is not supported for outputs"
|
||||
"custom_command is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
|
||||
SENSOR_VALUE_TYPE
|
||||
|
||||
@@ -17,7 +17,6 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->register_count = register_count;
|
||||
this->sensor_value_type = value_type;
|
||||
this->skip_updates = 0;
|
||||
this->set_address(this->start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
@@ -48,7 +47,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component,
|
||||
this->set_address(start_address);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->skip_updates = 0;
|
||||
this->register_count = 1;
|
||||
this->set_address(this->start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
|
||||
@@ -4,7 +4,12 @@ from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_M
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
|
||||
|
||||
from .. import ModbusController, SensorItem, modbus_controller_ns
|
||||
from .. import (
|
||||
ModbusController,
|
||||
SensorItem,
|
||||
modbus_controller_ns,
|
||||
validate_skip_updates_deprecated,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
@@ -69,7 +74,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
INTEGER_SENSOR_VALUE_TYPE
|
||||
),
|
||||
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
|
||||
cv.Optional(CONF_SKIP_UPDATES, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_SKIP_UPDATES): validate_skip_updates_deprecated,
|
||||
cv.Optional(CONF_FORCE_NEW_RANGE, default=False): cv.boolean,
|
||||
cv.Required(CONF_OPTIONSMAP): ensure_option_map(),
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
@@ -95,7 +100,6 @@ async def to_code(config):
|
||||
value_type,
|
||||
config[CONF_ADDRESS],
|
||||
reg_count,
|
||||
config[CONF_SKIP_UPDATES],
|
||||
config[CONF_FORCE_NEW_RANGE],
|
||||
list(options_map.values()),
|
||||
)
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSelect final : public Component, public select::Select, public SensorItem {
|
||||
public:
|
||||
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates,
|
||||
bool force_new_range, std::vector<int64_t> mapping) {
|
||||
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range,
|
||||
std::vector<int64_t> mapping) {
|
||||
this->register_type = modbus::EntityType::HOLDING; // not configurable
|
||||
this->sensor_value_type = sensor_value_type;
|
||||
this->set_address(start_address);
|
||||
@@ -20,7 +20,6 @@ class ModbusSelect final : public Component, public select::Select, public Senso
|
||||
this->bitmask = 0xFFFFFFFF; // not configurable
|
||||
this->register_count = register_count;
|
||||
this->response_bytes = 0; // not configurable
|
||||
this->skip_updates = skip_updates;
|
||||
this->force_new_range = force_new_range;
|
||||
this->mapping_ = std::move(mapping);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
SensorItem,
|
||||
add_modbus_base_properties,
|
||||
migrate_custom_command,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
validate_modbus_register,
|
||||
@@ -18,7 +19,6 @@ from ..const import (
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
CONF_REGISTER_COUNT,
|
||||
CONF_REGISTER_TYPE,
|
||||
CONF_SKIP_UPDATES,
|
||||
CONF_VALUE_TYPE,
|
||||
)
|
||||
|
||||
@@ -44,6 +44,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_modbus_register,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = migrate_custom_command
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
@@ -56,7 +58,6 @@ async def to_code(config):
|
||||
config[CONF_BITMASK],
|
||||
value_type,
|
||||
reg_count,
|
||||
config[CONF_SKIP_UPDATES],
|
||||
config[CONF_FORCE_NEW_RANGE],
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -11,14 +11,13 @@ namespace esphome::modbus_controller {
|
||||
class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem {
|
||||
public:
|
||||
ModbusSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) {
|
||||
SensorValueType value_type, int register_count, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->bitmask = bitmask;
|
||||
this->sensor_value_type = value_type;
|
||||
this->register_count = register_count;
|
||||
this->skip_updates = skip_updates;
|
||||
this->force_new_range = force_new_range;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
SensorItem,
|
||||
add_modbus_base_properties,
|
||||
migrate_custom_command,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
validate_modbus_register,
|
||||
@@ -17,7 +18,6 @@ from ..const import (
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
CONF_REGISTER_TYPE,
|
||||
CONF_SKIP_UPDATES,
|
||||
CONF_USE_WRITE_MULTIPLE,
|
||||
CONF_WRITE_LAMBDA,
|
||||
)
|
||||
@@ -45,6 +45,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_modbus_register,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = migrate_custom_command
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
byte_offset, _ = modbus_calc_properties(config)
|
||||
@@ -54,7 +56,6 @@ async def to_code(config):
|
||||
config[CONF_ADDRESS],
|
||||
byte_offset,
|
||||
config[CONF_BITMASK],
|
||||
config[CONF_SKIP_UPDATES],
|
||||
config[CONF_FORCE_NEW_RANGE],
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -11,13 +11,12 @@ namespace esphome::modbus_controller {
|
||||
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem {
|
||||
public:
|
||||
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
uint16_t skip_updates, bool force_new_range) {
|
||||
bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->bitmask = bitmask;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->skip_updates = skip_updates;
|
||||
this->register_count = 1;
|
||||
if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) {
|
||||
this->set_address(this->start_address + offset);
|
||||
|
||||
@@ -8,6 +8,7 @@ from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
SensorItem,
|
||||
add_modbus_base_properties,
|
||||
migrate_custom_command,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
validate_modbus_register,
|
||||
@@ -19,7 +20,6 @@ from ..const import (
|
||||
CONF_REGISTER_COUNT,
|
||||
CONF_REGISTER_TYPE,
|
||||
CONF_RESPONSE_SIZE,
|
||||
CONF_SKIP_UPDATES,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["modbus_controller"]
|
||||
@@ -55,6 +55,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_modbus_register,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = migrate_custom_command
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
@@ -70,7 +72,6 @@ async def to_code(config):
|
||||
reg_count,
|
||||
config[CONF_RESPONSE_SIZE],
|
||||
config[CONF_RAW_ENCODE],
|
||||
config[CONF_SKIP_UPDATES],
|
||||
config[CONF_FORCE_NEW_RANGE],
|
||||
)
|
||||
|
||||
|
||||
@@ -13,14 +13,13 @@ enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 };
|
||||
class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem {
|
||||
public:
|
||||
ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count,
|
||||
uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) {
|
||||
uint16_t response_bytes, RawEncoding encode, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->response_bytes = response_bytes;
|
||||
this->register_count = register_count;
|
||||
this->encode_ = encode;
|
||||
this->skip_updates = skip_updates;
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->sensor_value_type = SensorValueType::RAW;
|
||||
this->force_new_range = force_new_range;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Schema-level 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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components.modbus_controller import ModbusItemBaseSchema
|
||||
from esphome.components.modbus_controller.const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
CONF_CUSTOM_PDU,
|
||||
)
|
||||
|
||||
|
||||
def test_custom_command_accepted_at_schema_level() -> None:
|
||||
"""custom_command validates at the schema level; migration/rejection happens in final validate."""
|
||||
config = ModbusItemBaseSchema(
|
||||
{CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]}
|
||||
)
|
||||
assert config[CONF_CUSTOM_COMMAND] == [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]
|
||||
|
||||
|
||||
def test_custom_pdu_and_custom_command_mutually_exclusive() -> None:
|
||||
"""Only one custom source may be given; supplying both is a schema error."""
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
ModbusItemBaseSchema(
|
||||
{
|
||||
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_custom_pdu_accepted() -> None:
|
||||
"""The new key takes PDU bytes (function code + data, no address byte)."""
|
||||
config = ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01]})
|
||||
assert config[CONF_CUSTOM_PDU] == [0x03, 0x00, 0x2A, 0x00, 0x01]
|
||||
|
||||
|
||||
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]})
|
||||
@@ -108,6 +108,22 @@ select:
|
||||
return value;
|
||||
|
||||
sensor:
|
||||
# custom_pdu polls a ready-made PDU (function code + data - no device address byte, no CRC); covers
|
||||
# the set_custom_pdu codegen path and the custom-range polling constructor.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor_custom_pdu
|
||||
name: Test Custom PDU Sensor
|
||||
custom_pdu: [0x03, 0x00, 0x2A, 0x00, 0x01]
|
||||
value_type: U_WORD
|
||||
# Deprecated custom_command (leading byte 0x02 == modbus_controller1's address) drives the
|
||||
# migrate_custom_command final-validate auto-migration path in CI.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor_custom_command
|
||||
name: Test Custom Command Sensor
|
||||
custom_command: [0x02, 0x03, 0x00, 0x2B, 0x00, 0x01]
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor1
|
||||
|
||||
+6
-5
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-custom-command
|
||||
name: uart-mock-modbus-custom-pdu
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -69,13 +69,14 @@ sensor:
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom command: a raw frame {device address, function code, address hi, address lo,
|
||||
# count hi, count lo}; the CRC is appended by the hub. Reads holding register 0x0001,
|
||||
# count 1; the lambda parses the response payload (the register value, big-endian).
|
||||
# Custom PDU: read holding register 0x0001, count 1. The PDU is
|
||||
# {function code, address hi, address lo, count hi, count lo}; the device
|
||||
# address and CRC are added by the hub. The lambda parses the response payload
|
||||
# (the register value, big-endian).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_command: [0x01, 0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
@@ -134,7 +134,8 @@ sensor:
|
||||
value_type: U_WORD
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
|
||||
# F - contiguous registers where the second asks for a slower rate.
|
||||
# F - contiguous registers that historically carried differing skip_updates rates and were split by
|
||||
# the rate merge; with per-range rates gone they group like any contiguous pair.
|
||||
- platform: modbus_controller
|
||||
name: "rate_first"
|
||||
address: 0x150
|
||||
@@ -146,7 +147,6 @@ sensor:
|
||||
address: 0x151
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
skip_updates: 5
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
|
||||
# B - a wide value and one of its halves share a start address, with a contiguous sensor after them.
|
||||
|
||||
@@ -51,9 +51,8 @@ modbus_controller:
|
||||
modbus_id: virtual_modbus_client
|
||||
id: ctl
|
||||
max_cmd_retries: 1
|
||||
# offline_skip_updates and the sensor's skip_updates deliberately share a period: offline
|
||||
# probing must follow the offline cadence alone, or phase combinations like this one can
|
||||
# leave the device never probing again.
|
||||
# offline_skip_updates: 1 -> once offline, the controller re-probes every second update cycle;
|
||||
# the test silences the device to force it offline, then answers again and checks it recovers.
|
||||
offline_skip_updates: 1
|
||||
update_interval: never
|
||||
on_offline:
|
||||
@@ -71,7 +70,6 @@ sensor:
|
||||
address: 0x03
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
skip_updates: 1
|
||||
# Mirrors the controller's online state so the test can await the transitions.
|
||||
- platform: template
|
||||
name: link_state
|
||||
|
||||
@@ -49,14 +49,10 @@ uart_mock:
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x41, 0x79, 0xE4] # 0x101 = 0x0141 = 321
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x03, 0x00, 0x01, 0x75, 0xF6] # Read holding 0x103 count 1
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x01, 0xA5, 0x79, 0xAF] # 0x103 = 0x01A5 = 421
|
||||
# A widened shared-address range at 0x200 plus a sensor at 0x201 carrying its own skip_updates.
|
||||
# The sensor must keep its own range so the polling rates stay independent; if it were folded into
|
||||
# the widened range it would decode 0x201 from THAT response (2, not 777) and drag the range's
|
||||
# rate down to its own.
|
||||
# Two sensors sharing start address 0x200 (a word and a dword) widen the range to 2 registers
|
||||
# and both decode from the single response.
|
||||
- expect_tx: [0x01, 0x03, 0x02, 0x00, 0x00, 0x02, 0xC5, 0xB3] # Read holding 0x200 count 2
|
||||
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x41, 0x00, 0x02, 0x2A, 0x1A] # 0x200=0x0141, 0x201=0x0002
|
||||
- expect_tx: [0x01, 0x03, 0x02, 0x01, 0x00, 0x01, 0xD4, 0x72] # Read holding 0x201 count 1
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x03, 0x09, 0x78, 0xB2] # 0x201 = 0x0309 = 777
|
||||
|
||||
modbus:
|
||||
uart_id: virtual_uart_dev
|
||||
@@ -128,26 +124,17 @@ sensor:
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
# Shared address 0x200: the dword widens the range the word opened (or vice versa)
|
||||
- platform: modbus_controller
|
||||
name: "rate_word"
|
||||
name: "widen_word"
|
||||
address: 0x200
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
- platform: modbus_controller
|
||||
name: "rate_dword"
|
||||
name: "widen_dword"
|
||||
address: 0x200
|
||||
register_type: holding
|
||||
value_type: U_DWORD
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
# Inside the widened range but with its own skip_updates: must NOT be folded in, or the two sensors
|
||||
# above would silently drop to this sensor's polling rate
|
||||
- platform: modbus_controller
|
||||
name: "own_rate"
|
||||
address: 0x201
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
skip_updates: 100
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
|
||||
@@ -675,9 +675,7 @@ async def test_uart_mock_modbus_shared_address(
|
||||
wide sensor's span keep polling separately, and that the sensor at the span's tail address does not
|
||||
anchor a re-use join on a mid-range predecessor (which would make it decode that sensor's bytes).
|
||||
|
||||
A sensor at 0x201 carrying skip_updates sits inside a widened shared-address range at 0x200 but
|
||||
keeps its own range, so polling rates stay independent; folding it in would also make it decode
|
||||
0x201 out of the shared response (2) instead of its own poll (777).
|
||||
A word and a dword sharing 0x200 widen that range to two registers and both decode from the one read.
|
||||
"""
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
@@ -693,9 +691,8 @@ async def test_uart_mock_modbus_shared_address(
|
||||
"wide_qword": 100,
|
||||
"inside_wide": 321,
|
||||
"tail_of_wide": 421,
|
||||
"rate_word": 321,
|
||||
"rate_dword": pytest.approx(21037058),
|
||||
"own_rate": 777,
|
||||
"widen_word": 321,
|
||||
"widen_dword": pytest.approx(21037058),
|
||||
}
|
||||
tracker = SensorTracker(list(expected_values.keys()))
|
||||
futures = tracker.expect_all(expected_values)
|
||||
@@ -710,18 +707,18 @@ async def test_uart_mock_modbus_shared_address(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_custom_command(
|
||||
async def test_uart_mock_modbus_custom_pdu(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test a custom_command sensor polling a register served by the mock server.
|
||||
"""Test a custom_pdu sensor reading a register served by the mock server.
|
||||
|
||||
The custom_command is a raw frame (device address + PDU); the hub appends the CRC and
|
||||
routes the response back to the polling command, whose sensor lambda parses the payload.
|
||||
Guards the custom polling wiring: the command must reference the sensor's custom_data and
|
||||
decode the real function code, or nothing is ever transmitted. A plain read on the same
|
||||
register anchors the bus.
|
||||
The custom_pdu is a raw read-holding PDU (function code + address + count); the
|
||||
controller prepends its own device address and appends the CRC, sends it, and the
|
||||
sensor's lambda parses the response payload. Confirms the custom PDU path decodes
|
||||
the function code and routes the response to the sensor (the gap that hid the
|
||||
step-2 raw-vs-PDU bug). A plain read on the same register anchors the bus.
|
||||
"""
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
@@ -754,9 +751,8 @@ async def test_uart_mock_modbus_offline(
|
||||
publishes. This pins the pooled non-response counter, can_send() gating, the
|
||||
offline retry cadence, and recovery - none of which the responding-path tests touch.
|
||||
|
||||
The fixture gives offline_skip_updates and the sensor's skip_updates the same period
|
||||
on purpose: offline probing must follow the offline cadence alone, since requiring
|
||||
both cadences to coincide leaves phase combinations where no probe ever goes out.
|
||||
Offline probing follows the offline cadence alone; regular every-update polling
|
||||
resumes once the device answers again.
|
||||
"""
|
||||
|
||||
tracker = SensorTracker(["link_state", "reg"])
|
||||
|
||||
Reference in New Issue
Block a user