From 52e8c50f45a035362d62a1d47e967240ec6b65f6 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 28 Apr 2026 08:21:25 -0700 Subject: [PATCH] [modbus] Split modbus_server from modbus_controller (#15509) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/modbus_controller/__init__.py | 91 +-------- esphome/components/modbus_controller/const.py | 1 + .../modbus_controller/modbus_controller.cpp | 176 +--------------- .../modbus_controller/modbus_controller.h | 93 --------- esphome/components/modbus_server/__init__.py | 124 +++++++++++ esphome/components/modbus_server/const.py | 7 + .../modbus_server/modbus_server.cpp | 192 ++++++++++++++++++ .../components/modbus_server/modbus_server.h | 119 +++++++++++ .../components/modbus_controller/common.yaml | 44 +--- tests/components/modbus_server/common.yaml | 41 ++++ .../modbus_server/test.esp32-idf.yaml | 4 + .../modbus_server/test.esp8266-ard.yaml | 4 + .../modbus_server/test.rp2040-ard.yaml | 4 + .../fixtures/uart_mock_modbus_server.yaml | 4 +- .../uart_mock_modbus_server_controller.yaml | 5 +- ...ock_modbus_server_controller_multiple.yaml | 9 +- ...t_mock_modbus_server_controller_write.yaml | 5 +- 18 files changed, 523 insertions(+), 401 deletions(-) create mode 100644 esphome/components/modbus_server/__init__.py create mode 100644 esphome/components/modbus_server/const.py create mode 100644 esphome/components/modbus_server/modbus_server.cpp create mode 100644 esphome/components/modbus_server/modbus_server.h create mode 100644 tests/components/modbus_server/common.yaml create mode 100644 tests/components/modbus_server/test.esp32-idf.yaml create mode 100644 tests/components/modbus_server/test.esp8266-ard.yaml create mode 100644 tests/components/modbus_server/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 20c19a7dfa..471def542b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,6 +347,7 @@ esphome/components/modbus_controller/select/* @martgras @stegm esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/text_sensor/* @martgras +esphome/components/modbus_server/* @exciton esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 2af58a96be..67e5757397 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -3,11 +3,8 @@ import binascii from esphome import automation import esphome.codegen as cg from esphome.components import modbus -from esphome.components.const import CONF_ENABLED from esphome.components.modbus.helpers import ( - CPP_TYPE_REGISTER_MAP, MODBUS_REGISTER_TYPE, - SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP, ModbusRegisterType, ) @@ -29,11 +26,10 @@ from .const import ( CONF_ON_OFFLINE, CONF_ON_ONLINE, CONF_REGISTER_COUNT, - CONF_REGISTER_LAST_ADDRESS, CONF_REGISTER_TYPE, - CONF_REGISTER_VALUE, CONF_RESPONSE_SIZE, CONF_SERVER_COURTESY_RESPONSE, + CONF_SERVER_REGISTERS, CONF_SKIP_UPDATES, CONF_VALUE_TYPE, ) @@ -42,9 +38,6 @@ CODEOWNERS = ["@martgras"] AUTO_LOAD = ["modbus"] -CONF_READ_LAMBDA = "read_lambda" -CONF_WRITE_LAMBDA = "write_lambda" -CONF_SERVER_REGISTERS = "server_registers" MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") @@ -53,30 +46,9 @@ ModbusController = modbus_controller_ns.class_( ) SensorItem = modbus_controller_ns.struct("SensorItem") -ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse") -ServerRegister = modbus_controller_ns.struct("ServerRegister") _LOGGER = logging.getLogger(__name__) -SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( - { - cv.Optional(CONF_ENABLED, default=False): cv.boolean, - cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t, - cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t, - } -) - -ModbusServerRegisterSchema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), - cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, - } -) - - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -85,12 +57,16 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMMAND_THROTTLE, default="0ms" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_SERVER_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA, + cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid( + "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" + ), cv.Optional(CONF_MAX_CMD_RETRIES, default=4): cv.positive_int, cv.Optional(CONF_OFFLINE_SKIP_UPDATES, default=0): cv.positive_int, cv.Optional( CONF_SERVER_REGISTERS, - ): cv.ensure_list(ModbusServerRegisterSchema), + ): cv.invalid( + "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" + ), cv.Optional(CONF_ON_COMMAND_SENT): automation.validate_automation({}), cv.Optional(CONF_ON_ONLINE): automation.validate_automation({}), cv.Optional(CONF_ON_OFFLINE): automation.validate_automation({}), @@ -142,11 +118,9 @@ def validate_modbus_register(config): def _final_validate(config): - if CONF_SERVER_COURTESY_RESPONSE in config or CONF_SERVER_REGISTERS in config: - return modbus.final_validate_modbus_device("modbus_controller", role="server")( - config - ) - return config + return modbus.final_validate_modbus_device("modbus_controller", role="client")( + config + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -228,53 +202,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE])) - if server_courtesy_response := config.get(CONF_SERVER_COURTESY_RESPONSE): - cg.add( - var.set_server_courtesy_response( - cg.StructInitializer( - ServerCourtesyResponse, - ("enabled", server_courtesy_response[CONF_ENABLED]), - ( - "register_last_address", - server_courtesy_response[CONF_REGISTER_LAST_ADDRESS], - ), - ("register_value", server_courtesy_response[CONF_REGISTER_VALUE]), - ) - ) - ) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - if CONF_SERVER_REGISTERS in config: - for server_register in config[CONF_SERVER_REGISTERS]: - server_register_var = cg.new_Pvariable( - server_register[CONF_ID], - server_register[CONF_ADDRESS], - server_register[CONF_VALUE_TYPE], - TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]], - ) - cpp_type = CPP_TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]] - cg.add( - server_register_var.set_read_lambda( - cg.TemplateArguments(cpp_type), - await cg.process_lambda( - server_register[CONF_READ_LAMBDA], - [(cg.uint16, "address")], - return_type=cpp_type, - ), - ) - ) - if CONF_WRITE_LAMBDA in server_register: - cg.add( - server_register_var.set_write_lambda( - cg.TemplateArguments(cpp_type), - await cg.process_lambda( - server_register[CONF_WRITE_LAMBDA], - parameters=[(cg.uint16, "address"), (cpp_type, "x")], - return_type=cg.bool_, - ), - ) - ) - cg.add(var.add_server_register(server_register_var)) await register_modbus_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index c689d84576..0149a3cc49 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -18,6 +18,7 @@ CONF_REGISTER_TYPE = "register_type" CONF_REGISTER_VALUE = "register_value" CONF_RESPONSE_SIZE = "response_size" CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" +CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" CONF_USE_WRITE_MULTIPLE = "use_write_multiple" CONF_VALUE_TYPE = "value_type" diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 5c3b39c954..dabed7136b 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -112,167 +112,6 @@ void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_ } } -void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { - ESP_LOGD(TAG, - "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - std::vector sixteen_bit_response; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool found = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - if (!server_register->read_lambda) { - break; - } - int64_t value = server_register->read_lambda(); - ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", - server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value).c_str()); - - std::vector payload; - payload.reserve(server_register->register_count * 2); - modbus::helpers::number_to_payload(payload, value, server_register->value_type); - sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { - if (this->server_courtesy_response_.enabled && - (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGD(TAG, - "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", - current_address, this->server_courtesy_response_.register_value); - sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); - current_address += 1; // Just increment by 1, as the default response is a single register - } else { - ESP_LOGW(TAG, - "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", - current_address); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - } - } - - std::vector response; - for (auto v : sixteen_bit_response) { - auto decoded_value = decode_value(v); - response.push_back(decoded_value[0]); - response.push_back(decoded_value[1]); - } - - this->send(function_code, start_address, number_of_registers, response.size(), response.data()); -} - -void ModbusController::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; - - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (data.size() < 5) { - ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - uint16_t payload_size = data[4]; - if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", - payload_size, number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (data.size() < 5 + payload_size) { - ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), - 5 + payload_size); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - if (data.size() < 4) { - ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = 1; - payload_offset = 2; - } else { - ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); - ESP_LOGD(TAG, - "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - auto for_each_register = [this, start_address, number_of_registers, payload_offset]( - const std::function &callback) -> bool { - uint16_t offset = payload_offset; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool ok = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - ok = callback(server_register, offset); - current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); - break; - } - } - - if (!ok) { - return false; - } - } - return true; - }; - - // check all registers are writable before writing to any of them: - if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { - return server_register->write_lambda != nullptr; - })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - // Actually write to the registers: - if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); - return server_register->write_lambda(number); - })) { - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; - } - - std::vector response; - response.reserve(6); - response.push_back(this->address_); - response.push_back(function_code); - response.insert(response.end(), data.begin(), data.begin() + 4); - this->send_raw(response); -} - SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const { auto reg_it = std::find_if( std::begin(this->register_ranges_), std::end(this->register_ranges_), @@ -472,14 +311,8 @@ void ModbusController::dump_config() { "ModbusController:\n" " Address: 0x%02X\n" " Max Command Retries: %d\n" - " Offline Skip Updates: %d\n" - " Server Courtesy Response:\n" - " Enabled: %s\n" - " Register Last Address: 0x%02X\n" - " Register Value: %d", - this->address_, this->max_cmd_retries_, this->offline_skip_updates_, - this->server_courtesy_response_.enabled ? "true" : "false", - this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value); + " Offline Skip Updates: %d\n", + this->address_, this->max_cmd_retries_, this->offline_skip_updates_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); @@ -493,11 +326,6 @@ void ModbusController::dump_config() { ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), it.start_address, it.register_count, it.skip_updates); } - ESP_LOGCONFIG(TAG, "server registers"); - for (auto &r : this->server_registers_) { - ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, - static_cast(r->value_type), r->register_count); - } #endif } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 6c6c748b73..40139f055b 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -120,82 +120,6 @@ class SensorItem { bool force_new_range{false}; }; -struct ServerCourtesyResponse { - bool enabled{false}; - uint16_t register_last_address{0xFFFF}; - uint16_t register_value{0}; -}; - -class ServerRegister { - using ReadLambda = std::function; - using WriteLambda = std::function; - - public: - ServerRegister(uint16_t address, SensorValueType value_type, uint8_t register_count) { - this->address = address; - this->value_type = value_type; - this->register_count = register_count; - } - - template void set_read_lambda(const std::function &&user_read_lambda) { - this->read_lambda = [this, user_read_lambda]() -> int64_t { - T user_value = user_read_lambda(this->address); - if constexpr (std::is_same_v) { - return bit_cast(user_value); - } else { - return static_cast(user_value); - } - }; - } - - template - void set_write_lambda(const std::function &&user_write_lambda) { - this->write_lambda = [this, user_write_lambda](int64_t number) { - if constexpr (std::is_same_v) { - float float_value = bit_cast(static_cast(number)); - return user_write_lambda(this->address, float_value); - } - return user_write_lambda(this->address, static_cast(number)); - }; - } - - // Formats a raw value into a string representation based on the value type for debugging - std::string format_value(int64_t value) const { - // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) - // plus null terminator = 43, rounded to 44 for 4-byte alignment - char buf[44]; - switch (this->value_type) { - case SensorValueType::U_WORD: - case SensorValueType::U_DWORD: - case SensorValueType::U_DWORD_R: - case SensorValueType::U_QWORD: - case SensorValueType::U_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); - return buf; - case SensorValueType::S_WORD: - case SensorValueType::S_DWORD: - case SensorValueType::S_DWORD_R: - case SensorValueType::S_QWORD: - case SensorValueType::S_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); - return buf; - case SensorValueType::FP32_R: - case SensorValueType::FP32: - buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); - return buf; - default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); - return buf; - } - } - - uint16_t address{0}; - SensorValueType value_type{SensorValueType::RAW}; - uint8_t register_count{0}; - ReadLambda read_lambda; - WriteLambda write_lambda; -}; - // ModbusController::create_register_ranges_ tries to optimize register range // for this the sensors must be ordered by register_type, start_address and bitmask class SensorItemsComparator { @@ -367,16 +291,10 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void queue_command(const ModbusCommandItem &command); /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } - /// Registers a server register with the controller. Called by esphomes code generator - void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus response was parsed without errors void on_modbus_data(const std::vector &data) override; /// called when a modbus error response was received void on_modbus_error(uint8_t function_code, uint8_t exception_code) override; - /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; - /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue void on_register_data(ModbusRegisterType register_type, uint16_t start_address, const std::vector &data); /// default delegate called by process_modbus_data when a response for a write response has retrieved from the @@ -413,12 +331,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void set_max_cmd_retries(uint8_t max_cmd_retries) { this->max_cmd_retries_ = max_cmd_retries; } /// get how many times a command will be (re)sent if no response is received uint8_t get_max_cmd_retries() { return this->max_cmd_retries_; } - /// Called by esphome generated code to set the server courtesy response object - void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { - this->server_courtesy_response_ = server_courtesy_response; - } - /// Get the server courtesy response object - ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: /// parse sensormap_ and create range of sequential addresses @@ -435,8 +347,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void dump_sensors_(); /// Collection of all sensors for this component SensorSet sensorset_; - /// Collection of all server registers for this component - std::vector server_registers_{}; /// Continuous range of modbus registers std::vector register_ranges_{}; /// Hold the pending requests to be sent @@ -461,9 +371,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { CallbackManager online_callback_{}; /// Server offline callback CallbackManager offline_callback_{}; - /// Server courtesy response - ServerCourtesyResponse server_courtesy_response_{ - .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; }; /** Convert vector response payload to float. diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py new file mode 100644 index 0000000000..5182bc05d1 --- /dev/null +++ b/esphome/components/modbus_server/__init__.py @@ -0,0 +1,124 @@ +import esphome.codegen as cg +from esphome.components import modbus +from esphome.components.const import CONF_ENABLED +from esphome.components.modbus.helpers import ( + CPP_TYPE_REGISTER_MAP, + SENSOR_VALUE_TYPE, + TYPE_REGISTER_MAP, +) +import esphome.config_validation as cv +from esphome.const import CONF_ADDRESS, CONF_ID + +from .const import ( + CONF_COURTESY_RESPONSE, + CONF_READ_LAMBDA, + CONF_REGISTER_LAST_ADDRESS, + CONF_REGISTER_VALUE, + CONF_REGISTERS, + CONF_VALUE_TYPE, + CONF_WRITE_LAMBDA, +) + +CODEOWNERS = ["@exciton"] + +AUTO_LOAD = ["modbus"] + +MULTI_CONF = True + +modbus_server_ns = cg.esphome_ns.namespace("modbus_server") +ModbusServer = modbus_server_ns.class_( + "ModbusServer", cg.Component, modbus.ModbusDevice +) + +ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") +ServerRegister = modbus_server_ns.struct("ServerRegister") + +SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=False): cv.boolean, + cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t, + cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t, + } +) + +ModbusServerRegisterSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerRegister), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA, + cv.Optional( + CONF_REGISTERS, + ): cv.ensure_list(ModbusServerRegisterSchema), + } + ).extend(modbus.modbus_device_schema(0x01)), +) + + +def _final_validate(config): + return modbus.final_validate_modbus_device("modbus_server", role="server")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + if server_courtesy_response := config.get(CONF_COURTESY_RESPONSE): + cg.add( + var.set_server_courtesy_response( + cg.StructInitializer( + ServerCourtesyResponse, + ("enabled", server_courtesy_response[CONF_ENABLED]), + ( + "register_last_address", + server_courtesy_response[CONF_REGISTER_LAST_ADDRESS], + ), + ("register_value", server_courtesy_response[CONF_REGISTER_VALUE]), + ) + ) + ) + if CONF_REGISTERS in config: + for server_register in config[CONF_REGISTERS]: + server_register_var = cg.new_Pvariable( + server_register[CONF_ID], + server_register[CONF_ADDRESS], + server_register[CONF_VALUE_TYPE], + TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]], + ) + cpp_type = CPP_TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]] + cg.add( + server_register_var.set_read_lambda( + cg.TemplateArguments(cpp_type), + await cg.process_lambda( + server_register[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cpp_type, + ), + ) + ) + if CONF_WRITE_LAMBDA in server_register: + cg.add( + server_register_var.set_write_lambda( + cg.TemplateArguments(cpp_type), + await cg.process_lambda( + server_register[CONF_WRITE_LAMBDA], + parameters=[(cg.uint16, "address"), (cpp_type, "x")], + return_type=cg.bool_, + ), + ) + ) + cg.add(var.add_server_register(server_register_var)) + cg.add(var.set_address(config[CONF_ADDRESS])) + await cg.register_component(var, config) + return await modbus.register_modbus_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py new file mode 100644 index 0000000000..f83211c207 --- /dev/null +++ b/esphome/components/modbus_server/const.py @@ -0,0 +1,7 @@ +CONF_REGISTER_LAST_ADDRESS = "register_last_address" +CONF_REGISTER_VALUE = "register_value" +CONF_VALUE_TYPE = "value_type" +CONF_COURTESY_RESPONSE = "courtesy_response" +CONF_READ_LAMBDA = "read_lambda" +CONF_WRITE_LAMBDA = "write_lambda" +CONF_REGISTERS = "registers" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp new file mode 100644 index 0000000000..0063da3a1d --- /dev/null +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -0,0 +1,192 @@ +#include "modbus_server.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::modbus_server { +using modbus::ModbusFunctionCode; +using modbus::ModbusExceptionCode; + +static const char *const TAG = "modbus_server"; + +void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + ESP_LOGD(TAG, + "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " + "0x%X.", + this->address_, function_code, start_address, number_of_registers); + + if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return; + } + + std::vector sixteen_bit_response; + for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + bool found = false; + for (auto *server_register : this->server_registers_) { + if (server_register->address == current_address) { + if (!server_register->read_lambda) { + break; + } + int64_t value = server_register->read_lambda(); + ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", + server_register->address, static_cast(server_register->value_type), + server_register->register_count, server_register->format_value(value).c_str()); + + std::vector payload; + payload.reserve(server_register->register_count * 2); + modbus::helpers::number_to_payload(payload, value, server_register->value_type); + sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); + current_address += server_register->register_count; + found = true; + break; + } + } + + if (!found) { + if (this->server_courtesy_response_.enabled && + (current_address <= this->server_courtesy_response_.register_last_address)) { + ESP_LOGD(TAG, + "Could not match any register to address 0x%02X, but default allowed. " + "Returning default value: %d.", + current_address, this->server_courtesy_response_.register_value); + sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); + current_address += 1; // Just increment by 1, as the default response is a single register + } else { + ESP_LOGW(TAG, + "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", + current_address); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return; + } + } + } + + std::vector response; + for (auto v : sixteen_bit_response) { + auto decoded_value = decode_value(v); + response.push_back(decoded_value[0]); + response.push_back(decoded_value[1]); + } + + this->send(function_code, start_address, number_of_registers, response.size(), response.data()); +} + +void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { + uint16_t number_of_registers; + uint16_t payload_offset; + + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (data.size() < 5) { + ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); + if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + uint16_t payload_size = data[4]; + if (payload_size != number_of_registers * 2) { + ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", + payload_size, number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (data.size() < 5 + payload_size) { + ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), + 5 + payload_size); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + payload_offset = 5; + } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + if (data.size() < 4) { + ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + number_of_registers = 1; + payload_offset = 2; + } else { + ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + + uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); + ESP_LOGD(TAG, + "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " + "0x%X.", + this->address_, function_code, start_address, number_of_registers); + + auto for_each_register = [this, start_address, number_of_registers, payload_offset]( + const std::function &callback) -> bool { + uint16_t offset = payload_offset; + for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + bool ok = false; + for (auto *server_register : this->server_registers_) { + if (server_register->address == current_address) { + ok = callback(server_register, offset); + current_address += server_register->register_count; + offset += server_register->register_count * sizeof(uint16_t); + break; + } + } + + if (!ok) { + return false; + } + } + return true; + }; + + // check all registers are writable before writing to any of them: + if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { + return server_register->write_lambda != nullptr; + })) { + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + + // Actually write to the registers: + if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { + int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); + return server_register->write_lambda(number); + })) { + this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + std::vector response; + response.reserve(6); + response.push_back(this->address_); + response.push_back(function_code); + response.insert(response.end(), data.begin(), data.begin() + 4); + this->send_raw(response); +} + +void ModbusServer::dump_config() { + ESP_LOGCONFIG(TAG, + "ModbusServer:\n" + " Address: 0x%02X\n" + " Server Courtesy Response:\n" + " Enabled: %s\n" + " Register Last Address: 0x%02X\n" + " Register Value: %" PRIu16, + this->address_, this->server_courtesy_response_.enabled ? "true" : "false", + this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGCONFIG(TAG, "server registers"); + for (auto &r : this->server_registers_) { + ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, + static_cast(r->value_type), r->register_count); + } +#endif +} + +} // namespace esphome::modbus_server diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h new file mode 100644 index 0000000000..0fc2e0bef5 --- /dev/null +++ b/esphome/components/modbus_server/modbus_server.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/component.h" + +#include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/automation.h" + +#include +#include + +namespace esphome::modbus_server { + +using modbus::helpers::SensorValueType; + +struct ServerCourtesyResponse { + bool enabled{false}; + uint16_t register_last_address{0xFFFF}; + uint16_t register_value{0}; +}; + +class ServerRegister { + using ReadLambda = std::function; + using WriteLambda = std::function; + + public: + ServerRegister(uint16_t address, SensorValueType value_type, uint8_t register_count) { + this->address = address; + this->value_type = value_type; + this->register_count = register_count; + } + + template void set_read_lambda(const std::function &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> int64_t { + T user_value = user_read_lambda(this->address); + if constexpr (std::is_same_v) { + return bit_cast(user_value); + } else { + return static_cast(user_value); + } + }; + } + + template + void set_write_lambda(const std::function &&user_write_lambda) { + this->write_lambda = [this, user_write_lambda](int64_t number) { + if constexpr (std::is_same_v) { + float float_value = bit_cast(static_cast(number)); + return user_write_lambda(this->address, float_value); + } + return user_write_lambda(this->address, static_cast(number)); + }; + } + + // Formats a raw value into a string representation based on the value type for debugging + std::string format_value(int64_t value) const { + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + char buf[44]; + switch (this->value_type) { + case SensorValueType::U_WORD: + case SensorValueType::U_DWORD: + case SensorValueType::U_DWORD_R: + case SensorValueType::U_QWORD: + case SensorValueType::U_QWORD_R: + buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + return buf; + case SensorValueType::S_WORD: + case SensorValueType::S_DWORD: + case SensorValueType::S_DWORD_R: + case SensorValueType::S_QWORD: + case SensorValueType::S_QWORD_R: + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; + case SensorValueType::FP32_R: + case SensorValueType::FP32: + buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + return buf; + default: + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; + } + } + + uint16_t address{0}; + SensorValueType value_type{SensorValueType::RAW}; + uint8_t register_count{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + +class ModbusServer : public Component, public modbus::ModbusDevice { + public: + void dump_config() override; + + /// Not used for ModbusServer. + void on_modbus_data(const std::vector &data) override{}; + /// Registers a server register with the controller. Called by esphomes code generator + void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors + void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; + /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors + void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; + /// Called by esphome generated code to set the server courtesy response object + void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { + this->server_courtesy_response_ = server_courtesy_response; + } + /// Get the server courtesy response object + ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } + + protected: + /// Collection of all server registers for this component + std::vector server_registers_{}; + /// Server courtesy response + ServerCourtesyResponse server_courtesy_response_{ + .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; +}; + +} // namespace esphome::modbus_server diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index ffaa1491c5..51951a4528 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -1,53 +1,11 @@ -modbus: - - id: mod_bus2 - uart_id: uart_bus - role: server - modbus_controller: - id: modbus_controller1 address: 0x2 modbus_id: modbus_bus - allow_duplicate_commands: false on_online: then: logger.log: "Module Online" - - id: modbus_controller2 - address: 0x2 - modbus_id: mod_bus2 - server_registers: - - address: 0x0000 - value_type: S_DWORD_R - read_lambda: |- - return 42.3; - max_cmd_retries: 0 - - id: modbus_controller3 - address: 0x3 - modbus_id: mod_bus2 - server_registers: - - address: 0x0009 - value_type: S_DWORD - read_lambda: |- - return 31; - write_lambda: |- - printf("address=%d, value=%d", x); - return true; - max_cmd_retries: 0 - - id: modbus_controller4 - modbus_id: mod_bus2 - address: 0x4 - server_courtesy_response: - enabled: true - register_last_address: 100 - register_value: 0 - server_registers: - - address: 0x0001 - value_type: U_WORD - read_lambda: |- - return 0x8; - - address: 0x0005 - value_type: U_WORD - read_lambda: |- - return (random_uint32() % 100); + binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml new file mode 100644 index 0000000000..3522c9248c --- /dev/null +++ b/tests/components/modbus_server/common.yaml @@ -0,0 +1,41 @@ +modbus: + - id: mod_bus2 + uart_id: uart_bus + role: server + +modbus_server: + - id: modbus_server2 + address: 0x2 + modbus_id: mod_bus2 + registers: + - address: 0x0 + value_type: S_DWORD_R + read_lambda: |- + return 42.3; + - id: modbus_server3 + address: 0x3 + modbus_id: mod_bus2 + registers: + - address: 0x9 + value_type: S_DWORD + read_lambda: |- + return 31; + write_lambda: |- + printf("address=%d, value=%d", x); + return true; + - id: modbus_server4 + modbus_id: mod_bus2 + address: 0x4 + courtesy_response: + enabled: true + register_last_address: 100 + register_value: 0 + registers: + - address: 0x1 + value_type: U_WORD + read_lambda: |- + return 0x8; + - address: 0x5 + value_type: U_WORD + read_lambda: |- + return (random_uint32() % 100); diff --git a/tests/components/modbus_server/test.esp32-idf.yaml b/tests/components/modbus_server/test.esp32-idf.yaml new file mode 100644 index 0000000000..ace2d95a0b --- /dev/null +++ b/tests/components/modbus_server/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/modbus_server/test.esp8266-ard.yaml b/tests/components/modbus_server/test.esp8266-ard.yaml new file mode 100644 index 0000000000..560629b0cd --- /dev/null +++ b/tests/components/modbus_server/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/modbus_server/test.rp2040-ard.yaml b/tests/components/modbus_server/test.rp2040-ard.yaml new file mode 100644 index 0000000000..eeebbd2a8a --- /dev/null +++ b/tests/components/modbus_server/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml index b657a6fd21..cc5a59e242 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server.yaml @@ -86,9 +86,9 @@ modbus: uart_id: virtual_uart_dev role: server -modbus_controller: +modbus_server: - address: 1 - server_registers: + registers: - address: 0x03 value_type: U_WORD read_lambda: |- diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index f0f2c56a36..1e5f5a3389 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -33,7 +33,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -56,10 +56,11 @@ modbus_controller: update_interval: 1s id: modbus_controller_1 +modbus_server: - address: 1 modbus_id: virtual_modbus_server id: modbus_server_1 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 99; diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml index 7ec67b03db..e68edd2271 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -36,7 +36,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_server_2 baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -48,7 +48,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -81,15 +81,16 @@ modbus_controller: update_interval: 1s id: modbus_controller_2 +modbus_server: - address: 1 modbus_id: virtual_modbus_server - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 919; - address: 2 modbus_id: virtual_modbus_server_2 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 929; diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index 3edcc73f07..94890e90de 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -33,7 +33,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -94,10 +94,11 @@ modbus_controller: update_interval: 2s id: modbus_controller_1 +modbus_server: - address: 1 modbus_id: virtual_modbus_server id: modbus_server_1 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return id(stored_u_word);