[modbus_controller] Rename custom_command to custom_pdu; reject address 0; retire skip_updates (#18652)

This commit is contained in:
Bonne Eggleston
2026-08-23 19:33:53 -05:00
committed by GitHub
parent d75f85b089
commit 1ceaf699c2
26 changed files with 315 additions and 161 deletions
+110 -11
View File
@@ -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;