diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b04df1923ff..76056ed3e8a 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -220,6 +220,26 @@ inline bool value_type_is_float(SensorValueType v) { return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; } +/// Number of 16-bit registers a value of this type occupies (RAW counts as one register). +inline uint16_t register_width_for(SensorValueType v) { + switch (v) { + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32: + case SensorValueType::FP32_R: + return 2; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + return 4; + default: + return 1; + } +} + /// Coils and discrete inputs are the bit-addressed entity tables; the other types are 16-bit registers. inline bool is_entity_type_binary(EntityType type) { return type == EntityType::COIL || type == EntityType::DISCRETE_INPUT; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index c390d8ab799..f888cc060e3 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -41,6 +41,7 @@ from .const import ( CONF_REGISTER_COUNT, CONF_REGISTER_TYPE, CONF_RESPONSE_SIZE, + CONF_REUSE_PREVIOUS_RANGE, CONF_SERVER_COURTESY_RESPONSE, CONF_SERVER_REGISTERS, CONF_SKIP_UPDATES, @@ -60,6 +61,13 @@ ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingCom SensorItem = modbus_controller_ns.struct("SensorItem") +RangeReuse = modbus_controller_ns.enum("RangeReuse", is_class=True) +RANGE_REUSE = { + "auto": RangeReuse.AUTO, + True: RangeReuse.ALWAYS, + False: RangeReuse.NEVER, +} + _LOGGER = logging.getLogger(__name__) @@ -184,13 +192,88 @@ ModbusItemBaseSchema = cv.Schema( ): cv.positive_int, cv.Optional(CONF_BITMASK, default=0xFFFFFFFF): cv.hex_uint32_t, cv.Optional(CONF_SKIP_UPDATES): validate_skip_updates_deprecated, - cv.Optional(CONF_FORCE_NEW_RANGE, default=False): cv.boolean, + cv.Optional(CONF_REUSE_PREVIOUS_RANGE, default="auto"): cv.Any( + cv.boolean, cv.one_of("auto", lower=True) + ), + # Deprecated options, migrated by validate_range_reuse_migration(). Remove before 2027.3.0 + cv.Optional(CONF_FORCE_NEW_RANGE): cv.boolean, + cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Optional(CONF_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_RESPONSE_SIZE, default=0): cv.positive_int, + cv.Optional(CONF_RESPONSE_SIZE, default=0): cv.int_range(min=0, max=250), }, ) +def _derived_register_widths(config: ConfigType) -> set[int]: + """Register widths an item derives on its own; a matching register_count is redundant.""" + response_size = config.get(CONF_RESPONSE_SIZE, 0) + if (value_type := config.get(CONF_VALUE_TYPE)) is not None: + widths = {TYPE_REGISTER_MAP[value_type]} + if value_type == "RAW" and response_size > 0: + widths.add((response_size + 1) // 2) + return widths + if response_size > 0: + # text sensors: the old default was floor(response_size / 2); the derived width is now ceil + return {response_size // 2, (response_size + 1) // 2} + return {1} + + +def entity_label(config: ConfigType) -> str: + """The entity's name or id, so migration messages say which entry to edit.""" + label = config.get(CONF_NAME) or config.get(CONF_ID) + return str(label) if label is not None else "" + + +# Remove before 2027.3.0 +def validate_range_reuse_migration(config: ConfigType) -> ConfigType: + """Migrate the removed force_new_range/register_count options to reuse_previous_range.""" + if (force_new_range := config.pop(CONF_FORCE_NEW_RANGE, None)) is not None: + if config[CONF_REUSE_PREVIOUS_RANGE] != "auto": + raise cv.Invalid( + f"'{CONF_FORCE_NEW_RANGE}' and '{CONF_REUSE_PREVIOUS_RANGE}' can't be used together; " + f"remove '{CONF_FORCE_NEW_RANGE}'" + ) + if force_new_range: + _LOGGER.warning( + "%s: '%s' is deprecated; '%s: false' replaces it but only stops this entity joining " + "the PREVIOUS range - set it on the following entity too if the range must stay " + "isolated. Removed in 2027.3.0", + entity_label(config), + CONF_FORCE_NEW_RANGE, + CONF_REUSE_PREVIOUS_RANGE, + ) + config[CONF_REUSE_PREVIOUS_RANGE] = False + else: + _LOGGER.warning( + "%s: '%s: false' has no effect; remove it. Removed in 2027.3.0", + entity_label(config), + CONF_FORCE_NEW_RANGE, + ) + if (register_count := config.pop(CONF_REGISTER_COUNT, None)) is not None: + if ( + register_count not in _derived_register_widths(config) + and register_count != 0 + ): + raise cv.Invalid( + f"'{CONF_REGISTER_COUNT}' has been removed; the number of registers to read is now " + f"derived from '{CONF_VALUE_TYPE}' (or '{CONF_RESPONSE_SIZE}' for RAW values and text " + f"sensors). To make one request span extra registers up to the next sensor, set " + f"'{CONF_REUSE_PREVIOUS_RANGE}: true' on the NEXT sensor instead; for RAW or text block " + f"reads set '{CONF_RESPONSE_SIZE}' to the byte count; to force multi-register writes set " + f"'use_write_multiple: true'. See " + "https://esphome.io/components/modbus_controller/" + ) + _LOGGER.warning( + "%s: '%s' is now derived from '%s' (or '%s' for RAW values and text sensors) and has no " + "effect; remove it. Removed in 2027.3.0", + entity_label(config), + CONF_REGISTER_COUNT, + CONF_VALUE_TYPE, + CONF_RESPONSE_SIZE, + ) + return config + + def validate_modbus_register(config: ConfigType) -> ConfigType: # custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat # either as "a custom frame is configured" so the address/register_type rules match. @@ -293,20 +376,13 @@ def reject_odd_holding_write_offset(config: ConfigType) -> ConfigType: return config -def modbus_calc_properties(config: ConfigType) -> tuple[int, int]: +def modbus_calc_properties(config: ConfigType) -> int: byte_offset = 0 - reg_count = 0 if CONF_OFFSET in config: byte_offset = config[CONF_OFFSET] # A CONF_BYTE_OFFSET setting overrides CONF_OFFSET if CONF_BYTE_OFFSET in config: byte_offset = config[CONF_BYTE_OFFSET] - if CONF_REGISTER_COUNT in config: - reg_count = config[CONF_REGISTER_COUNT] - if CONF_VALUE_TYPE in config: - value_type = config[CONF_VALUE_TYPE] - if reg_count == 0: - reg_count = TYPE_REGISTER_MAP[value_type] if CONF_CUSTOM_PDU in config: if CONF_ADDRESS not in config: # generate a unique modbus address using the hash of the name @@ -317,8 +393,7 @@ def modbus_calc_properties(config: ConfigType) -> tuple[int, int]: value = value.encode() config[CONF_ADDRESS] = binascii.crc_hqx(value, 0) config[CONF_REGISTER_TYPE] = cv.enum(MODBUS_REGISTER_TYPE)("custom") - config[CONF_FORCE_NEW_RANGE] = True - return byte_offset, reg_count + return byte_offset async def add_modbus_base_properties( diff --git a/esphome/components/modbus_controller/binary_sensor/__init__.py b/esphome/components/modbus_controller/binary_sensor/__init__.py index 366dab60626..32247b4cece 100644 --- a/esphome/components/modbus_controller/binary_sensor/__init__.py +++ b/esphome/components/modbus_controller/binary_sensor/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( + RANGE_REUSE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, @@ -12,12 +13,13 @@ from .. import ( modbus_controller_ns, validate_custom_pdu_item, validate_modbus_register, + validate_range_reuse_migration, ) from ..const import ( CONF_BITMASK, - CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, CONF_REGISTER_TYPE, + CONF_REUSE_PREVIOUS_RANGE, ) DEPENDENCIES = ["modbus_controller"] @@ -38,20 +40,21 @@ CONFIG_SCHEMA = cv.All( } ), validate_modbus_register, + validate_range_reuse_migration, ) FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item async def to_code(config): - byte_offset, _ = modbus_calc_properties(config) + byte_offset = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], config[CONF_ADDRESS], byte_offset, config[CONF_BITMASK], - config[CONF_FORCE_NEW_RANGE], + RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]], ) await cg.register_component(var, config) await binary_sensor.register_binary_sensor(var, config) diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index f5ddbd82ccd..a6b5bc4ef98 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -11,19 +11,22 @@ 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, - bool force_new_range) { + RangeReuse reuse_previous_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->force_new_range = force_new_range; + this->reuse_previous_range = reuse_previous_range; + } - if (modbus::helpers::is_entity_type_binary(register_type)) { - this->register_count = offset + 1; - } else { - this->register_count = 1; + /// On the bit-addressed tables the bit sits at start_address + offset, so the read must span offset + 1 + /// bits. Uses the offset as configured: `offset` itself is overwritten with the position in the range. + uint16_t entity_count() const override { + if (modbus::helpers::is_entity_type_binary(this->register_type)) { + return this->offset_from_start_address + 1; } + return 1; } void parse_and_publish(std::span data) override; diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index 8412a651b86..364a0a510e8 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -18,6 +18,7 @@ CONF_REGISTER_LAST_ADDRESS = "register_last_address" CONF_REGISTER_TYPE = "register_type" CONF_REGISTER_VALUE = "register_value" CONF_RESPONSE_SIZE = "response_size" +CONF_REUSE_PREVIOUS_RANGE = "reuse_previous_range" CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 8801c33d8c0..c7fc10a0bb0 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include +#include namespace esphome::modbus_controller { @@ -137,7 +138,7 @@ ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::Modbu SensorItem *sensor) : modbus::ModbusClientDevice(parent, address), start_address_(sensor->start_address), - register_count_(sensor->register_count), + register_count_(sensor->entity_count()), 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 @@ -350,129 +351,176 @@ void ModbusController::update() { } // walk through the sensors and determine the register ranges to read +namespace { + +class RangeBuilder { + public: + explicit RangeBuilder(FixedVector &ranges) : ranges_(ranges) {} + + bool can_join(const SensorItem *curr) const { + return this->have_range_ && curr->reuse_previous_range != RangeReuse::NEVER && + this->r_.register_type == curr->register_type && curr->register_type != modbus::EntityType::CUSTOM; + } + + // A sensor that joined mid-range must never anchor this - hence both address tests. + bool try_reuse_register(SensorItem *curr) { + const uint32_t range_end = this->range_end_(); + if (curr->start_address != range_end - this->prev_->entity_count() || + this->prev_->start_address + this->prev_->entity_count() != range_end || + curr->entity_count() != this->prev_->entity_count() || + curr->get_register_size() != this->prev_->get_register_size()) { + return false; + } + if (!place_offset(curr, static_cast(this->prev_->offset) + curr->offset_from_start_address)) + return false; + ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address); + return true; + } + + bool try_extend(SensorItem *curr) { + const uint32_t range_end = this->range_end_(); + const bool reachable = + curr->reuse_previous_range == RangeReuse::ALWAYS + ? curr->start_address >= range_end + : curr->start_address == range_end && (curr->addresses_bits() || !this->range_custom_size_); + if (!reachable) + return false; + const uint16_t gap = static_cast(curr->start_address - range_end); + const uint32_t new_count = this->r_.register_count + gap + curr->entity_count(); + const uint16_t max_quantity = + curr->addresses_bits() ? modbus::MAX_NUM_OF_COILS_TO_READ : modbus::MAX_NUM_OF_REGISTERS_TO_READ; + const uint32_t prospective_offset = + (curr->addresses_bits() ? static_cast(curr->start_address - this->r_.start_address) + : static_cast(this->range_bytes_) + gap * 2) + + curr->offset_from_start_address; + if (new_count > max_quantity || !place_offset(curr, prospective_offset)) { + return false; + } + if (!curr->addresses_bits()) + this->range_bytes_ += static_cast(gap) * 2; + this->range_bytes_ += curr->get_register_size(); + this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr); + this->r_.register_count = static_cast(new_count); + ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address); + return true; + } + + bool try_cover(SensorItem *curr) { + if (!this->range_shared_ || this->range_forced_ || curr->start_address < this->r_.start_address || + curr->start_address + curr->entity_count() > this->range_end_() || this->range_custom_size_ || + has_custom_size(curr)) { + return false; + } + const uint32_t addr_delta = curr->start_address - this->r_.start_address; + if (!place_offset(curr, (curr->addresses_bits() ? addr_delta : addr_delta * 2) + curr->offset_from_start_address)) + return false; + ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, this->r_.start_address); + return true; + } + + // A response dispatches to a single range per (start address, register type), so same-address items + // must share - even reuse_previous_range: false and custom entities. + bool try_share(SensorItem *curr) { + if (!this->have_range_ || this->r_.register_type != curr->register_type || + this->r_.start_address != curr->start_address) { + return false; + } + curr->offset = curr->offset_from_start_address; + this->r_.register_count = std::max(this->r_.register_count, curr->entity_count()); + this->range_bytes_ = std::max(this->range_bytes_, curr->get_register_size()); + this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr); + this->range_shared_ = true; + this->range_forced_ = this->range_forced_ || curr->reuse_previous_range == RangeReuse::NEVER; + ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address); + return true; + } + + bool always_declined(const SensorItem *curr) const { + return this->have_range_ && curr->reuse_previous_range == RangeReuse::ALWAYS && + this->r_.register_type == curr->register_type && curr->start_address != this->r_.start_address; + } + + void open(SensorItem *curr) { + this->close(); + this->r_ = {}; + this->range_bytes_ = curr->get_register_size(); + this->range_custom_size_ = has_custom_size(curr); + this->range_forced_ = curr->reuse_previous_range == RangeReuse::NEVER; + this->range_shared_ = false; + curr->offset = curr->offset_from_start_address; + this->r_.start_address = curr->start_address; + this->r_.register_count = curr->entity_count(); + this->r_.register_type = curr->register_type; + if (curr->register_type == modbus::EntityType::CUSTOM) + this->r_.custom_pdu = &curr->custom_pdu; + this->have_range_ = true; + } + + void record(SensorItem *curr) { + curr->range_start_address = this->r_.start_address; + this->r_.sensors.insert(curr); + this->prev_ = curr; + } + + void close() { + if (!this->have_range_) + return; + ESP_LOGV(TAG, "Add range 0x%X %d", this->r_.start_address, this->r_.register_count); + this->ranges_.push_back(std::move(this->r_)); + this->have_range_ = false; + } + + private: + uint32_t range_end_() const { return this->r_.start_address + this->r_.register_count; } + // The resolved offset must fit its uint8_t field or the sensor would parse the wrong slice. + static bool place_offset(SensorItem *curr, uint32_t offset) { + if (offset > std::numeric_limits::max()) + return false; + curr->offset = static_cast(offset); + return true; + } + static bool has_custom_size(const SensorItem *item) { + return item->get_register_size() != static_cast(item->entity_count()) * 2; + } + FixedVector &ranges_; + RegisterRange r_ = {}; + bool have_range_ = false; + bool range_forced_ = false; // a reuse: false member blocks the coverage join + bool range_shared_ = false; // only a share-widened range absorbs by coverage + size_t range_bytes_ = 0; + bool range_custom_size_ = false; + SensorItem *prev_ = nullptr; +}; + +} // namespace + void ModbusController::create_polling_commands_() { if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); return; } - // Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then - // force_new_range ahead of the rest, then address - so the walk is not purely address-ordered. - // Each keeps the address it was configured with; what is resolved here is its `offset`, the position - // of its data within the response of whichever range it ends up in. - // One range per sensor is a strict upper bound: each walk step closes at most one range, plus one - // closed after the walk. Sized to that bound so no push is ever silently dropped, then handed on by move. + // At most one range closes per sensor plus one final close, so sensorset_.size() bounds the pushes + // (FixedVector silently drops past capacity). FixedVector ranges; ranges.init(this->sensorset_.size()); - RegisterRange r = {}; - bool have_range = false; - // Set while the open range belongs to a force_new_range sensor: a range the user asked to keep - // separate must not quietly absorb other sensors. - bool range_forced = false; - // Set once a sensor has joined by sharing the range's start address, which widens the read. Only a - // widened range can absorb a later sensor by coverage: ranges that were kept apart before stay apart, - // so their frames and polling rates are untouched. - bool range_shared = false; - // Bytes the range's registers have consumed so far. An extending sensor starts after them, so a - // register that returns more bytes than its count implies pushes the sensors after it along. - // range_custom_size records whether any of them returns something other than two bytes per register, - // which is what makes a position inside the range impossible to work out from addresses alone. Coils - // count as such: they carry one bit per address, so bit ranges never take the coverage join. - size_t range_bytes = 0; - bool range_custom_size = false; - SensorItem *prev = nullptr; + RangeBuilder builder(ranges); for (SensorItem *curr : this->sensorset_) { - ESP_LOGV(TAG, "Register: 0x%X count=%d size=%zu offset=%u addr=%p", curr->start_address, curr->register_count, + ESP_LOGV(TAG, "Register: 0x%X width=%u size=%zu offset=%u addr=%p", curr->start_address, curr->entity_count(), curr->get_register_size(), curr->offset, curr); - - const bool custom_size = curr->get_register_size() != static_cast(curr->register_count) * 2; - - bool join = false; - if (have_range && !curr->force_new_range && r.register_type == curr->register_type && - curr->register_type != modbus::EntityType::CUSTOM) { - if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && - prev->start_address + prev->register_count == r.start_address + r.register_count && - curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { - // A second sensor on the register(s) the previous one covers: it reads those same bytes, - // starting where that sensor's offset pointed, so a chain configured 0/2/4 resolves to 0/2/6. - // Both address tests matter. The first identifies the previous sensor's register by working back - // from the range's end, which only describes it while it actually sits there - hence the second. - // A sensor that joined mid-range must never anchor this, or the next one inherits its offset. - curr->offset = static_cast(prev->offset + curr->offset_from_start_address); - join = true; - ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address); - } else if (curr->start_address == (r.start_address + r.register_count)) { - // The next contiguous register(s): the data begins after what the range has consumed so far - - // the byte cursor for registers, the distance in bits for coils. - curr->offset = - static_cast((curr->addresses_bits() ? curr->start_address - r.start_address : range_bytes) + - curr->offset_from_start_address); - range_bytes += curr->get_register_size(); - range_custom_size = range_custom_size || custom_size; - r.register_count += curr->register_count; - join = true; - 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) { - // 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. - const uint16_t addr_delta = curr->start_address - r.start_address; - curr->offset = static_cast((curr->addresses_bits() ? addr_delta : addr_delta * 2) + - curr->offset_from_start_address); - join = true; - ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, r.start_address); - } + bool join = builder.can_join(curr) && + (builder.try_reuse_register(curr) || builder.try_extend(curr) || builder.try_cover(curr)); + if (!join && builder.always_declined(curr)) { + ESP_LOGW(TAG, "reuse_previous_range on 0x%X cannot join the previous range; starting a new range", + curr->start_address); } - - // Sensors on the same start address have to share one range: a response is dispatched to a single - // range per (start_address, register_type), so a second range with that key would never receive - // data. This holds for force_new_range and custom entities too. The read widens to cover whichever - // sensor needs the most registers, which also fixes a short read for coils that use offset. - if (!join && have_range && r.register_type == curr->register_type && r.start_address == curr->start_address) { - curr->offset = curr->offset_from_start_address; // shares the range start - r.register_count = std::max(r.register_count, curr->register_count); - range_bytes = std::max(range_bytes, curr->get_register_size()); - range_custom_size = range_custom_size || custom_size; - range_shared = true; - range_forced = range_forced || curr->force_new_range; - join = true; - ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address); - } - - if (!join) { - if (have_range) { - ESP_LOGV(TAG, "Add range 0x%X %d", r.start_address, r.register_count); - ranges.push_back(std::move(r)); - } - r = {}; - range_bytes = curr->get_register_size(); - range_custom_size = custom_size; - range_forced = curr->force_new_range; - range_shared = false; - curr->offset = curr->offset_from_start_address; - r.start_address = curr->start_address; - r.register_count = curr->register_count; - r.register_type = curr->register_type; - if (curr->register_type == modbus::EntityType::CUSTOM) - r.custom_pdu = &curr->custom_pdu; - have_range = true; - } - - // Every member records its range's first register. The resolved offset is relative to it, so the - // two together give the sensor's real position, and the address a write entity targets. - curr->range_start_address = r.start_address; - r.sensors.insert(curr); - prev = curr; + join = join || builder.try_share(curr); + if (!join) + builder.open(curr); + builder.record(curr); } - if (have_range) { - ESP_LOGV(TAG, "Add last range 0x%X %d", r.start_address, r.register_count); - ranges.push_back(std::move(r)); - } - // Staged in a setup-time vector so the device storage can be sized exactly (see polling_devices_). + builder.close(); + this->polling_devices_.init(ranges.size()); for (auto &range : ranges) { this->polling_devices_.emplace_back(*this, std::move(range)); @@ -490,8 +538,8 @@ void ModbusController::dump_config() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); for (auto &it : this->sensorset_) { - ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X count=%d size=%zu", - static_cast(it->register_type), it->start_address, it->offset, it->register_count, + ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X width=%u size=%zu", + static_cast(it->register_type), it->start_address, it->offset, it->entity_count(), it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 490efbde0b8..821c500a31e 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -126,6 +126,16 @@ inline std::vector float_to_payload(float value, SensorValueType value class ModbusController; +/// How an item relates to the register range built just before it (same register type, address order). +/// The numeric order doubles as the comparator tiebreak for items at the same address (see +/// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and +/// shares a range it did not start (items on one address must share, see create_polling_commands_()). +enum class RangeReuse : uint8_t { + AUTO = 0, // join when adjacent and the position in the reply is exact (no non-standard response_size ahead) + ALWAYS = 1, // join unconditionally, reading across any address gap + NEVER = 2, // never join backward (later items may still extend this item's range) +}; + class SensorItem { public: /// Parse this sensor's slice out of its range's response and publish it. The span points into the @@ -159,11 +169,26 @@ class SensorItem { } void set_custom_pdu(std::initializer_list pdu) { this->custom_pdu.set(pdu.begin(), pdu.size()); } + + /// Entities this item spans: one bit for bit-addressed types, ceil(bytes / 2) registers for RAW + /// with a response_size, else the value type's register width. + virtual uint16_t entity_count() const { + if (modbus::helpers::is_entity_type_binary(this->register_type)) { + return 1; + } + if (this->sensor_value_type == SensorValueType::RAW && this->response_bytes > 0) { + return (this->response_bytes + 1) / 2; + } + return modbus::helpers::register_width_for(this->sensor_value_type); + } + + /// Bytes this item's registers occupy in a response: one per bit for bit-addressed types; response_size + /// when set (devices that answer more bytes per register than the standard two); else two per register. size_t virtual get_register_size() const { if (this->addresses_bits()) { return 1; } else { // if CONF_RESPONSE_BYTES is used override the default - return response_bytes > 0 ? response_bytes : register_count * 2; + return response_bytes > 0 ? response_bytes : this->entity_count() * 2; } } // Override register size for modbus devices not using 1 register for one dword @@ -177,7 +202,6 @@ class SensorItem { /// for the registers ahead of it (including wide response_size ones) and for any offset inherited /// from an earlier sensor sharing the same register. uint8_t offset{0}; - uint8_t register_count{0}; uint8_t response_bytes{0}; /// The offset exactly as configured: measured from this sensor's own start_address, where `offset` /// is measured from the first register of the range it ends up polled in. Same units as `offset` - @@ -188,7 +212,7 @@ class SensorItem { /// First register of the range this sensor is polled in; equals start_address for an unpolled item. uint16_t range_start_address{0}; SmallInlineBuffer<8> custom_pdu{}; - bool force_new_range{false}; + RangeReuse reuse_previous_range{RangeReuse::AUTO}; }; // ModbusController::create_polling_commands_ tries to optimize register range @@ -201,16 +225,17 @@ class SensorItemsComparator { return lhs->register_type < rhs->register_type; } - // ensure that sensor with force_new_range set are before the others - if (lhs->force_new_range != rhs->force_new_range) { - return lhs->force_new_range > rhs->force_new_range; - } - // sort by start address if (lhs->start_address != rhs->start_address) { return lhs->start_address < rhs->start_address; } + // at the same address: AUTO before ALWAYS before NEVER, so a NEVER item never starts the range + // the others at that address are then forced to share (see RangeReuse) + if (lhs->reuse_previous_range != rhs->reuse_previous_range) { + return lhs->reuse_previous_range < rhs->reuse_previous_range; + } + // sort by the offset as configured (ensures update of sensors in ascending order). The resolved // `offset` is deliberately not used: ranges are built while iterating this set and assign it, and // a sort key that changed under the iteration would corrupt the set's ordering. @@ -229,8 +254,8 @@ using SensorSet = std::set; struct RegisterRange { uint16_t start_address; modbus::EntityType register_type; - uint8_t register_count; - SensorSet sensors; // all sensors of this range + uint16_t register_count; // registers (or bits) the poll command reads; joins across gaps can exceed 255 + SensorSet sensors; // all sensors of this range /// A custom range polls this PDU, referenced from the sensor that opened the range. const SmallInlineBuffer<8> *custom_pdu{nullptr}; }; diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6a5b7041b8a..6f7bf588af7 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -17,20 +17,22 @@ from esphome.const import ( from esphome.types import ConfigType from .. import ( + RANGE_REUSE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, validate_custom_pdu_item, + validate_range_reuse_migration, ) from ..const import ( CONF_BITMASK, CONF_CUSTOM_COMMAND, CONF_CUSTOM_PDU, - CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, CONF_REGISTER_TYPE, + CONF_REUSE_PREVIOUS_RANGE, CONF_USE_WRITE_MULTIPLE, CONF_VALUE_TYPE, CONF_WRITE_LAMBDA, @@ -86,13 +88,14 @@ CONFIG_SCHEMA = cv.All( ), validate_min_max, validate_modbus_number, + validate_range_reuse_migration, ) FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item async def to_code(config: ConfigType) -> None: - byte_offset, reg_count = modbus_calc_properties(config) + byte_offset = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], @@ -100,8 +103,7 @@ async def to_code(config: ConfigType) -> None: byte_offset, config[CONF_BITMASK], config[CONF_VALUE_TYPE], - reg_count, - config[CONF_FORCE_NEW_RANGE], + RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]], ) await cg.register_component(var, config) diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index e890a2a9ac6..aff05cd517a 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -83,10 +83,10 @@ void ModbusNumber::control(float value) { ESP_LOGD(TAG, "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", - this->get_name().c_str(), this->start_address, this->register_count, value, write_value); + this->get_name().c_str(), this->start_address, this->entity_count(), value, write_value); bool queued; - if (this->register_count == 1 && !this->use_write_multiple_) { + if (this->entity_count() == 1 && !this->use_write_multiple_) { queued = this->write_single_register(this->write_address(), data[0]); } else { queued = this->write_multiple_registers(this->write_address(), data); diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index 59c76e18f2a..a61840cf5bd 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -13,14 +13,13 @@ using value_to_data_t = std::function(float); class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity { public: ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, - SensorValueType value_type, int register_count, bool force_new_range) { + SensorValueType value_type, RangeReuse reuse_previous_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->force_new_range = force_new_range; + this->reuse_previous_range = reuse_previous_range; }; void dump_config() override; diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index c2055fa690b..0e8d5363d74 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,3 +1,5 @@ +import logging + import esphome.codegen as cg from esphome.components import output from esphome.components.modbus.helpers import ( @@ -12,6 +14,7 @@ from esphome.types import ConfigType from .. import ( ModbusItemBaseSchema, SensorItem, + entity_label, modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, @@ -19,13 +22,18 @@ from .. import ( from ..const import ( CONF_CUSTOM_COMMAND, CONF_CUSTOM_PDU, + CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, + CONF_REGISTER_COUNT, CONF_REGISTER_TYPE, + CONF_REUSE_PREVIOUS_RANGE, CONF_USE_WRITE_MULTIPLE, CONF_VALUE_TYPE, CONF_WRITE_LAMBDA, ) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ["modbus_controller"] CODEOWNERS = ["@martgras"] @@ -38,26 +46,30 @@ ModbusBinaryOutput = modbus_controller_ns.class_( ) -CONFIG_SCHEMA = cv.typed_schema( - { - "coil": output.BINARY_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( - { - 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; use a write_lambda instead" - ), - cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, - } - ), - "holding": cv.All( - output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( +def _warn_unused_range_options(config: ConfigType) -> ConfigType: + # Outputs are write-only and never polled, so nothing here builds a range for them. The write + # spans whatever the payload holds, so register_count no longer bounds it either. + for key in (CONF_FORCE_NEW_RANGE, CONF_REGISTER_COUNT): + if config.pop(key, None) is not None: + _LOGGER.warning( + "%s: '%s' has no effect on outputs; remove it. Removed in 2027.3.0", + entity_label(config), + key, + ) + if config.pop(CONF_REUSE_PREVIOUS_RANGE, None) not in (None, "auto"): + raise cv.Invalid( + f"'{CONF_REUSE_PREVIOUS_RANGE}' has no effect on outputs: they are write-only and are " + f"never part of a polled range. Remove it." + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "coil": output.BINARY_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( { - cv.GenerateID(): cv.declare_id(ModbusFloatOutput), + 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" @@ -65,25 +77,42 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid( "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 - ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, } ), - reject_odd_holding_write_offset, - ), - }, - lower=True, - key=CONF_REGISTER_TYPE, - default_type="holding", + "holding": cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( + { + 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; use a write_lambda instead" + ), + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( + SENSOR_VALUE_TYPE + ), + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, + cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + } + ), + reject_odd_holding_write_offset, + ), + }, + lower=True, + key=CONF_REGISTER_TYPE, + default_type="holding", + ), + _warn_unused_range_options, ) async def to_code(config: ConfigType) -> None: - byte_offset, reg_count = modbus_calc_properties(config) + byte_offset = modbus_calc_properties(config) # Binary Output write_template = None if config[CONF_REGISTER_TYPE] == "coil": @@ -109,7 +138,6 @@ async def to_code(config: ConfigType) -> None: config[CONF_ADDRESS], byte_offset, config[CONF_VALUE_TYPE], - reg_count, ) cg.add(var.set_write_multiply(config[CONF_MULTIPLY])) if CONF_WRITE_LAMBDA in config: diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index b05d3889fd8..ad29015d328 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -46,29 +46,24 @@ void ModbusFloatOutput::write_state(float value) { modbus::helpers::float_to_payload(data, value, this->sensor_value_type); } - ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)", - this->start_address, this->register_count, value, original_value); + ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%u new value=%.02f (val=%.02f)", + this->start_address, this->entity_count(), value, original_value); - // The command declares register_count registers, so the payload must be exactly that many words; - // anything else would put a byte count on the wire that disagrees with the quantity field. - // number_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0]. + // float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0]. if (data.empty()) { ESP_LOGW(TAG, "No payload was created for updating output"); return; } - // register_count declares the READ range width - it may pull neighboring registers into one poll - - // so a write covers exactly the registers the value occupies: the quantity comes from the payload, - // never from register_count (padding to it would zero registers the user only declared for reading). - // A payload wider than the declared range means the config and the lambda disagree - drop it. - if (data.size() > this->register_count) { - ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(), - this->register_count); + // The value type sets the write width, so a wider payload means the config and the lambda disagree. + if (data.size() > this->entity_count()) { + ESP_LOGE(TAG, "Payload has %zu registers but the value type only spans %u; dropping write", data.size(), + this->entity_count()); return; } bool queued; - if (this->register_count == 1 && !this->use_write_multiple_) { + if (this->entity_count() == 1 && !this->use_write_multiple_) { queued = this->write_single_register(this->write_address(), data[0]); } else { queued = this->write_multiple_registers(this->write_address(), data); @@ -85,7 +80,7 @@ void ModbusFloatOutput::dump_config() { " Device start address: 0x%X\n" " Register count: %d\n" " Value type: %d", - this->start_address, this->register_count, static_cast(this->sensor_value_type)); + this->start_address, this->entity_count(), static_cast(this->sensor_value_type)); } // ModbusBinaryOutput @@ -145,7 +140,7 @@ void ModbusBinaryOutput::dump_config() { " Device start address: 0x%X\n" " Register count: %d\n" " Value type: %d", - this->start_address, this->register_count, static_cast(this->sensor_value_type)); + this->start_address, this->entity_count(), static_cast(this->sensor_value_type)); } } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index 48153dc0b71..f76c7eada7a 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -10,13 +10,12 @@ namespace esphome::modbus_controller { class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity { public: - ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { + ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type) { this->register_type = modbus::EntityType::HOLDING; // A byte offset folds into the address as whole registers; odd offsets are rejected at validation. this->set_address(start_address + offset / 2); this->set_offset_from_start_address(0); this->bitmask = 0xFFFFFFFF; - this->register_count = register_count; this->sensor_value_type = value_type; } void dump_config() override; @@ -46,7 +45,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component, this->set_address(start_address + offset); this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; - this->register_count = 1; this->set_offset_from_start_address(0); } void dump_config() override; diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index 07893e33036..d8319932ab6 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -3,25 +3,24 @@ from typing import Any import esphome.codegen as cg from esphome.components import select -from esphome.components.modbus.helpers import ( - SENSOR_VALUE_TYPE, - TYPE_REGISTER_MAP, - RegisterValues, -) +from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC from esphome.types import ConfigType from .. import ( + RANGE_REUSE, ModbusController, SensorItem, modbus_controller_ns, + validate_range_reuse_migration, validate_skip_updates_deprecated, ) from ..const import ( CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, CONF_REGISTER_COUNT, + CONF_REUSE_PREVIOUS_RANGE, CONF_SKIP_UPDATES, CONF_USE_WRITE_MULTIPLE, CONF_VALUE_TYPE, @@ -55,18 +54,6 @@ def ensure_option_map() -> Callable[[Any], dict[str, int]]: return validator -def register_count_value_type_min(value: ConfigType) -> ConfigType: - reg_count = value.get(CONF_REGISTER_COUNT) - if reg_count is not None: - value_type = value[CONF_VALUE_TYPE] - min_register_count = TYPE_REGISTER_MAP[value_type] - if min_register_count > reg_count: - raise cv.Invalid( - f"Value type {value_type} needs at least {min_register_count} registers" - ) - return value - - INTEGER_SENSOR_VALUE_TYPE = { key: value for key, value in SENSOR_VALUE_TYPE.items() if not key.startswith("FP") } @@ -81,9 +68,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( INTEGER_SENSOR_VALUE_TYPE ), - cv.Optional(CONF_REGISTER_COUNT): 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_REUSE_PREVIOUS_RANGE, default="auto"): cv.Any( + cv.boolean, cv.one_of("auto", lower=True) + ), + # Deprecated options, migrated by validate_range_reuse_migration(). Remove before 2027.3.0 + cv.Optional(CONF_FORCE_NEW_RANGE): cv.boolean, + cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, @@ -91,24 +82,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, }, ), - register_count_value_type_min, + validate_range_reuse_migration, ) async def to_code(config: ConfigType) -> None: - value_type = config[CONF_VALUE_TYPE] - reg_count = config.get(CONF_REGISTER_COUNT) - if reg_count is None: - reg_count = TYPE_REGISTER_MAP[value_type] - options_map = config[CONF_OPTIONSMAP] var = cg.new_Pvariable( config[CONF_ID], - value_type, + config[CONF_VALUE_TYPE], config[CONF_ADDRESS], - reg_count, - config[CONF_FORCE_NEW_RANGE], + RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]], list(options_map.values()), ) diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index c1cc241d6b7..a2f15d54f66 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -83,19 +83,17 @@ void ModbusSelect::control(size_t index) { } } - // register_count declares the READ range width - it may pull neighboring registers into one poll - - // so a write covers exactly the registers the value occupies: the quantity comes from the payload, - // never from register_count (padding to it would zero registers the user only declared for reading). - // A payload wider than the declared range means the config and the lambda disagree - drop it. - if (data.size() > this->register_count) { - ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(), - this->register_count); + // A write covers exactly the registers the value occupies: the quantity comes from the payload. A + // payload wider than the value type's register width means the config and the lambda disagree - drop it. + if (data.size() > this->entity_count()) { + ESP_LOGE(TAG, "Payload has %zu registers but the value type only spans %u; dropping write", data.size(), + this->entity_count()); return; } const uint16_t write_address = this->write_address(); bool queued; - if ((this->register_count == 1) && (!this->use_write_multiple_)) { + if ((this->entity_count() == 1) && (!this->use_write_multiple_)) { queued = this->write_single_register(write_address, data[0]); } else { queued = this->write_multiple_registers(write_address, data); diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index c6ac76a45b2..3827d38755c 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -11,16 +11,15 @@ namespace esphome::modbus_controller { class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity { public: - ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range, + ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, RangeReuse reuse_previous_range, std::vector mapping) { this->register_type = modbus::EntityType::HOLDING; // not configurable this->sensor_value_type = sensor_value_type; this->set_address(start_address); this->set_offset_from_start_address(0); // not configurable this->bitmask = 0xFFFFFFFF; // not configurable - this->register_count = register_count; - this->response_bytes = 0; // not configurable - this->force_new_range = force_new_range; + this->response_bytes = 0; // not configurable + this->reuse_previous_range = reuse_previous_range; this->mapping_ = std::move(mapping); } diff --git a/esphome/components/modbus_controller/sensor/__init__.py b/esphome/components/modbus_controller/sensor/__init__.py index 2c34ef04b45..bd51b9a8a39 100644 --- a/esphome/components/modbus_controller/sensor/__init__.py +++ b/esphome/components/modbus_controller/sensor/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( + RANGE_REUSE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, @@ -12,13 +13,13 @@ from .. import ( modbus_controller_ns, validate_custom_pdu_item, validate_modbus_register, + validate_range_reuse_migration, ) from ..const import ( CONF_BITMASK, - CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, - CONF_REGISTER_COUNT, CONF_REGISTER_TYPE, + CONF_REUSE_PREVIOUS_RANGE, CONF_VALUE_TYPE, ) @@ -38,27 +39,25 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), - cv.Optional(CONF_REGISTER_COUNT, default=0): cv.positive_int, } ), validate_modbus_register, + validate_range_reuse_migration, ) FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item async def to_code(config): - byte_offset, reg_count = modbus_calc_properties(config) - value_type = config[CONF_VALUE_TYPE] + byte_offset = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], config[CONF_ADDRESS], byte_offset, config[CONF_BITMASK], - value_type, - reg_count, - config[CONF_FORCE_NEW_RANGE], + config[CONF_VALUE_TYPE], + RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]], ) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 68dc9e6fcc6..12c29bf584f 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -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, bool force_new_range) { + SensorValueType value_type, RangeReuse reuse_previous_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->force_new_range = force_new_range; + this->reuse_previous_range = reuse_previous_range; } void parse_and_publish(std::span data) override; diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 49dc0bb222e..00b67446a31 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID from esphome.types import ConfigType from .. import ( + RANGE_REUSE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, @@ -14,12 +15,13 @@ from .. import ( reject_odd_holding_write_offset, validate_custom_pdu_item, validate_modbus_register, + validate_range_reuse_migration, ) from ..const import ( CONF_BITMASK, - CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, CONF_REGISTER_TYPE, + CONF_REUSE_PREVIOUS_RANGE, CONF_USE_WRITE_MULTIPLE, CONF_WRITE_LAMBDA, ) @@ -54,20 +56,21 @@ CONFIG_SCHEMA = cv.All( ), validate_modbus_register, _validate_holding_offset, + validate_range_reuse_migration, ) FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item async def to_code(config: ConfigType) -> None: - byte_offset, _ = modbus_calc_properties(config) + byte_offset = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], config[CONF_ADDRESS], byte_offset, config[CONF_BITMASK], - config[CONF_FORCE_NEW_RANGE], + RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]], ) await cg.register_component(var, config) await switch.register_switch(var, config) diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index bd1c837080d..688a620bac1 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -11,13 +11,12 @@ namespace esphome::modbus_controller { class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity { public: ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, - bool force_new_range) { + RangeReuse reuse_previous_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->register_count = 1; // A holding byte offset folds into the address as whole registers (odd offsets are rejected at // validation: a 16-bit register write cannot target half a register); a coil offset is a coil count. if (register_type == modbus::EntityType::HOLDING) { @@ -27,7 +26,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens this->set_address(start_address + offset); this->set_offset_from_start_address(0); } - this->force_new_range = force_new_range; + this->reuse_previous_range = reuse_previous_range; }; void setup() override; void write_state(bool state) override; diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 31f5f87a987..7ab77700ca5 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( + RANGE_REUSE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, @@ -12,14 +13,14 @@ from .. import ( modbus_controller_ns, validate_custom_pdu_item, validate_modbus_register, + validate_range_reuse_migration, ) from ..const import ( - CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, CONF_RAW_ENCODE, - CONF_REGISTER_COUNT, CONF_REGISTER_TYPE, CONF_RESPONSE_SIZE, + CONF_REUSE_PREVIOUS_RANGE, ) DEPENDENCIES = ["modbus_controller"] @@ -47,32 +48,27 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ModbusTextSensor), cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), - cv.Optional(CONF_REGISTER_COUNT, default=0): cv.positive_int, - cv.Optional(CONF_RESPONSE_SIZE, default=2): cv.positive_int, + cv.Optional(CONF_RESPONSE_SIZE, default=2): cv.int_range(min=1, max=250), cv.Optional(CONF_RAW_ENCODE, default="ANSI"): cv.enum(RAW_ENCODING), } ), validate_modbus_register, + validate_range_reuse_migration, ) FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item async def to_code(config): - byte_offset, reg_count = modbus_calc_properties(config) - response_size = config[CONF_RESPONSE_SIZE] - reg_count = config[CONF_REGISTER_COUNT] - if reg_count == 0: - reg_count = response_size // 2 + byte_offset = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], config[CONF_ADDRESS], byte_offset, - reg_count, config[CONF_RESPONSE_SIZE], config[CONF_RAW_ENCODE], - config[CONF_FORCE_NEW_RANGE], + RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]], ) await cg.register_component(var, config) diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index 9e8dce57e7b..6657967786b 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -12,17 +12,16 @@ 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, bool force_new_range) { + ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint16_t response_bytes, + RawEncoding encode, RangeReuse reuse_previous_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->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::RAW; - this->force_new_range = force_new_range; + this->reuse_previous_range = reuse_previous_range; } void dump_config() override; diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index 78bec522cf8..b9a7610cb73 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -21,6 +21,7 @@ binary_sensor: name: Test Binary Sensor with Lambda register_type: input address: 0x3201 + reuse_previous_range: false lambda: |- return x; @@ -85,6 +86,7 @@ select: name: Test Select with Lambda address: 1001 value_type: U_WORD + reuse_previous_range: auto optionsmap: "Off": 0 "On": 1 @@ -140,9 +142,10 @@ sensor: register_type: holding address: 0x9002 value_type: U_WORD + reuse_previous_range: true lambda: |- return x / 10.0; - # Non-mergeable sensor sharing the start address of modbus_sensor1 (different register_count): + # Non-mergeable sensor sharing the start address of modbus_sensor1 (different value type width): # must join the same range, never open a second range keyed on the same (address, type). - platform: modbus_controller modbus_controller_id: modbus_controller1 @@ -187,8 +190,8 @@ sensor: value_type: U_WORD lambda: |- return modbus_controller::get_data(data, item->offset) * 0.1f; - # force_new_range sensors sort before plain ones, so this high-address forced sensor is grouped - # first and the lower-address plain sensors above must still get their own ranges. + # The deprecated force_new_range migrates to reuse_previous_range: false, so this sensor never + # joins a range built before it and the lower-address sensors above keep their own ranges. - platform: modbus_controller modbus_controller_id: modbus_controller1 id: modbus_sensor_forced_high @@ -224,7 +227,6 @@ text_sensor: name: Test Text Sensor register_type: holding address: 0x9013 - register_count: 3 raw_encode: HEXBYTES response_size: 6 - platform: modbus_controller @@ -233,12 +235,13 @@ text_sensor: name: Test Text Sensor with Lambda register_type: holding address: 0x9014 - register_count: 2 response_size: 4 lambda: |- return "Modified: " + x; - # A register reporting FEWER bytes than 2*register_count (response_size: 3 for 2 registers), followed - # by a contiguous sensor: the follower's byte position must track the actual 3 bytes, not underflow. + # A register reporting FEWER bytes than two per register (response_size: 3 over 2 registers), followed + # by a contiguous reuse:true sensor (auto never joins past a response_size register): the follower's + # byte position must track the actual 3 bytes, not underflow. + # register_count matches the derived width, so it migrates with a deprecation warning. - platform: modbus_controller modbus_controller_id: modbus_controller1 id: modbus_text_sensor_narrow @@ -255,4 +258,5 @@ text_sensor: register_type: holding address: 0x9032 register_count: 1 + reuse_previous_range: true raw_encode: HEXBYTES diff --git a/tests/integration/fixtures/uart_mock_modbus_grouping.yaml b/tests/integration/fixtures/uart_mock_modbus_grouping.yaml index d580f5c2e23..2c4c39e7a51 100644 --- a/tests/integration/fixtures/uart_mock_modbus_grouping.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_grouping.yaml @@ -27,8 +27,8 @@ uart_mock: # so these also pin the grouping: an extra or differently shaped read fails the test. - expect_tx: [0x01, 0x01, 0x00, 0x10, 0x00, 0x02, 0xBC, 0x0E] # coils 0x10 count 2 inject_rx: [0x01, 0x01, 0x01, 0x01, 0x90, 0x48] # bit0 set, bit1 clear - - expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x01, 0x85, 0xE8] # holding 0x160 count 1 - inject_rx: [0x01, 0x03, 0x02, 0x01, 0x60, 0xB9, 0xFC] # 352 + - expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x02, 0xC5, 0xE9] # holding 0x160 count 2 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x60, 0x01, 0x61, 0x3B, 0xA9] # 352, 353 - expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x01, 0x85, 0xF6] # holding 0x100 count 1 inject_rx: [0x01, 0x03, 0x04, 0x01, 0x11, 0x02, 0x22, 0x2A, 0xB3] # 4 bytes: 273 then 546 - expect_tx: [0x01, 0x03, 0x01, 0x20, 0x00, 0x04, 0x44, 0x3F] # holding 0x120 count 4 @@ -49,8 +49,6 @@ uart_mock: inject_rx: [0x01, 0x03, 0x02, 0x33, 0x33, 0xEC, 0xA1] # 13107 - expect_tx: [0x01, 0x03, 0x01, 0x70, 0x00, 0x03, 0x05, 0xEC] # holding 0x170 count 3 inject_rx: [0x01, 0x03, 0x06, 0x00, 0x2A, 0x1B, 0x2C, 0x03, 0x0D, 0x3E, 0xAB] # 6 bytes - - expect_tx: [0x01, 0x03, 0x01, 0x61, 0x00, 0x01, 0xD4, 0x28] # holding 0x161 count 1 - inject_rx: [0x01, 0x03, 0x02, 0x01, 0x61, 0x78, 0x3C] # 353 modbus: uart_id: virtual_uart_dev @@ -104,8 +102,9 @@ sensor: value_type: U_DWORD modbus_controller_id: modbus_controller_ok - # D - a wide (response_size) register followed by a contiguous one: the follower must start after the - # bytes the wide register actually returned, not after 2 * register_count. + # D - a wide (response_size) register followed by a contiguous reuse:true one (auto never joins past + # a response_size register): the follower must start after the bytes the wide register actually + # returned, not after two per register. - platform: modbus_controller name: "wide_first" address: 0x130 @@ -118,6 +117,7 @@ sensor: address: 0x131 register_type: holding value_type: U_WORD + reuse_previous_range: true modbus_controller_id: modbus_controller_ok # E - a gap: these must never share a range. @@ -195,13 +195,14 @@ sensor: value_type: U_WORD modbus_controller_id: modbus_controller_ok - # H - a sensor pinned to its own range, followed by a contiguous one. + # H - a sensor that never joins the range built before it (reuse_previous_range: false), followed + # by a contiguous plain item that extends the new range it started. - platform: modbus_controller name: "forced_first" address: 0x160 register_type: holding value_type: U_WORD - force_new_range: true + reuse_previous_range: false modbus_controller_id: modbus_controller_ok - platform: modbus_controller name: "forced_next" diff --git a/tests/integration/fixtures/uart_mock_modbus_ranges.yaml b/tests/integration/fixtures/uart_mock_modbus_ranges.yaml new file mode 100644 index 00000000000..09e5a202415 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_ranges.yaml @@ -0,0 +1,227 @@ +esphome: + name: uart-mock-modbus-ranges-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Each expect_tx below pins the exact read request the controller's range builder emits, so this +# fixture is a wire-level test of reuse_previous_range (auto/yes/no), gap joins, same-register reuse, +# response_size surplus accounting, and RAW/text block reads. +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. + auto_start: false + debug: + responses: + - expect_tx: [0x01, 0x03, 0x00, 0x00, 0x00, 0x03, 0x05, 0xCB] # auto adjacency: one read covers 0x00-0x02 + inject_rx: [0x01, 0x03, 0x06, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0xFD, 0x74] + - expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # auto gap: 0x10 alone + inject_rx: [0x01, 0x03, 0x02, 0x00, 0x04, 0xB9, 0x87] + - expect_tx: [0x01, 0x03, 0x00, 0x13, 0x00, 0x01, 0x75, 0xCF] # auto gap: 0x13 alone + inject_rx: [0x01, 0x03, 0x02, 0x00, 0x05, 0x78, 0x47] + - expect_tx: [0x01, 0x03, 0x00, 0x20, 0x00, 0x04, 0x45, 0xC3] # yes across gap: one read 0x20-0x23, gap registers ignored + inject_rx: [0x01, 0x03, 0x08, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x33, 0xD1] + - expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # no isolation: 0x30 alone despite adjacency + inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0A, 0x38, 0x43] + - expect_tx: [0x01, 0x03, 0x00, 0x31, 0x00, 0x01, 0xD5, 0xC5] # no isolation: 0x31 alone + inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0B, 0xF9, 0x83] + - expect_tx: [0x01, 0x03, 0x00, 0x3F, 0x00, 0x01, 0xB4, 0x06] # open NEVER: 0x3F alone (the reuse:false item split off) + inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0C, 0xB8, 0x41] + - expect_tx: [0x01, 0x03, 0x00, 0x40, 0x00, 0x02, 0xC5, 0xDF] # open NEVER: 0x40 (reuse: false) still extended by the auto item at 0x41 + inject_rx: [0x01, 0x03, 0x04, 0x00, 0x0D, 0x00, 0x0E, 0xEA, 0x34] + - expect_tx: [0x01, 0x03, 0x00, 0x50, 0x00, 0x01, 0x84, 0x1B] # same-address reuse: one read, two sensors on 0x50 + inject_rx: [0x01, 0x03, 0x02, 0x12, 0x34, 0xB5, 0x33] + - expect_tx: [0x01, 0x03, 0x00, 0x60, 0x00, 0x04, 0x44, 0x17] # text block + adjacent word: one read 0x60-0x63 + inject_rx: [0x01, 0x03, 0x08, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x00, 0x0F, 0x78, 0x0E] + - expect_tx: [0x01, 0x03, 0x00, 0x70, 0x00, 0x02, 0xC5, 0xD0] # response_size surplus: 0x70 answers 4 bytes, reuse:true word at 0x71 shifted along + inject_rx: [0x01, 0x03, 0x06, 0x00, 0x10, 0xAA, 0xBB, 0x00, 0x11, 0x71, 0x47] + - expect_tx: [0x01, 0x03, 0x00, 0x90, 0x00, 0x01, 0x84, 0x27] # auto after surplus: 0x90 alone (auto never joins past response_size) + inject_rx: [0x01, 0x03, 0x04, 0x00, 0x18, 0xCC, 0xDD, 0xEF, 0x6D] + - expect_tx: [0x01, 0x03, 0x00, 0x91, 0x00, 0x01, 0xD5, 0xE7] # auto after surplus: 0x91 alone + inject_rx: [0x01, 0x03, 0x02, 0x00, 0x19, 0x79, 0x8E] + - expect_tx: [0x01, 0x03, 0x00, 0x80, 0x00, 0x04, 0x45, 0xE1] # RAW block via response_size: 8 bytes = 4 registers in one read + inject_rx: [0x01, 0x03, 0x08, 0x00, 0x14, 0x00, 0x15, 0x00, 0x16, 0x00, 0x17, 0x6D, 0xDF] + +modbus: + uart_id: virtual_uart_dev + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + id: ranges_controller + max_cmd_retries: 0 + # The test triggers a single poll by pressing the "Start Scenario" button + update_interval: never + +sensor: + # Case 1: three adjacent registers merge into one read (auto default) + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "adjacent_a" + register_type: holding + address: 0x00 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "adjacent_b" + register_type: holding + address: 0x01 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "adjacent_c" + register_type: holding + address: 0x02 + + # Case 2: a gap keeps auto items apart + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "gap_a" + register_type: holding + address: 0x10 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "gap_b" + register_type: holding + address: 0x13 + + # Case 3: reuse_previous_range: true bridges the gap into one read + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "bridge_a" + register_type: holding + address: 0x20 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "bridge_b" + register_type: holding + address: 0x23 + reuse_previous_range: true + + # Case 4: reuse_previous_range: false splits adjacent registers + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "split_a" + register_type: holding + address: 0x30 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "split_b" + register_type: holding + address: 0x31 + reuse_previous_range: false + + # Case 5: a reuse:false item starts its own range but stays open for later auto items + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "open_prev" + register_type: holding + address: 0x3F + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "open_never" + register_type: holding + address: 0x40 + reuse_previous_range: false + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "open_tagalong" + register_type: holding + address: 0x41 + + # Case 6: two sensors on the same register share one read + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "shared_lo" + register_type: holding + address: 0x50 + bitmask: 0x00FF + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "shared_hi" + register_type: holding + address: 0x50 + bitmask: 0xFF00 + + # Case 10 (text block, see text_sensor below) shares the range with this word at 0x63 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "after_text" + register_type: holding + address: 0x63 + + # Case 11: response_size surplus — the device answers 4 bytes for this single register, so the + # following sensor's data sits 2 bytes later than its address alone implies. Joining past a + # non-standard response_size takes an explicit reuse_previous_range: true. + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "surplus" + register_type: holding + address: 0x70 + response_size: 4 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "after_surplus" + register_type: holding + address: 0x71 + reuse_previous_range: true + + # Case 13: auto never joins past a response_size register — despite adjacency these poll separately + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "surplus_split" + register_type: holding + address: 0x90 + response_size: 4 + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "after_surplus_split" + register_type: holding + address: 0x91 + + # Case 12: RAW + response_size reads a block of ceil(8/2) = 4 registers + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "raw_block" + register_type: holding + address: 0x80 + value_type: RAW + response_size: 8 + lambda: |- + return (float) data.size(); + +text_sensor: + # Case 10: text sensor reads 3 registers (response_size 6) + - platform: modbus_controller + modbus_controller_id: ranges_controller + name: "text_block" + register_type: holding + address: 0x60 + response_size: 6 + raw_encode: NONE + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(ranges_controller).set_update_interval(1000); + id(ranges_controller).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml b/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml index 109603f3b64..7a94082ed87 100644 --- a/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml @@ -32,9 +32,9 @@ uart_mock: # duplicate or overlapping range would put an extra frame on the bus and fail to match. - expect_tx: [0x01, 0x03, 0x90, 0x01, 0x00, 0x02, 0xB8, 0xCB] # Read holding 0x9001 count 2 on device 1 inject_rx: [0x01, 0x03, 0x04, 0x03, 0x97, 0x02, 0x91, 0x8B, 0x57] # 0x9001=0x0397, 0x9002=0x0291 - # A force_new_range sensor at a HIGH address (0x30) sorts before the plain sensor at a LOW address - # (0x10). The two must poll as separate ranges: the covered branch's lower-bound check prevents the - # 0x10 sensor from being absorbed into the forced 0x30 range with a wrapped byte offset. + # A sensor at 0x30 with the deprecated force_new_range (migrates to reuse_previous_range: false) + # and a plain sensor at 0x10. The two must poll as separate ranges: the 0x10 sensor must not be + # absorbed into the isolated 0x30 range. - expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # Read holding 0x30 count 1 (forced range) inject_rx: [0x01, 0x03, 0x02, 0x01, 0x11, 0x79, 0xD8] # 0x30 = 0x0111 = 273 - expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # Read holding 0x10 count 1 (own range) @@ -87,7 +87,7 @@ sensor: register_type: holding value_type: U_WORD modbus_controller_id: modbus_controller_ok - # Forced sensor at a high address: sorts first, opens its own isolated range + # Isolated sensor (deprecated spelling, migrates to reuse_previous_range: false): own range - platform: modbus_controller name: "forced_high" address: 0x30 @@ -95,7 +95,7 @@ sensor: value_type: U_WORD force_new_range: true modbus_controller_id: modbus_controller_ok - # Plain sensor at a lower address: must get its own range, never absorbed into the forced one + # Plain sensor at a lower address: must get its own range, never absorbed into the isolated one - platform: modbus_controller name: "plain_low" address: 0x10 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index f88febabf54..864275f5ed7 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -21,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState import pytest from .state_utils import SensorTracker, find_entity, wait_for_state @@ -1158,3 +1158,78 @@ async def test_uart_mock_modbus_deprecated_write_buffer( assert warn_count == 1, ( f"deprecation warning should fire exactly once per entity, got {warn_count}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_ranges( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Wire-level test of the range builder's reuse_previous_range semantics. + + Every expect_tx in the fixture pins the exact read request the controller emits, so a + wrongly merged or split range fails on the mock before any value arrives. Covers: auto + adjacency merging, auto gap splitting, reuse:true bridging a gap (with correct data + offsets past the gap), reuse:false splitting adjacent registers while staying open for + later auto items, two sensors sharing one register, a text block read sized by + response_size with a following word, response_size surplus shifting a later reuse:true + sensor's bytes while an auto sensor refuses to join past the surplus, and a RAW block + read of ceil(response_size / 2) registers. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = { + "adjacent_a": 1, + "adjacent_b": 2, + "adjacent_c": 3, + "gap_a": 4, + "gap_b": 5, + "bridge_a": 6, + "bridge_b": 9, + "split_a": 10, + "split_b": 11, + "open_prev": 12, + "open_never": 13, + "open_tagalong": 14, + "shared_lo": 0x34, + "shared_hi": 0x12, + "after_text": 15, + "surplus": 16, + "after_surplus": 17, + "surplus_split": 24, + "after_surplus_split": 25, + "raw_block": 8, # the RAW lambda publishes data.size(): 4 registers = 8 bytes + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + # The tracker only handles numeric sensors; capture the text block separately. + text_future: asyncio.Future = asyncio.get_running_loop().create_future() + tracker_on_state = tracker.on_state + + def on_state(state) -> None: + if ( + isinstance(state, TextSensorState) + and not state.missing_state + and state.state == "ABCDEF" + and not text_future.done() + ): + text_future.set_result(True) + tracker_on_state(state) + + tracker.on_state = on_state + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + # text_block is not tracker-registered (non-numeric), so time out explicitly. + try: + await asyncio.wait_for(text_future, timeout=5.0) + except TimeoutError: + pytest.fail("text_block never published 'ABCDEF'") + _assert_no_modbus_errors(error_log_lines, warning_log_lines)