Merge branch 'dev' into webserver-list-entities-xmacro

This commit is contained in:
J. Nick Koston
2026-04-28 10:37:21 -05:00
committed by GitHub
42 changed files with 1724 additions and 704 deletions
+30
View File
@@ -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
+1
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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) {
+7 -7
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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
+47
View File
@@ -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
+33
View File
@@ -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
+60
View File
@@ -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
+17
View File
@@ -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
+31
View File
@@ -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
+64
View File
@@ -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
+58
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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:
+3
View File
@@ -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
+241
View File
@@ -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())
+38
View File
@@ -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),
+5
View File
@@ -0,0 +1,5 @@
{
"target_module": "esphome.__main__",
"margin_pct": 15,
"cumulative_us": 123000
}
+1 -43
View File
@@ -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);
+191
View File
@@ -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)
+61
View File
@@ -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"),
[
+164
View File
@@ -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"]