mirror of
https://github.com/esphome/esphome.git
synced 2026-09-16 01:28:39 +00:00
Merge branch 'dev' into webserver-list-entities-xmacro
This commit is contained in:
@@ -108,6 +108,34 @@ jobs:
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2040-boards.py --check
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.import-time == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Check import time against budget and write waterfall HAR
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
script/check_import_time.py --check --har importtime.har
|
||||
- name: Upload waterfall HAR
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: import-time-waterfall
|
||||
path: importtime.har
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
strategy:
|
||||
@@ -176,6 +204,7 @@ jobs:
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
import-time: ${{ steps.determine.outputs.import-time }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -219,6 +248,7 @@ jobs:
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<uint16_t> 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<size_t>(server_register->value_type),
|
||||
server_register->register_count, server_register->format_value(value).c_str());
|
||||
|
||||
std::vector<uint16_t> 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<uint8_t> 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<uint8_t> &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<bool(ServerRegister *, uint16_t offset)> &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<uint8_t> 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<uint8_t>(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<uint8_t>(r->value_type), r->register_count);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -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<int64_t()>;
|
||||
using WriteLambda = std::function<bool(int64_t value)>;
|
||||
|
||||
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<typename T> void set_read_lambda(const std::function<T(uint16_t address)> &&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<T, float>) {
|
||||
return bit_cast<uint32_t>(user_value);
|
||||
} else {
|
||||
return static_cast<int64_t>(user_value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void set_write_lambda(const std::function<bool(uint16_t address, const T v)> &&user_write_lambda) {
|
||||
this->write_lambda = [this, user_write_lambda](int64_t number) {
|
||||
if constexpr (std::is_same_v<T, float>) {
|
||||
float float_value = bit_cast<float>(static_cast<uint32_t>(number));
|
||||
return user_write_lambda(this->address, float_value);
|
||||
}
|
||||
return user_write_lambda(this->address, static_cast<T>(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<uint64_t>(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<float>(static_cast<uint32_t>(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<uint8_t> &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<uint8_t> &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<uint8_t> &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<ServerRegister *> server_registers_{};
|
||||
/// Continuous range of modbus registers
|
||||
std::vector<RegisterRange> register_ranges_{};
|
||||
/// Hold the pending requests to be sent
|
||||
@@ -461,9 +371,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice {
|
||||
CallbackManager<void(int, int)> online_callback_{};
|
||||
/// Server offline callback
|
||||
CallbackManager<void(int, int)> offline_callback_{};
|
||||
/// Server courtesy response
|
||||
ServerCourtesyResponse server_courtesy_response_{
|
||||
.enabled = false, .register_last_address = 0xFFFF, .register_value = 0};
|
||||
};
|
||||
|
||||
/** Convert vector<uint8_t> response payload to float.
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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<uint16_t> 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<size_t>(server_register->value_type),
|
||||
server_register->register_count, server_register->format_value(value).c_str());
|
||||
|
||||
std::vector<uint16_t> 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<uint8_t> 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<uint8_t> &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<bool(ServerRegister *, uint16_t offset)> &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<uint8_t> 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<uint8_t>(r->value_type), r->register_count);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus_server
|
||||
@@ -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 <utility>
|
||||
#include <vector>
|
||||
|
||||
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<int64_t()>;
|
||||
using WriteLambda = std::function<bool(int64_t value)>;
|
||||
|
||||
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<typename T> void set_read_lambda(const std::function<T(uint16_t address)> &&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<T, float>) {
|
||||
return bit_cast<uint32_t>(user_value);
|
||||
} else {
|
||||
return static_cast<int64_t>(user_value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void set_write_lambda(const std::function<bool(uint16_t address, const T v)> &&user_write_lambda) {
|
||||
this->write_lambda = [this, user_write_lambda](int64_t number) {
|
||||
if constexpr (std::is_same_v<T, float>) {
|
||||
float float_value = bit_cast<float>(static_cast<uint32_t>(number));
|
||||
return user_write_lambda(this->address, float_value);
|
||||
}
|
||||
return user_write_lambda(this->address, static_cast<T>(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<uint64_t>(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<float>(static_cast<uint32_t>(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<uint8_t> &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<uint8_t> &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<ServerRegister *> server_registers_{};
|
||||
/// Server courtesy response
|
||||
ServerCourtesyResponse server_courtesy_response_{
|
||||
.enabled = false, .register_last_address = 0xFFFF, .register_value = 0};
|
||||
};
|
||||
|
||||
} // namespace esphome::modbus_server
|
||||
@@ -792,6 +792,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
},
|
||||
# Per-platform wake implementations — wake.h dispatches to exactly one of
|
||||
# these based on USE_*, so the others can be skipped at the source level
|
||||
# too. Header files next to each .cpp are always copied (the dispatcher
|
||||
# #include's them) but compile to empty TUs on the wrong platform anyway.
|
||||
"wake/wake_freertos.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
},
|
||||
"wake/wake_esp8266.cpp": {
|
||||
PlatformFramework.ESP8266_ARDUINO,
|
||||
},
|
||||
"wake/wake_rp2040.cpp": {
|
||||
PlatformFramework.RP2040_ARDUINO,
|
||||
},
|
||||
"wake/wake_host.cpp": {
|
||||
PlatformFramework.HOST_NATIVE,
|
||||
},
|
||||
"wake/wake_generic.cpp": {
|
||||
PlatformFramework.NRF52_ZEPHYR,
|
||||
},
|
||||
# Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered
|
||||
# as they are only included when needed by the preprocessor
|
||||
}
|
||||
|
||||
+14
-1
@@ -17,8 +17,21 @@
|
||||
#define ESPHOME_DEBUG_SCHEDULER
|
||||
#define ESPHOME_DEBUG_API
|
||||
|
||||
// Default threading model for static analysis (ESP32 is multi-threaded with atomics)
|
||||
// Threading model for static analysis. Match what the real codegen picks per
|
||||
// platform (see esphome/components/<platform>/__init__.py ThreadModel.*):
|
||||
// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE
|
||||
// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS
|
||||
// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS
|
||||
// Without this the clang-tidy envs end up with USE_<single-threaded platform>
|
||||
// + MULTI_ATOMICS simultaneously, a combination that can never occur in a
|
||||
// real build.
|
||||
#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52)
|
||||
#define ESPHOME_THREAD_SINGLE
|
||||
#elif defined(USE_BK72XX)
|
||||
#define ESPHOME_THREAD_MULTI_NO_ATOMICS
|
||||
#else
|
||||
#define ESPHOME_THREAD_MULTI_ATOMICS
|
||||
#endif
|
||||
|
||||
// logger
|
||||
#define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
|
||||
@@ -22,8 +22,8 @@ static const char *const TAG = "time_64";
|
||||
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Storage for Millis64Impl inline compute() — defined here so all TUs share one copy.
|
||||
uint32_t Millis64Impl::last_millis_{0};
|
||||
uint16_t Millis64Impl::millis_major_{0};
|
||||
uint32_t Millis64Impl::last_millis{0};
|
||||
uint16_t Millis64Impl::millis_major{0};
|
||||
#else
|
||||
|
||||
uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
|
||||
@@ -21,8 +21,8 @@ class Millis64Impl {
|
||||
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Storage defined in time_64.cpp — declared here so the inline body can access them.
|
||||
static uint32_t last_millis_;
|
||||
static uint16_t millis_major_;
|
||||
static uint32_t last_millis;
|
||||
static uint16_t millis_major;
|
||||
|
||||
static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) {
|
||||
// Half the 32-bit range - used to detect rollovers vs normal time progression
|
||||
@@ -30,17 +30,17 @@ class Millis64Impl {
|
||||
|
||||
// Single-core platforms have no concurrency, so this is a simple implementation
|
||||
// that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics.
|
||||
uint16_t major = millis_major_;
|
||||
uint32_t last = last_millis_;
|
||||
uint16_t major = millis_major;
|
||||
uint32_t last = last_millis;
|
||||
|
||||
// Check for rollover
|
||||
if (now < last && (last - now) > HALF_MAX_UINT32) {
|
||||
millis_major_++;
|
||||
millis_major++;
|
||||
major++;
|
||||
last_millis_ = now;
|
||||
last_millis = now;
|
||||
} else if (now > last) {
|
||||
// Only update if time moved forward
|
||||
last_millis_ = now;
|
||||
last_millis = now;
|
||||
}
|
||||
|
||||
// Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
|
||||
|
||||
+19
-195
@@ -3,6 +3,10 @@
|
||||
/// @file wake.h
|
||||
/// Platform-specific main loop wake primitives.
|
||||
/// Always available on all platforms — no opt-in needed.
|
||||
///
|
||||
/// The public API for callers lives here; the per-platform implementations
|
||||
/// live under esphome/core/wake/ and are included at the bottom of this file
|
||||
/// based on the active USE_* platform define.
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
@@ -11,21 +15,6 @@
|
||||
#include <atomic>
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#include "esphome/core/main_task.h"
|
||||
#endif
|
||||
#ifdef USE_ESP8266
|
||||
#include <coredecls.h>
|
||||
#elif defined(USE_RP2040)
|
||||
#include <hardware/sync.h>
|
||||
#include <pico/time.h>
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake flag for ESP8266/RP2040 ===
|
||||
@@ -67,184 +56,19 @@ __attribute__((always_inline)) inline bool wake_request_take() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// === ESP32 / LibreTiny (FreeRTOS) ===
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
|
||||
/// Wake the main loop from any context (ISR or task).
|
||||
/// always_inline so callers placed in IRAM keep the whole wake path in IRAM.
|
||||
__attribute__((always_inline)) inline void wake_main_task_any_context() {
|
||||
// Set the wake-requested flag BEFORE the task notification so the consumer
|
||||
// (Application::loop() gate) is guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
if (in_isr_context()) {
|
||||
BaseType_t px_higher_priority_task_woken = pdFALSE;
|
||||
esphome_main_task_notify_from_isr(&px_higher_priority_task_woken);
|
||||
#ifdef portYIELD_FROM_ISR
|
||||
portYIELD_FROM_ISR(px_higher_priority_task_woken);
|
||||
#else
|
||||
// ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ
|
||||
// exit sequence performs the context switch if one was requested.
|
||||
(void) px_higher_priority_task_woken;
|
||||
#endif
|
||||
} else {
|
||||
esphome_main_task_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// IRAM_ATTR entry points — defined in wake.cpp.
|
||||
void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken);
|
||||
void wake_loop_any_context();
|
||||
|
||||
inline void wake_loop_threadsafe() {
|
||||
wake_request_set();
|
||||
esphome_main_task_notify();
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
// Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip
|
||||
// event_callback wrapper (see lwip_fast_select.c) are the single source of truth for
|
||||
// socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification
|
||||
// that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns
|
||||
// immediately) or wakes a blocked Take directly. Additional wake sources:
|
||||
// wake_loop_threadsafe() from background tasks, and the ms timeout.
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms));
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
// === ESP8266 ===
|
||||
#elif defined(USE_ESP8266)
|
||||
|
||||
/// Inline implementation — IRAM callers inline this directly.
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
|
||||
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
|
||||
// guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
g_main_loop_woke = true;
|
||||
esp_schedule();
|
||||
}
|
||||
|
||||
/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp.
|
||||
void wake_loop_any_context();
|
||||
|
||||
/// Non-ISR: always inline.
|
||||
inline void wake_loop_threadsafe() { wake_loop_impl(); }
|
||||
|
||||
/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR.
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); }
|
||||
|
||||
namespace internal {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
delay(0);
|
||||
return;
|
||||
}
|
||||
if (g_main_loop_woke) {
|
||||
g_main_loop_woke = false;
|
||||
return;
|
||||
}
|
||||
esp_delay(ms, []() { return !g_main_loop_woke; });
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
// === RP2040 ===
|
||||
#elif defined(USE_RP2040)
|
||||
|
||||
inline void wake_loop_any_context() {
|
||||
// Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed
|
||||
// to see it on its next gate check.
|
||||
wake_request_set();
|
||||
g_main_loop_woke = true;
|
||||
__sev();
|
||||
}
|
||||
|
||||
inline void wake_loop_threadsafe() { wake_loop_any_context(); }
|
||||
|
||||
/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp.
|
||||
namespace internal {
|
||||
void wakeable_delay(uint32_t ms);
|
||||
} // namespace internal
|
||||
|
||||
// === Host / Zephyr / other ===
|
||||
#else
|
||||
|
||||
#ifdef USE_HOST
|
||||
/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp.
|
||||
void wake_loop_threadsafe();
|
||||
|
||||
/// Register a socket file descriptor with the host select() loop. Not
|
||||
/// thread-safe — main loop only. Returns false if fd is invalid or
|
||||
/// >= FD_SETSIZE.
|
||||
bool wake_register_fd(int fd);
|
||||
|
||||
/// Unregister a socket file descriptor. Not thread-safe — main loop only.
|
||||
void wake_unregister_fd(int fd);
|
||||
|
||||
/// One-time setup of the loopback wake socket. Called from Application::setup().
|
||||
void wake_setup();
|
||||
|
||||
// wake_fd_ready() and wake_drain_notifications() are defined inline at the
|
||||
// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in
|
||||
// scope, which depend on USE_HOST-only includes pulled in above.
|
||||
#else
|
||||
/// Zephyr is currently the only platform without a wake mechanism.
|
||||
/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay().
|
||||
/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar.
|
||||
inline void wake_loop_threadsafe() {}
|
||||
#endif
|
||||
|
||||
inline void wake_loop_any_context() { wake_loop_threadsafe(); }
|
||||
|
||||
namespace internal {
|
||||
#ifdef USE_HOST
|
||||
/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp.
|
||||
void wakeable_delay(uint32_t ms);
|
||||
#else
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
delay(ms);
|
||||
}
|
||||
#endif
|
||||
} // namespace internal
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
namespace internal {
|
||||
// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications()
|
||||
// and wake_fd_ready() so the hot path stays in the header.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern int g_wake_socket_fd;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern fd_set g_read_fds;
|
||||
} // namespace internal
|
||||
|
||||
inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); }
|
||||
|
||||
// Small buffer for draining wake notification bytes (1 byte sent per wake).
|
||||
// Sized to drain multiple notifications per recvfrom() without wasting stack.
|
||||
inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16;
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() {
|
||||
// Called from main loop to drain any pending wake notifications.
|
||||
// Must check wake_fd_ready() to avoid blocking on empty socket.
|
||||
if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) {
|
||||
char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE];
|
||||
// Drain all pending notifications with non-blocking reads. Multiple wake events
|
||||
// may have triggered multiple writes, so drain until EWOULDBLOCK. We control
|
||||
// both ends of this loopback socket (always 1 byte per wake), so no error
|
||||
// checking — any error indicates catastrophic system failure.
|
||||
while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
// Per-platform implementations. Each header re-enters namespace esphome {} and
|
||||
// guards its body with the matching USE_* check, so only one contributes code
|
||||
// for the active target.
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#include "esphome/core/wake/wake_freertos.h"
|
||||
#elif defined(USE_ESP8266)
|
||||
#include "esphome/core/wake/wake_esp8266.h"
|
||||
#elif defined(USE_RP2040)
|
||||
#include "esphome/core/wake/wake_rp2040.h"
|
||||
#elif defined(USE_HOST)
|
||||
#include "esphome/core/wake/wake_host.h"
|
||||
#else
|
||||
#include "esphome/core/wake/wake_generic.h"
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake-requested flag + main-loop woke flag storage ===
|
||||
// ESP8266 is always ESPHOME_THREAD_SINGLE.
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile uint8_t g_wake_requested = 0;
|
||||
volatile bool g_main_loop_woke = false;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); }
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP8266
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <coredecls.h>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Inline implementation — IRAM callers inline this directly.
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
|
||||
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
|
||||
// guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
g_main_loop_woke = true;
|
||||
esp_schedule();
|
||||
}
|
||||
|
||||
/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp.
|
||||
void wake_loop_any_context();
|
||||
|
||||
/// Non-ISR: always inline.
|
||||
inline void wake_loop_threadsafe() { wake_loop_impl(); }
|
||||
|
||||
/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR.
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); }
|
||||
|
||||
namespace internal {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
delay(0);
|
||||
return;
|
||||
}
|
||||
if (g_main_loop_woke) {
|
||||
g_main_loop_woke = false;
|
||||
return;
|
||||
}
|
||||
esp_delay(ms, []() { return !g_main_loop_woke; });
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP8266
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake-requested flag storage ===
|
||||
// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with
|
||||
// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX).
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
std::atomic<uint8_t> g_wake_requested{0};
|
||||
#else
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile uint8_t g_wake_requested = 0;
|
||||
#endif
|
||||
|
||||
void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) {
|
||||
// ISR-safe: set flag before notify so the wake is visible on the next gate
|
||||
// check. wake_request_set() is just an aligned 8-bit store / atomic store
|
||||
// and is safe from IRAM.
|
||||
wake_request_set();
|
||||
esphome_main_task_notify_from_isr(px_higher_priority_task_woken);
|
||||
}
|
||||
|
||||
void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); }
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP32 || USE_LIBRETINY
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/main_task.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Wake the main loop from any context (ISR or task).
|
||||
/// always_inline so callers placed in IRAM keep the whole wake path in IRAM.
|
||||
__attribute__((always_inline)) inline void wake_main_task_any_context() {
|
||||
// Set the wake-requested flag BEFORE the task notification so the consumer
|
||||
// (Application::loop() gate) is guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
if (in_isr_context()) {
|
||||
BaseType_t px_higher_priority_task_woken = pdFALSE;
|
||||
esphome_main_task_notify_from_isr(&px_higher_priority_task_woken);
|
||||
#ifdef portYIELD_FROM_ISR
|
||||
portYIELD_FROM_ISR(px_higher_priority_task_woken);
|
||||
#else
|
||||
// ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ
|
||||
// exit sequence performs the context switch if one was requested.
|
||||
(void) px_higher_priority_task_woken;
|
||||
#endif
|
||||
} else {
|
||||
esphome_main_task_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// IRAM_ATTR entry points — defined in wake_freertos.cpp.
|
||||
void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken);
|
||||
void wake_loop_any_context();
|
||||
|
||||
inline void wake_loop_threadsafe() {
|
||||
wake_request_set();
|
||||
esphome_main_task_notify();
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
// Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip
|
||||
// event_callback wrapper (see lwip_fast_select.c) are the single source of truth for
|
||||
// socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification
|
||||
// that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns
|
||||
// immediately) or wakes a blocked Take directly. Additional wake sources:
|
||||
// wake_loop_threadsafe() from background tasks, and the ms timeout.
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms));
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP32 || USE_LIBRETINY
|
||||
@@ -0,0 +1,17 @@
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \
|
||||
!defined(USE_HOST)
|
||||
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake-requested flag storage ===
|
||||
// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile uint8_t g_wake_requested = 0;
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // fallback guard
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \
|
||||
!defined(USE_HOST)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Zephyr is currently the only platform without a wake mechanism.
|
||||
/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay().
|
||||
/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar.
|
||||
inline void wake_loop_threadsafe() {}
|
||||
|
||||
inline void wake_loop_any_context() { wake_loop_threadsafe(); }
|
||||
|
||||
namespace internal {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
delay(ms);
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // fallback guard
|
||||
@@ -1,12 +1,11 @@
|
||||
#include "esphome/core/wake.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <coredecls.h>
|
||||
#endif
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
@@ -15,88 +14,19 @@
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake-requested flag storage ===
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
// Host is always ESPHOME_THREAD_MULTI_ATOMICS.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
std::atomic<uint8_t> g_wake_requested{0};
|
||||
#else
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile uint8_t g_wake_requested = 0;
|
||||
#endif
|
||||
|
||||
// === ESP32 / LibreTiny — IRAM_ATTR entry points ===
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) {
|
||||
// ISR-safe: set flag before notify so the wake is visible on the next gate
|
||||
// check. wake_request_set() is just an aligned 8-bit store / atomic store
|
||||
// and is safe from IRAM.
|
||||
wake_request_set();
|
||||
esphome_main_task_notify_from_isr(px_higher_priority_task_woken);
|
||||
}
|
||||
void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); }
|
||||
#endif
|
||||
|
||||
// === ESP8266 / RP2040 ===
|
||||
#if defined(USE_ESP8266) || defined(USE_RP2040)
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile bool g_main_loop_woke = false;
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); }
|
||||
#endif
|
||||
|
||||
// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) ===
|
||||
#ifdef USE_RP2040
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static volatile bool s_delay_expired = false;
|
||||
|
||||
static int64_t alarm_callback_(alarm_id_t id, void *user_data) {
|
||||
(void) id;
|
||||
(void) user_data;
|
||||
s_delay_expired = true;
|
||||
__sev();
|
||||
return 0;
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
void wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
if (g_main_loop_woke) {
|
||||
g_main_loop_woke = false;
|
||||
return;
|
||||
}
|
||||
s_delay_expired = false;
|
||||
alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true);
|
||||
if (alarm <= 0) {
|
||||
delay(ms);
|
||||
return;
|
||||
}
|
||||
while (!g_main_loop_woke && !s_delay_expired) {
|
||||
__wfe();
|
||||
}
|
||||
if (!s_delay_expired)
|
||||
cancel_alarm(alarm);
|
||||
g_main_loop_woke = false;
|
||||
}
|
||||
} // namespace internal
|
||||
#endif // USE_RP2040
|
||||
|
||||
// === Host (UDP loopback socket + select() based fd watcher) ===
|
||||
#ifdef USE_HOST
|
||||
|
||||
static const char *const TAG = "wake";
|
||||
|
||||
namespace internal {
|
||||
// File-scope state — referenced inline by wake_drain_notifications() and
|
||||
// wake_fd_ready() in wake.h, and by the bodies in this file.
|
||||
// wake_fd_ready() in wake_host.h, and by the bodies in this file.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
int g_wake_socket_fd = -1;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -271,6 +201,7 @@ void wake_setup() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp.
|
||||
void wake_loop_threadsafe();
|
||||
|
||||
/// Register a socket file descriptor with the host select() loop. Not
|
||||
/// thread-safe — main loop only. Returns false if fd is invalid or
|
||||
/// >= FD_SETSIZE.
|
||||
bool wake_register_fd(int fd);
|
||||
|
||||
/// Unregister a socket file descriptor. Not thread-safe — main loop only.
|
||||
void wake_unregister_fd(int fd);
|
||||
|
||||
/// One-time setup of the loopback wake socket. Called from Application::setup().
|
||||
void wake_setup();
|
||||
|
||||
inline void wake_loop_any_context() { wake_loop_threadsafe(); }
|
||||
|
||||
namespace internal {
|
||||
/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp.
|
||||
void wakeable_delay(uint32_t ms);
|
||||
|
||||
// File-scope state owned by wake_host.cpp. Accessed inline by
|
||||
// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern int g_wake_socket_fd;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern fd_set g_read_fds;
|
||||
} // namespace internal
|
||||
|
||||
inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); }
|
||||
|
||||
// Small buffer for draining wake notification bytes (1 byte sent per wake).
|
||||
// Sized to drain multiple notifications per recvfrom() without wasting stack.
|
||||
inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16;
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() {
|
||||
// Called from main loop to drain any pending wake notifications.
|
||||
// Must check wake_fd_ready() to avoid blocking on empty socket.
|
||||
if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) {
|
||||
char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE];
|
||||
// Drain all pending notifications with non-blocking reads. Multiple wake events
|
||||
// may have triggered multiple writes, so drain until EWOULDBLOCK. We control
|
||||
// both ends of this loopback socket (always 1 byte per wake), so no error
|
||||
// checking — any error indicates catastrophic system failure.
|
||||
while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_RP2040
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
#include <hardware/sync.h>
|
||||
#include <pico/time.h>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake-requested flag + main-loop woke flag storage ===
|
||||
// RP2040 is always ESPHOME_THREAD_SINGLE.
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile uint8_t g_wake_requested = 0;
|
||||
volatile bool g_main_loop_woke = false;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static volatile bool s_delay_expired = false;
|
||||
|
||||
static int64_t alarm_callback_(alarm_id_t id, void *user_data) {
|
||||
(void) id;
|
||||
(void) user_data;
|
||||
s_delay_expired = true;
|
||||
__sev();
|
||||
return 0;
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
void wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
if (g_main_loop_woke) {
|
||||
g_main_loop_woke = false;
|
||||
return;
|
||||
}
|
||||
s_delay_expired = false;
|
||||
alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true);
|
||||
if (alarm <= 0) {
|
||||
delay(ms);
|
||||
return;
|
||||
}
|
||||
while (!g_main_loop_woke && !s_delay_expired) {
|
||||
__wfe();
|
||||
}
|
||||
if (!s_delay_expired)
|
||||
cancel_alarm(alarm);
|
||||
g_main_loop_woke = false;
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_RP2040
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_RP2040
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <hardware/sync.h>
|
||||
#include <pico/time.h>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
inline void wake_loop_any_context() {
|
||||
// Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed
|
||||
// to see it on its next gate check.
|
||||
wake_request_set();
|
||||
g_main_loop_woke = true;
|
||||
__sev();
|
||||
}
|
||||
|
||||
inline void wake_loop_threadsafe() { wake_loop_any_context(); }
|
||||
|
||||
/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp.
|
||||
namespace internal {
|
||||
void wakeable_delay(uint32_t ms);
|
||||
} // namespace internal
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_RP2040
|
||||
+31
-19
@@ -31,8 +31,9 @@ class FileResource:
|
||||
|
||||
|
||||
class ComponentManifest:
|
||||
def __init__(self, module: ModuleType):
|
||||
def __init__(self, module: ModuleType, recursive_sources: bool = False):
|
||||
self.module = module
|
||||
self.recursive_sources = recursive_sources
|
||||
|
||||
@property
|
||||
def package(self) -> str:
|
||||
@@ -108,8 +109,10 @@ class ComponentManifest:
|
||||
def resources(self) -> list[FileResource]:
|
||||
"""Return a list of all file resources defined in the package of this component.
|
||||
|
||||
This will return all cpp source files that are located in the same folder as the
|
||||
loaded .py file (does not look through subdirectories)
|
||||
By default only files directly in the package directory are returned. Manifests
|
||||
constructed with ``recursive_sources=True`` also descend into non-subpackage
|
||||
subdirectories (subdirectories without an ``__init__.py``), so core code can
|
||||
live under ``esphome/core/<group>/`` without every component paying the cost.
|
||||
"""
|
||||
ret: list[FileResource] = []
|
||||
|
||||
@@ -121,23 +124,30 @@ class ComponentManifest:
|
||||
set(filter_source_files_func()) if filter_source_files_func else set()
|
||||
)
|
||||
|
||||
# Process all resources
|
||||
for resource in (
|
||||
r.name
|
||||
for r in importlib.resources.files(self.package).iterdir()
|
||||
if r.is_file()
|
||||
):
|
||||
if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS:
|
||||
continue
|
||||
if not importlib.resources.files(self.package).joinpath(resource).is_file():
|
||||
# Not a resource = this is a directory (yeah this is confusing)
|
||||
continue
|
||||
root = importlib.resources.files(self.package)
|
||||
|
||||
# Skip excluded files
|
||||
if resource in excluded_files:
|
||||
continue
|
||||
for child in root.iterdir():
|
||||
name = child.name
|
||||
if child.is_file():
|
||||
if Path(name).suffix not in SOURCE_FILE_EXTENSIONS:
|
||||
continue
|
||||
if name in excluded_files:
|
||||
continue
|
||||
ret.append(FileResource(self.package, name))
|
||||
elif self.recursive_sources and child.is_dir() and name != "__pycache__":
|
||||
# Skip Python subpackages — they load as their own components.
|
||||
if child.joinpath("__init__.py").is_file():
|
||||
continue
|
||||
for sub in child.iterdir():
|
||||
if not sub.is_file():
|
||||
continue
|
||||
if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS:
|
||||
continue
|
||||
resource = f"{name}/{sub.name}"
|
||||
if resource in excluded_files:
|
||||
continue
|
||||
ret.append(FileResource(self.package, resource))
|
||||
|
||||
ret.append(FileResource(self.package, resource))
|
||||
return ret
|
||||
|
||||
|
||||
@@ -237,7 +247,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None:
|
||||
|
||||
_COMPONENT_CACHE: dict[str, ComponentManifest] = {}
|
||||
CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve()
|
||||
_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config)
|
||||
_COMPONENT_CACHE["esphome"] = ComponentManifest(
|
||||
esphome.core.config, recursive_sources=True
|
||||
)
|
||||
|
||||
|
||||
def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None:
|
||||
|
||||
@@ -12,3 +12,6 @@ pytest-asyncio==1.3.0
|
||||
pytest-xdist==3.8.0
|
||||
asyncmock==0.4.2
|
||||
hypothesis==6.92.1
|
||||
|
||||
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
|
||||
importtime-waterfall==1.0.0
|
||||
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression check for `import esphome.__main__` cost.
|
||||
|
||||
Runs `python -m importtime_waterfall --har esphome.__main__` (which invokes
|
||||
`-X importtime` in fresh subprocesses, best-of-N) and compares the root
|
||||
cumulative import time against a checked-in budget
|
||||
(`script/import_time_budget.json`).
|
||||
|
||||
The CLI pays this cost on every invocation before the requested command even
|
||||
runs, so a regression here hurts every user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, TextIO
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
BUDGET_PATH = SCRIPT_DIR / "import_time_budget.json"
|
||||
|
||||
TARGET_MODULE = "esphome.__main__"
|
||||
DEFAULT_MARGIN_PCT = 15
|
||||
OFFENDERS_TOP_N = 15
|
||||
|
||||
|
||||
def run_waterfall(module: str) -> str:
|
||||
"""Run `importtime_waterfall --har <module>` and return the HAR JSON text.
|
||||
|
||||
`importtime_waterfall` itself runs the target in 6 fresh subprocesses
|
||||
under `-X importtime` and emits the HAR of the fastest run.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "importtime_waterfall", "--har", module],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def measure(module: str, har_path: Path | None = None) -> dict[str, Any]:
|
||||
"""Return the parsed HAR for importing `module`.
|
||||
|
||||
When `har_path` is given, also write the raw HAR JSON to that path so
|
||||
callers can combine `--check` with `--har` without measuring twice.
|
||||
"""
|
||||
har_text = run_waterfall(module)
|
||||
if har_path is not None:
|
||||
har_path.write_text(har_text)
|
||||
return json.loads(har_text)
|
||||
|
||||
|
||||
def _entries(har: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return har["log"]["entries"]
|
||||
|
||||
|
||||
def root_cumulative_us(har: dict[str, Any], module: str) -> int:
|
||||
"""Return the cumulative import time (µs) of `module` from a HAR.
|
||||
|
||||
The HAR `time` field is authored by importtime_waterfall using µs values
|
||||
fed through `timedelta(milliseconds=...)`, so the number read back is the
|
||||
original self/cumulative time in microseconds (labelled "ms" in HAR).
|
||||
"""
|
||||
for entry in _entries(har):
|
||||
if entry["request"]["url"] == module:
|
||||
return entry["time"]
|
||||
raise RuntimeError(
|
||||
f"No HAR entry for {module!r}. Is it importable with "
|
||||
f"`python -c 'import {module}'`?"
|
||||
)
|
||||
|
||||
|
||||
def top_offenders(har: dict[str, Any], n: int) -> list[tuple[str, int, int]]:
|
||||
"""Return up to `n` (name, self_us, cumulative_us), ranked by self_us desc.
|
||||
|
||||
A module imported from multiple places is counted once (first entry wins,
|
||||
matching importtime's own de-duplication).
|
||||
"""
|
||||
seen: dict[str, tuple[int, int]] = {}
|
||||
for entry in _entries(har):
|
||||
name = entry["request"]["url"]
|
||||
if name in seen:
|
||||
continue
|
||||
self_us = entry["timings"]["receive"]
|
||||
cumulative_us = entry["time"]
|
||||
seen[name] = (self_us, cumulative_us)
|
||||
ranked = sorted(
|
||||
((name, s, c) for name, (s, c) in seen.items()),
|
||||
key=lambda row: row[1],
|
||||
reverse=True,
|
||||
)
|
||||
return ranked[:n]
|
||||
|
||||
|
||||
def read_budget() -> dict[str, Any]:
|
||||
if not BUDGET_PATH.exists():
|
||||
return {}
|
||||
with BUDGET_PATH.open() as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def write_budget(cumulative_us: int, margin_pct: int) -> None:
|
||||
payload = {
|
||||
"target_module": TARGET_MODULE,
|
||||
"margin_pct": margin_pct,
|
||||
"cumulative_us": cumulative_us,
|
||||
}
|
||||
with BUDGET_PATH.open("w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _format_us(us: int) -> str:
|
||||
if us >= 1000:
|
||||
return f"{us / 1000:.1f}ms"
|
||||
return f"{us}us"
|
||||
|
||||
|
||||
def _print_offenders_table(
|
||||
offenders: list[tuple[str, int, int]], stream: TextIO
|
||||
) -> None:
|
||||
name_w = max(len(name) for name, _, _ in offenders)
|
||||
print(f"\n{'module':<{name_w}} {'self':>10} {'cumulative':>12}", file=stream)
|
||||
print(f"{'-' * name_w} {'-' * 10} {'-' * 12}", file=stream)
|
||||
for name, self_us, cum_us in offenders:
|
||||
print(
|
||||
f"{name:<{name_w}} {_format_us(self_us):>10} {_format_us(cum_us):>12}",
|
||||
file=stream,
|
||||
)
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> int:
|
||||
budget = read_budget()
|
||||
if not budget:
|
||||
print(
|
||||
f"ERROR: {BUDGET_PATH.name} missing. Run with --update first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None)
|
||||
measured = root_cumulative_us(har, TARGET_MODULE)
|
||||
|
||||
baseline = budget["cumulative_us"]
|
||||
margin_pct = budget.get("margin_pct", DEFAULT_MARGIN_PCT)
|
||||
ceiling = int(baseline * (1 + margin_pct / 100))
|
||||
|
||||
summary = (
|
||||
f"measured {TARGET_MODULE}: {_format_us(measured)} "
|
||||
f"(budget {_format_us(baseline)} + {margin_pct}% = {_format_us(ceiling)})"
|
||||
)
|
||||
passed = measured <= ceiling
|
||||
stream = sys.stdout if passed else sys.stderr
|
||||
|
||||
if passed:
|
||||
print(summary)
|
||||
else:
|
||||
print(
|
||||
f"REGRESSION: `import {TARGET_MODULE}` took {_format_us(measured)}, "
|
||||
f"exceeding the budget of {_format_us(baseline)} + {margin_pct}% "
|
||||
f"({_format_us(ceiling)}).",
|
||||
file=stream,
|
||||
)
|
||||
|
||||
print("\nTop import-time offenders (by self time):", file=stream)
|
||||
_print_offenders_table(top_offenders(har, OFFENDERS_TOP_N), stream)
|
||||
|
||||
if not passed:
|
||||
print(
|
||||
"\nIf this regression is intentional, regenerate the budget with:\n"
|
||||
" script/check_import_time.py --update\n"
|
||||
"Otherwise, consider making the new import lazy "
|
||||
"(import inside the function that uses it).",
|
||||
file=stream,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_update(args: argparse.Namespace) -> int:
|
||||
har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None)
|
||||
measured = root_cumulative_us(har, TARGET_MODULE)
|
||||
write_budget(measured, args.margin_pct)
|
||||
print(
|
||||
f"Wrote {BUDGET_PATH.name}: "
|
||||
f"{TARGET_MODULE}={_format_us(measured)} "
|
||||
f"(margin {args.margin_pct}%)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_har_only(args: argparse.Namespace) -> int:
|
||||
Path(args.har).write_text(run_waterfall(TARGET_MODULE))
|
||||
print(f"Wrote waterfall HAR to {args.har}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--margin-pct",
|
||||
type=int,
|
||||
default=DEFAULT_MARGIN_PCT,
|
||||
help=(f"Margin over baseline for --update (default: {DEFAULT_MARGIN_PCT}%%)."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--har",
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Write a waterfall HAR file at PATH. Can be combined with "
|
||||
"--check or --update to reuse that run's measurement (avoids "
|
||||
"measuring twice)."
|
||||
),
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument(
|
||||
"--check", action="store_true", help="Fail if measured time exceeds budget."
|
||||
)
|
||||
mode.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="Rewrite the budget from a fresh measurement.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
return cmd_check(args)
|
||||
if args.update:
|
||||
return cmd_update(args)
|
||||
if args.har:
|
||||
return cmd_har_only(args)
|
||||
parser.error("Specify at least one of --check, --update, or --har PATH.")
|
||||
return 2 # unreachable; parser.error exits. Here to satisfy ruff RET503.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -349,6 +349,42 @@ def should_run_python_linters(branch: str | None = None) -> bool:
|
||||
return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS)
|
||||
|
||||
|
||||
# Files outside esphome/**/*.py whose changes can affect `import esphome.__main__`
|
||||
# cost. requirements.txt / pyproject.toml change the dependency graph pulled in
|
||||
# by top-level imports; check_import_time.py itself changes the check's behavior.
|
||||
IMPORT_TIME_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"requirements.txt",
|
||||
"requirements_dev.txt",
|
||||
"requirements_test.txt",
|
||||
"pyproject.toml",
|
||||
"script/check_import_time.py",
|
||||
"script/import_time_budget.json",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def should_run_import_time(branch: str | None = None) -> bool:
|
||||
"""Determine if the `import esphome.__main__` time regression check should run.
|
||||
|
||||
Runs when any Python file under `esphome/` changes (those modules are
|
||||
loaded transitively from `esphome.__main__`), when dependency
|
||||
declarations change, or when the check script/budget itself changes.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if the import-time check should run, False otherwise.
|
||||
"""
|
||||
for file in changed_files(branch):
|
||||
if file.startswith("esphome/") and file.endswith(PYTHON_FILE_EXTENSIONS):
|
||||
return True
|
||||
if file in IMPORT_TIME_TRIGGER_FILES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
@@ -780,6 +816,7 @@ def main() -> None:
|
||||
run_clang_tidy = should_run_clang_tidy(args.branch)
|
||||
run_clang_format = should_run_clang_format(args.branch)
|
||||
run_python_linters = should_run_python_linters(args.branch)
|
||||
run_import_time = should_run_import_time(args.branch)
|
||||
changed_cpp_file_count = count_changed_cpp_files(args.branch)
|
||||
|
||||
# Get changed components
|
||||
@@ -913,6 +950,7 @@ def main() -> None:
|
||||
"clang_tidy_mode": clang_tidy_mode,
|
||||
"clang_format": run_clang_format,
|
||||
"python_linters": run_python_linters,
|
||||
"import_time": run_import_time,
|
||||
"changed_components": changed_components,
|
||||
"changed_components_with_tests": changed_components_with_tests,
|
||||
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"target_module": "esphome.__main__",
|
||||
"margin_pct": 15,
|
||||
"cumulative_us": 123000
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml
|
||||
|
||||
<<: !include common.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: |-
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Unit tests for script/check_import_time.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Load the script-under-test as `check_import_time` (it's a hyphenated path
|
||||
# inside `script/` that mirrors the existing `determine_jobs` pattern).
|
||||
script_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "script")
|
||||
)
|
||||
sys.path.insert(0, script_dir)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_import_time", os.path.join(script_dir, "check_import_time.py")
|
||||
)
|
||||
check_import_time = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(check_import_time)
|
||||
|
||||
|
||||
def _entry(name: str, self_us: int, cumulative_us: int) -> dict:
|
||||
"""Build a minimal HAR entry matching `importtime_waterfall --har`."""
|
||||
return {
|
||||
"request": {"url": name},
|
||||
"time": cumulative_us,
|
||||
"timings": {"receive": self_us, "wait": cumulative_us - self_us},
|
||||
}
|
||||
|
||||
|
||||
def _har(*entries: dict) -> dict:
|
||||
return {"log": {"entries": list(entries)}}
|
||||
|
||||
|
||||
def test_root_cumulative_us_returns_time_for_root_module() -> None:
|
||||
har = _har(
|
||||
_entry("dep_a", 500, 500),
|
||||
_entry("dep_b", 300, 300),
|
||||
_entry("esphome.__main__", 100, 1000),
|
||||
)
|
||||
assert check_import_time.root_cumulative_us(har, "esphome.__main__") == 1000
|
||||
|
||||
|
||||
def test_root_cumulative_us_missing_module_raises() -> None:
|
||||
har = _har(_entry("something.else", 100, 100))
|
||||
with pytest.raises(RuntimeError, match="No HAR entry for 'esphome.__main__'"):
|
||||
check_import_time.root_cumulative_us(har, "esphome.__main__")
|
||||
|
||||
|
||||
def test_top_offenders_ranks_by_self_time_descending() -> None:
|
||||
har = _har(
|
||||
_entry("small", 100, 100),
|
||||
_entry("big", 5000, 5000),
|
||||
_entry("medium", 2000, 2500),
|
||||
)
|
||||
result = check_import_time.top_offenders(har, n=10)
|
||||
assert [name for name, _, _ in result] == ["big", "medium", "small"]
|
||||
assert result[0] == ("big", 5000, 5000)
|
||||
|
||||
|
||||
def test_top_offenders_respects_n_limit() -> None:
|
||||
har = _har(*[_entry(f"m{i}", i * 100, i * 100) for i in range(1, 20)])
|
||||
assert len(check_import_time.top_offenders(har, n=5)) == 5
|
||||
|
||||
|
||||
def test_top_offenders_dedupes_repeat_names_keeping_first() -> None:
|
||||
har = _har(
|
||||
_entry("pkg", 5000, 5000),
|
||||
_entry("pkg", 100, 100), # reimport later in trace
|
||||
_entry("other", 1000, 1000),
|
||||
)
|
||||
result = check_import_time.top_offenders(har, n=10)
|
||||
assert [name for name, _, _ in result] == ["pkg", "other"]
|
||||
# First occurrence wins
|
||||
assert ("pkg", 5000, 5000) in result
|
||||
|
||||
|
||||
def test_format_us_switches_to_ms_at_threshold() -> None:
|
||||
assert check_import_time._format_us(500) == "500us"
|
||||
assert check_import_time._format_us(999) == "999us"
|
||||
assert check_import_time._format_us(1000) == "1.0ms"
|
||||
assert check_import_time._format_us(12345) == "12.3ms"
|
||||
|
||||
|
||||
def test_read_write_budget_roundtrip(tmp_path: Path) -> None:
|
||||
budget_path = tmp_path / "budget.json"
|
||||
with patch.object(check_import_time, "BUDGET_PATH", budget_path):
|
||||
assert check_import_time.read_budget() == {}
|
||||
check_import_time.write_budget(cumulative_us=12345, margin_pct=20)
|
||||
loaded = check_import_time.read_budget()
|
||||
assert loaded["cumulative_us"] == 12345
|
||||
assert loaded["margin_pct"] == 20
|
||||
assert loaded["target_module"] == check_import_time.TARGET_MODULE
|
||||
|
||||
|
||||
def test_cmd_check_passes_when_measured_within_ceiling(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
budget_path = tmp_path / "budget.json"
|
||||
budget_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_module": check_import_time.TARGET_MODULE,
|
||||
"margin_pct": 15,
|
||||
"cumulative_us": 100000, # 100ms
|
||||
}
|
||||
)
|
||||
)
|
||||
# Measured 90ms: inside 100ms + 15% = 115ms ceiling
|
||||
har = _har(_entry(check_import_time.TARGET_MODULE, 1000, 90000))
|
||||
args = type("A", (), {"har": None})()
|
||||
with (
|
||||
patch.object(check_import_time, "BUDGET_PATH", budget_path),
|
||||
patch.object(check_import_time, "measure", return_value=har),
|
||||
):
|
||||
rc = check_import_time.cmd_check(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "measured esphome.__main__:" in out
|
||||
assert "budget 100.0ms" in out
|
||||
|
||||
|
||||
def test_cmd_check_fails_when_measured_exceeds_ceiling(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
budget_path = tmp_path / "budget.json"
|
||||
budget_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_module": check_import_time.TARGET_MODULE,
|
||||
"margin_pct": 15,
|
||||
"cumulative_us": 100000,
|
||||
}
|
||||
)
|
||||
)
|
||||
# Measured 120ms: over 100ms + 15% = 115ms ceiling
|
||||
har = _har(
|
||||
_entry("offender_a", 10000, 10000),
|
||||
_entry(check_import_time.TARGET_MODULE, 1000, 120000),
|
||||
)
|
||||
args = type("A", (), {"har": None})()
|
||||
with (
|
||||
patch.object(check_import_time, "BUDGET_PATH", budget_path),
|
||||
patch.object(check_import_time, "measure", return_value=har),
|
||||
):
|
||||
rc = check_import_time.cmd_check(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "REGRESSION" in err
|
||||
assert "120.0ms" in err
|
||||
assert "offender_a" in err # top offender table
|
||||
|
||||
|
||||
def test_cmd_check_returns_2_when_budget_missing(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
budget_path = tmp_path / "nonexistent.json"
|
||||
args = type("A", (), {"har": None})()
|
||||
with patch.object(check_import_time, "BUDGET_PATH", budget_path):
|
||||
rc = check_import_time.cmd_check(args)
|
||||
assert rc == 2
|
||||
assert "missing" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_check_writes_har_when_path_given(tmp_path: Path) -> None:
|
||||
budget_path = tmp_path / "budget.json"
|
||||
budget_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_module": check_import_time.TARGET_MODULE,
|
||||
"margin_pct": 15,
|
||||
"cumulative_us": 100000,
|
||||
}
|
||||
)
|
||||
)
|
||||
har_path = tmp_path / "out.har"
|
||||
har_text = json.dumps(_har(_entry(check_import_time.TARGET_MODULE, 1000, 80000)))
|
||||
args = type("A", (), {"har": str(har_path)})()
|
||||
with (
|
||||
patch.object(check_import_time, "BUDGET_PATH", budget_path),
|
||||
patch.object(check_import_time, "run_waterfall", return_value=har_text),
|
||||
):
|
||||
rc = check_import_time.cmd_check(args)
|
||||
assert rc == 0
|
||||
assert har_path.exists()
|
||||
assert json.loads(har_path.read_text()) == json.loads(har_text)
|
||||
@@ -56,6 +56,13 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_should_run_import_time() -> Generator[Mock, None, None]:
|
||||
"""Mock should_run_import_time from determine_jobs."""
|
||||
with patch.object(determine_jobs, "should_run_import_time") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
@@ -91,6 +98,7 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -104,6 +112,7 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = True
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -158,6 +167,7 @@ def test_main_all_tests_should_run(
|
||||
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
||||
assert output["clang_format"] is True
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -189,6 +199,7 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -202,6 +213,7 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
@@ -241,6 +253,7 @@ def test_main_no_tests_should_run(
|
||||
assert output["clang_tidy_mode"] == "disabled"
|
||||
assert output["clang_format"] is False
|
||||
assert output["python_linters"] is False
|
||||
assert output["import_time"] is False
|
||||
assert output["changed_components"] == []
|
||||
assert output["changed_components_with_tests"] == []
|
||||
assert output["component_test_count"] == 0
|
||||
@@ -261,6 +274,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -274,6 +288,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -310,6 +325,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_clang_tidy.assert_called_once_with("main")
|
||||
mock_should_run_clang_format.assert_called_once_with("main")
|
||||
mock_should_run_python_linters.assert_called_once_with("main")
|
||||
mock_should_run_import_time.assert_called_once_with("main")
|
||||
|
||||
# Check output
|
||||
captured = capsys.readouterr()
|
||||
@@ -322,6 +338,7 @@ def test_main_with_branch_argument(
|
||||
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
||||
assert output["clang_format"] is False
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["changed_components"] == ["mqtt"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -597,6 +614,50 @@ def test_should_run_python_linters_with_branch() -> None:
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
# esphome Python files trigger the check
|
||||
(["esphome/__main__.py"], True),
|
||||
(["esphome/components/wifi/__init__.py"], True),
|
||||
(["esphome/core/config.py"], True),
|
||||
(["esphome/types.pyi"], True),
|
||||
# Dependency declarations and the check's own files trigger
|
||||
(["requirements.txt"], True),
|
||||
(["requirements_dev.txt"], True),
|
||||
(["requirements_test.txt"], True),
|
||||
(["pyproject.toml"], True),
|
||||
(["script/check_import_time.py"], True),
|
||||
(["script/import_time_budget.json"], True),
|
||||
# Mixed: any triggering file is enough
|
||||
(["docs/README.md", "esphome/config.py"], True),
|
||||
# Python files outside esphome/ don't trigger
|
||||
(["script/some_other_script.py"], False),
|
||||
(["tests/script/test_determine_jobs.py"], False),
|
||||
# Non-Python changes don't trigger
|
||||
(["esphome/core/component.cpp"], False),
|
||||
(["tests/components/wifi/test.esp32-idf.yaml"], False),
|
||||
(["README.md"], False),
|
||||
([], False),
|
||||
],
|
||||
)
|
||||
def test_should_run_import_time(
|
||||
changed_files: list[str], expected_result: bool
|
||||
) -> None:
|
||||
"""Test should_run_import_time function."""
|
||||
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
||||
result = determine_jobs.should_run_import_time()
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_should_run_import_time_with_branch() -> None:
|
||||
"""Test should_run_import_time with branch argument."""
|
||||
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_import_time("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
|
||||
@@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None:
|
||||
|
||||
# Verify the correct number of resources
|
||||
assert len(resources) == 3 # test.cpp, test.h, common.cpp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# recursive_sources — used only by the core "esphome" manifest so that files
|
||||
# in esphome/core/<subdir>/*.cpp (e.g. esphome/core/wake/wake_host.cpp) are
|
||||
# discovered without promoting <subdir>/ to a Python subpackage.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_file(filename: str) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.name = filename
|
||||
m.is_file.return_value = True
|
||||
m.is_dir.return_value = False
|
||||
return m
|
||||
|
||||
|
||||
def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock:
|
||||
"""Mock a directory entry with an iterdir() and joinpath('__init__.py')."""
|
||||
d = MagicMock()
|
||||
d.name = dirname
|
||||
d.is_file.return_value = False
|
||||
d.is_dir.return_value = True
|
||||
d.iterdir.return_value = children
|
||||
init_marker = MagicMock()
|
||||
init_marker.is_file.return_value = has_init
|
||||
d.joinpath.return_value = init_marker
|
||||
return d
|
||||
|
||||
|
||||
def test_component_manifest_resources_non_recursive_skips_subdirs() -> None:
|
||||
"""Default (recursive_sources=False) does not descend into subdirectories."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.__package__ = "esphome.components.test_component"
|
||||
# No FILTER_SOURCE_FILES.
|
||||
del mock_module.FILTER_SOURCE_FILES
|
||||
|
||||
manifest = ComponentManifest(mock_module) # recursive_sources defaults to False
|
||||
|
||||
top_level = [
|
||||
_mock_file("top.cpp"),
|
||||
_mock_dir("subdir", [_mock_file("nested.cpp")]),
|
||||
]
|
||||
with patch("importlib.resources.files") as mock_files_func:
|
||||
pkg = MagicMock()
|
||||
pkg.iterdir.return_value = top_level
|
||||
mock_files_func.return_value = pkg
|
||||
|
||||
names = [r.resource for r in manifest.resources]
|
||||
|
||||
assert names == ["top.cpp"]
|
||||
|
||||
|
||||
def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None:
|
||||
"""With recursive_sources=True, a subdir without __init__.py is walked."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.__package__ = "esphome.core"
|
||||
del mock_module.FILTER_SOURCE_FILES
|
||||
|
||||
manifest = ComponentManifest(mock_module, recursive_sources=True)
|
||||
|
||||
wake_dir = _mock_dir(
|
||||
"wake",
|
||||
[
|
||||
_mock_file("wake_host.cpp"),
|
||||
_mock_file("wake_host.h"),
|
||||
_mock_file("README.md"), # wrong suffix, excluded
|
||||
],
|
||||
has_init=False,
|
||||
)
|
||||
top_level = [
|
||||
_mock_file("wake.h"),
|
||||
wake_dir,
|
||||
]
|
||||
with patch("importlib.resources.files") as mock_files_func:
|
||||
pkg = MagicMock()
|
||||
pkg.iterdir.return_value = top_level
|
||||
mock_files_func.return_value = pkg
|
||||
|
||||
names = sorted(r.resource for r in manifest.resources)
|
||||
|
||||
assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"]
|
||||
|
||||
|
||||
def test_component_manifest_resources_recursive_skips_subpackages() -> None:
|
||||
"""Subdirectories that ARE Python subpackages (contain __init__.py) are
|
||||
skipped even with recursive_sources=True — those load as their own
|
||||
ComponentManifest and would otherwise be double-counted."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.__package__ = "esphome.components.haier"
|
||||
del mock_module.FILTER_SOURCE_FILES
|
||||
|
||||
manifest = ComponentManifest(mock_module, recursive_sources=True)
|
||||
|
||||
button_pkg = _mock_dir(
|
||||
"button",
|
||||
[_mock_file("self_cleaning.cpp")],
|
||||
has_init=True, # Python subpackage — must be skipped.
|
||||
)
|
||||
top_level = [
|
||||
_mock_file("haier.cpp"),
|
||||
button_pkg,
|
||||
]
|
||||
with patch("importlib.resources.files") as mock_files_func:
|
||||
pkg = MagicMock()
|
||||
pkg.iterdir.return_value = top_level
|
||||
mock_files_func.return_value = pkg
|
||||
|
||||
names = [r.resource for r in manifest.resources]
|
||||
|
||||
assert names == ["haier.cpp"]
|
||||
|
||||
|
||||
def test_component_manifest_resources_recursive_skips_pycache() -> None:
|
||||
"""__pycache__ inside a recursive walk must never be descended into."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.__package__ = "esphome.core"
|
||||
del mock_module.FILTER_SOURCE_FILES
|
||||
|
||||
manifest = ComponentManifest(mock_module, recursive_sources=True)
|
||||
|
||||
# __pycache__ is_dir=True but must be skipped without checking __init__.py
|
||||
# or calling iterdir (would yield compiled artifacts).
|
||||
pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")])
|
||||
top_level = [
|
||||
_mock_file("wake.h"),
|
||||
pycache,
|
||||
]
|
||||
with patch("importlib.resources.files") as mock_files_func:
|
||||
pkg = MagicMock()
|
||||
pkg.iterdir.return_value = top_level
|
||||
mock_files_func.return_value = pkg
|
||||
|
||||
names = [r.resource for r in manifest.resources]
|
||||
|
||||
assert names == ["wake.h"]
|
||||
|
||||
|
||||
def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> (
|
||||
None
|
||||
):
|
||||
"""FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files
|
||||
inside a recursively-walked subdir."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.__package__ = "esphome.core"
|
||||
mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"]
|
||||
|
||||
manifest = ComponentManifest(mock_module, recursive_sources=True)
|
||||
|
||||
wake_dir = _mock_dir(
|
||||
"wake",
|
||||
[
|
||||
_mock_file("wake_host.cpp"), # excluded
|
||||
_mock_file("wake_freertos.cpp"), # kept
|
||||
],
|
||||
)
|
||||
with patch("importlib.resources.files") as mock_files_func:
|
||||
pkg = MagicMock()
|
||||
pkg.iterdir.return_value = [wake_dir]
|
||||
mock_files_func.return_value = pkg
|
||||
|
||||
names = [r.resource for r in manifest.resources]
|
||||
|
||||
assert names == ["wake/wake_freertos.cpp"]
|
||||
|
||||
Reference in New Issue
Block a user