Compare commits

..
Author SHA1 Message Date
J. Nick Koston c307706ae0 Point at can_proceed() for handshake-dependent flags 2026-09-10 09:01:09 -05:00
J. Nick Koston cbd2645307 Keep writing the flag after setup, log that it stops working in 2027.3.0 2026-09-10 09:00:37 -05:00
J. Nick Koston 7b747bbd6b Soften the set_internal() limitations wording 2026-09-10 08:56:35 -05:00
J. Nick Koston b0bd118991 Recommend the YAML internal key over set_internal() 2026-09-10 08:54:55 -05:00
J. Nick Koston 77d6570605 Mark set_internal() as legacy support in the header comment 2026-09-10 08:51:42 -05:00
J. Nick Koston db1dfff925 Correct set_internal() priority bound to AFTER_WIFI 2026-09-10 08:37:43 -05:00
J. Nick Koston a368ad860a Document set_internal() limitations inline 2026-09-10 08:26:16 -05:00
J. Nick Koston fc59f6ca50 [core] Support set_internal() during setup, reject calls after setup 2026-09-10 08:19:16 -05:00
luar123 66f829c760 [zigbee] wake loop on defer/set_timeout (#19050) 2026-09-10 08:08:07 -05:00
luar123 a88ec7d90b [logger] Flush uart before sleep in idf 6 (#18975) 2026-09-10 13:07:09 +00:00
luar123 54706e869c [deep_sleep] disable loop (#18962) 2026-09-10 07:18:15 -05:00
05f7d5e4f1 [mlx90614] pec validation (#6689)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-10 16:14:43 +12:00
Robin ThoniandJesse Hills ddbd89dd2a [network] Improve network::is_connected() to better handle multiple interfaces (#18999)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-10 04:06:44 +00:00
Jesse Hills 5ed59af920 [template] Surface value metadata on template entity forms (#17545) 2026-09-10 14:11:13 +12:00
Jesse Hills 4868b498cf [ci] Ask stale PR authors to merge dev instead of rebasing (#19064) 2026-09-10 14:09:58 +12:00
Jesse Hills c66fa81208 [core] Consolidate setup scripts into a cross-platform setup.py (#18856) 2026-09-10 14:09:49 +12:00
46 changed files with 1331 additions and 377 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
to keep it open. Also, please merge the latest dev branch into your
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
-2
View File
@@ -13,7 +13,6 @@ from esphome.components.logger import request_log_listener
from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
enable_spare_ephemeral,
encryption_schema,
new_psk_progmem,
validate_encryption_key,
@@ -604,7 +603,6 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
enable_spare_ephemeral()
else:
cg.add_define("USE_API_PLAINTEXT")
-7
View File
@@ -316,16 +316,9 @@ class APIConnection final : public APIServerConnectionBase {
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
#endif
// How long a new connection holds off the spare ephemeral refill
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
bool is_authenticated() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
// Unauthenticated and within its grace period; an older unauthenticated
// connection is a stale half open client and no longer counts
bool is_still_connecting(uint32_t now) {
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
}
bool is_connection_setup() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
+2 -25
View File
@@ -143,15 +143,6 @@ void APIServer::loop() {
this->accept_new_connections_();
}
// Checked once per pass for the refill and for the clients below
const bool connected = network::is_connected();
#ifdef USE_NOISE_SPARE_EPHEMERAL
// Only the flag test is inline; refilling is the rare path
if (connected && !noise::has_spare_ephemeral()) {
this->refill_spare_ephemeral_();
}
#endif
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time).
@@ -168,7 +159,8 @@ void APIServer::loop() {
}
// Process clients and remove disconnected ones in a single pass
if (!connected) {
// Check network connectivity once for all clients
if (!network::is_connected()) {
// Network is down - disconnect all clients
for (auto &client : this->active_clients()) {
client->on_fatal_error();
@@ -196,21 +188,6 @@ void APIServer::loop() {
}
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
// Called with the network up; refill only while no api client is still
// connecting (an OTA handshake is not visible here and just pays the refill
// it triggered).
void APIServer::refill_spare_ephemeral_() {
const uint32_t now = App.get_loop_component_start_time();
for (auto &client : this->active_clients()) {
if (client->is_still_connecting(now)) {
return;
}
}
noise::prepare_spare_ephemeral();
}
#endif
void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
+1 -4
View File
@@ -5,7 +5,7 @@
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL)
#ifdef USE_API_NOISE
// Only present in the build when the noise component is loaded
#include "esphome/components/noise/noise.h"
#endif
@@ -363,9 +363,6 @@ class APIServer final : public Component,
uint8_t provisioning_source_{0};
#endif
#ifdef USE_NOISE_SPARE_EPHEMERAL
void refill_spare_ephemeral_();
#endif
#ifdef USE_API_NOISE
noise::NoiseContext noise_ctx_;
#ifndef USE_API_NOISE_PSK_FROM_YAML
@@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() {
this->status_set_warning();
ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep...");
}
this->next_enter_deep_sleep_ = true;
this->defer_sleep_();
return false;
}
}
@@ -17,6 +17,7 @@ void DeepSleepComponent::setup() {
void DeepSleepComponent::schedule_sleep_() {
this->next_enter_deep_sleep_ = false;
this->disable_loop();
const optional<uint32_t> run_duration = get_run_duration_();
if (run_duration.has_value()) {
ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration);
@@ -45,7 +46,7 @@ void DeepSleepComponent::loop() {
void DeepSleepComponent::begin_sleep(bool manual) {
if (this->prevent_ && !manual) {
this->next_enter_deep_sleep_ = true;
this->defer_sleep_();
return;
}
@@ -190,6 +190,11 @@ class DeepSleepComponent final : public Component {
void schedule_sleep_();
bool should_teardown_();
void defer_sleep_() {
this->next_enter_deep_sleep_ = true;
this->enable_loop();
}
#ifdef USE_BK72XX
bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const;
bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); }
@@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() {
this->status_set_warning();
ESP_LOGW(TAG, "Waiting for wakeup pin state change");
}
this->next_enter_deep_sleep_ = true;
this->defer_sleep_();
return false;
}
return true;
+11 -2
View File
@@ -3,6 +3,7 @@
#include "esphome/components/esp32/crash_handler.h"
#include <esp_log.h>
#include <esp_idf_version.h>
#include <driver/uart.h>
#include <soc/soc_caps.h>
@@ -16,8 +17,10 @@
#include <driver/usb_serial_jtag_vfs.h>
#endif
#endif
#include "esp_idf_version.h"
#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0))
#include "esp_sleep.h"
#endif
#include "freertos/FreeRTOS.h"
#include <fcntl.h>
@@ -87,6 +90,12 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) {
// ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes).
const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1;
uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0);
#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0))
// Always flush before going to light sleep. Could be disabled for devices
// without TOP_PD or if source_clk = UART_SCLK_RTC
esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART);
#endif
}
void Logger::pre_setup() {
+139 -46
View File
@@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F;
static const char *const TAG = "mlx90614";
// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures
static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3;
// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first
static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); }
void MLX90614Component::setup() {
if (!this->write_emissivity_()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
if (std::isnan(this->emissivity_)) {
return;
}
this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS;
this->try_write_emissivity_();
if (this->emissivity_write_attempts_ != 0) {
this->status_set_warning(LOG_STR("Failed to write emissivity, will retry"));
}
}
void MLX90614Component::try_write_emissivity_() {
if (this->emissivity_write_attempts_ == 0) {
return;
}
if (this->write_emissivity_()) {
this->emissivity_write_attempts_ = 0;
return;
}
if (--this->emissivity_write_attempts_ == 0) {
ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS);
this->emissivity_write_failed_ = true;
}
}
bool MLX90614Component::write_emissivity_() {
if (std::isnan(this->emissivity_))
// Skip the write when the EEPROM already holds the desired value to save write cycles
uint16_t current_emissivity;
if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) {
return false;
}
const auto desired_emissivity = static_cast<uint16_t>(this->emissivity_ * 0xFFFF);
if (current_emissivity == desired_emissivity) {
return true;
uint16_t value = (uint16_t) (this->emissivity_ * 65535);
if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) {
return false;
}
delay(10);
if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) {
return false;
}
delay(10);
return true;
return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity);
}
bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) {
bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) {
// The PEC covers the whole write transaction: SLA+W, command, data low, data high
uint8_t buf[5];
buf[0] = this->address_ << 1;
buf[1] = reg;
buf[2] = data & 0xFF;
buf[3] = data >> 8;
buf[4] = crc8(buf, 4, 0x00, 0x07, true);
return this->write_bytes(reg, buf + 2, 3);
// See datasheet 8.3.3.1 EEPROM write sequence
// 1. Write 0x0000 into the cell of interest (erases the cell)
buf[2] = buf[3] = 0;
buf[4] = crc8_pec(buf, 4);
auto ec = this->write_register(reg, buf + 2, 3);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec);
return false;
}
// 2. Wait at least 5ms
delay(10);
// 3. Write the new value
if (data != 0) {
buf[2] = data & 0xFF;
buf[3] = data >> 8;
buf[4] = crc8_pec(buf, 4);
ec = this->write_register(reg, buf + 2, 3);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec);
return false;
}
// 4. Wait at least 5ms
delay(10);
}
// 5. Read back to confirm the value was stored
uint16_t read_back;
ec = this->read_register_(reg, read_back);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec);
return false;
}
if (read_back != data) {
ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back);
return false;
}
return true;
}
i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) {
// The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high
uint8_t buf[6];
buf[0] = this->address_ << 1;
buf[1] = reg;
buf[2] = (this->address_ << 1) | 0x01;
const auto ec = this->read_register(reg, buf + 3, 3);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "i2c read error %d", ec);
return ec;
}
const auto expected_pec = crc8_pec(buf, 5);
if (buf[5] != expected_pec) {
ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]);
return i2c::ERROR_CRC;
}
data = encode_uint16(buf[4], buf[3]);
return i2c::ERROR_OK;
}
void MLX90614Component::dump_config() {
ESP_LOGCONFIG(TAG, "MLX90614:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
if (this->emissivity_write_attempts_ != 0) {
ESP_LOGW(TAG, " Emissivity not written yet, will retry");
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Ambient", this->ambient_sensor_);
@@ -71,33 +156,41 @@ void MLX90614Component::dump_config() {
}
void MLX90614Component::update() {
uint8_t emissivity[3];
if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) {
this->status_set_warning();
return;
// Temperature reads run regardless of the emissivity state so a failure still shows up as NAN
this->try_write_emissivity_();
// Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale
auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) {
if (sensor == nullptr) {
return i2c::ERROR_OK;
}
uint16_t raw;
const auto ec = this->read_register_(reg, raw);
if (ec != i2c::ERROR_OK) {
sensor->publish_state(NAN);
return ec;
}
// Bit 15 set means the device flagged the reading as invalid
const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f;
ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature);
sensor->publish_state(temperature);
return ec;
};
const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1);
const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT);
if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) {
this->status_set_warning(LOG_STR("Failed to read some sensors"));
} else if (this->emissivity_write_failed_) {
this->status_set_warning(LOG_STR("Failed to write emissivity"));
} else if (this->emissivity_write_attempts_ != 0) {
this->status_set_warning(LOG_STR("Failed to write emissivity, will retry"));
} else {
this->status_clear_warning();
}
uint8_t raw_object[3];
if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
uint8_t raw_ambient[3];
if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f;
float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f;
ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient);
if (this->ambient_sensor_ != nullptr && !std::isnan(ambient))
this->ambient_sensor_->publish_state(ambient);
if (this->object_sensor_ != nullptr && !std::isnan(object))
this->object_sensor_->publish_state(object);
this->status_clear_warning();
}
} // namespace esphome::mlx90614
+6 -1
View File
@@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice {
void set_emissivity(float emissivity) { emissivity_ = emissivity; }
protected:
void try_write_emissivity_();
bool write_emissivity_();
bool write_bytes_(uint8_t reg, uint16_t data);
bool write_register_(uint8_t reg, uint16_t data);
i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data);
sensor::Sensor *ambient_sensor_{nullptr};
sensor::Sensor *object_sensor_{nullptr};
float emissivity_{NAN};
// Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear
uint8_t emissivity_write_attempts_{0};
bool emissivity_write_failed_{false};
};
} // namespace esphome::mlx90614
+10 -6
View File
@@ -26,30 +26,34 @@ namespace esphome::network {
/// Return whether the node is connected to the network (through wifi, eth, ...)
ESPHOME_ALWAYS_INLINE inline bool is_connected() {
// With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which
// clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked.
// NOLINTBEGIN(readability-simplify-boolean-expr)
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected())
return true;
#endif
#ifdef USE_MODEM
if (modem::global_modem_component != nullptr)
return modem::global_modem_component->is_connected();
if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected())
return true;
#endif
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr)
return wifi::global_wifi_component->is_connected();
if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected())
return true;
#endif
#ifdef USE_OPENTHREAD
if (openthread::global_openthread_component != nullptr)
return openthread::global_openthread_component->is_connected();
if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected())
return true;
#endif
#ifdef USE_HOST
return true; // Assume it's connected
#endif
return false;
// NOLINTEND(readability-simplify-boolean-expr)
}
/// Return whether the network is disabled: every configured interface with a
-5
View File
@@ -86,11 +86,6 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
return ENCRYPTION_SCHEMA(config)
def enable_spare_ephemeral() -> None:
"""Compile the spare ephemeral key slot; the component that refills it calls this."""
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.26")
-35
View File
@@ -1,14 +1,12 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -26,39 +24,6 @@ void NoiseContext::load_psk(psk_t &out) const {
progmem_memcpy(out.data(), this->psk_, out.size());
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
static constexpr size_t PRIVATE_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE;
static constexpr size_t PUBLIC_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE;
uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void prepare_spare_ephemeral() {
uint8_t *private_key = spare_ephemeral;
uint8_t *public_key = spare_ephemeral + PRIVATE_KEY_SIZE;
// Same steps as noise-c's curve25519 keygen; the clamp sets the ready bit,
// a failure wipes the slot so the handshake generates its own key
if (!random_bytes(private_key, PRIVATE_KEY_SIZE)) {
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
return;
}
private_key[0] &= 0xF8;
private_key[PRIVATE_KEY_SIZE - 1] = (private_key[PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40;
if (crypto_scalarmult_curve25519_base(public_key, private_key) != 0) {
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
}
}
int consume_spare_ephemeral(NoiseHandshakeState *state) {
if (!has_spare_ephemeral()) {
return 0;
}
// noise-c keeps its own copy, so the slot is wiped either way
int err = noise_handshakestate_set_local_ephemeral(state, spare_ephemeral, PRIVATE_KEY_SIZE,
spare_ephemeral + PRIVATE_KEY_SIZE, PUBLIC_KEY_SIZE);
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
return err;
}
#endif // USE_NOISE_SPARE_EPHEMERAL
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
-23
View File
@@ -6,9 +6,6 @@
#include <cstdint>
#include "esphome/core/log.h"
// noise-c handshake state; the full definition lives in <noise/protocol.h>
using NoiseHandshakeState = struct NoiseHandshakeState_s;
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
@@ -41,26 +38,6 @@ class NoiseContext {
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err);
#ifdef USE_NOISE_SPARE_EPHEMERAL
// One responder ephemeral key pair generated ahead of time (about 60 ms on
// ESP8266), refilled by the api server while idle and consumed by the next
// handshake of any noise transport; an empty slot means the handshake
// generates its own key. The private key stays in RAM until consumed; it is
// not wiped on shutdown.
// Private key then public key; zero when empty
static constexpr size_t SPARE_EPHEMERAL_KEY_SIZE = 32;
static constexpr size_t SPARE_EPHEMERAL_SIZE = 2 * SPARE_EPHEMERAL_KEY_SIZE;
extern uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// Polled every api loop tick, so it must inline. A clamped X25519 private key
// always has bit 254 set, so that byte doubles as the ready flag.
inline bool has_spare_ephemeral() { return (spare_ephemeral[SPARE_EPHEMERAL_KEY_SIZE - 1] & 0x40) != 0; }
/// Fill the slot; blocks for the base point multiply
void prepare_spare_ephemeral();
/// Hand the slot's key pair to a handshake that has not started and wipe the
/// slot; 0 when the slot was empty or the key was taken, else the noise-c error
int consume_spare_ephemeral(NoiseHandshakeState *state);
#endif
// Shared wire format for the noise transports (api and ota): every frame is
// FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload.
// Handshake payloads start with a status byte; transport payloads end with
@@ -57,13 +57,6 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err);
return this->fail_init_(err);
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
err = consume_spare_ephemeral(this->handshake_);
// Not fatal: the handshake generates its own key instead
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err);
}
#endif
err = noise_handshakestate_start(this->handshake_);
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_start", err);
+1 -2
View File
@@ -37,8 +37,7 @@ class NoiseResponderHandshake {
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
/// Create and start the handshake with the context's PSK and the prologue.
/// A repeated call frees the previous handshake state and starts over. A
/// spare ephemeral key, when one is ready, is used instead of generating.
/// A repeated call frees the previous handshake state and starts over.
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len);
/// ACTION_FAILED is the catch-all: returned before init(), after split()
/// has released the state, and when noise-c reports a failed handshake.
@@ -2,7 +2,13 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_CONDITION, CONF_ID, CONF_LAMBDA, CONF_STATE
from esphome.const import (
CONF_CONDITION,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_STATE,
)
from esphome.cpp_generator import LambdaExpression
from .. import template_ns
@@ -12,7 +18,11 @@ TemplateBinarySensor = template_ns.class_(
)
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(TemplateBinarySensor)
cv.with_visibility(
binary_sensor.binary_sensor_schema(TemplateBinarySensor),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Exclusive(CONF_LAMBDA, CONF_CONDITION): cv.returning_lambda,
@@ -1,10 +1,14 @@
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE_CLASS
from .. import template_ns
TemplateButton = template_ns.class_("TemplateButton", button.Button)
CONFIG_SCHEMA = button.button_schema(TemplateButton)
CONFIG_SCHEMA = cv.with_visibility(
button.button_schema(TemplateButton), cv.Visibility.UI, CONF_DEVICE_CLASS
)
async def to_code(config):
@@ -6,6 +6,7 @@ from esphome.const import (
CONF_ASSUMED_STATE,
CONF_CLOSE_ACTION,
CONF_CURRENT_OPERATION,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_OPEN_ACTION,
@@ -38,7 +39,11 @@ CONF_HAS_POSITION = "has_position"
CONF_TOGGLE_ACTION = "toggle_action"
CONFIG_SCHEMA = (
cover.cover_schema(TemplateCover)
cv.with_visibility(
cover.cover_schema(TemplateCover),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import event
import esphome.config_validation as cv
from esphome.const import CONF_EVENT_TYPES
from esphome.const import CONF_DEVICE_CLASS, CONF_EVENT_TYPES
from .. import template_ns
@@ -9,7 +9,9 @@ CODEOWNERS = ["@nohat"]
TemplateEvent = template_ns.class_("TemplateEvent", event.Event, cg.Component)
CONFIG_SCHEMA = event.event_schema(TemplateEvent).extend(
CONFIG_SCHEMA = cv.with_visibility(
event.event_schema(TemplateEvent), cv.Visibility.UI, CONF_DEVICE_CLASS
).extend(
{
cv.Required(CONF_EVENT_TYPES): cv.ensure_list(cv.string_strict),
}
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ID,
CONF_INITIAL_VALUE,
CONF_LAMBDA,
@@ -12,6 +13,7 @@ from esphome.const import (
CONF_RESTORE_VALUE,
CONF_SET_ACTION,
CONF_STEP,
CONF_UNIT_OF_MEASUREMENT,
)
from .. import template_ns
@@ -46,7 +48,12 @@ def validate(config):
CONFIG_SCHEMA = cv.All(
number.number_schema(TemplateNumber)
cv.with_visibility(
number.number_schema(TemplateNumber),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
.extend(
{
cv.Required(CONF_MAX_VALUE): cv.float_,
+18 -4
View File
@@ -2,7 +2,16 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_FORCE_UPDATE,
CONF_ID,
CONF_LAMBDA,
CONF_STATE,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
from .. import template_ns
@@ -11,9 +20,14 @@ TemplateSensor = template_ns.class_(
)
CONFIG_SCHEMA = (
sensor.sensor_schema(
TemplateSensor,
accuracy_decimals=1,
cv.with_visibility(
sensor.sensor_schema(TemplateSensor, accuracy_decimals=1),
cv.Visibility.UI,
CONF_UNIT_OF_MEASUREMENT,
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_FORCE_UPDATE,
)
.extend(
{
@@ -4,6 +4,7 @@ from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import (
CONF_ASSUMED_STATE,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_OPTIMISTIC,
@@ -31,7 +32,11 @@ def validate(config):
CONFIG_SCHEMA = cv.All(
switch.switch_schema(TemplateSwitch)
cv.with_visibility(
switch.switch_schema(TemplateSwitch),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
@@ -3,7 +3,7 @@ import esphome.codegen as cg
from esphome.components import text_sensor
from esphome.components.text_sensor import TextSensorPublishAction
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE
from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE
from .. import template_ns
@@ -12,7 +12,11 @@ TemplateTextSensor = template_ns.class_(
)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema()
cv.with_visibility(
text_sensor.text_sensor_schema(),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.GenerateID(): cv.declare_id(TemplateTextSensor),
@@ -6,6 +6,7 @@ from esphome.const import (
CONF_ASSUMED_STATE,
CONF_CLOSE_ACTION,
CONF_CURRENT_OPERATION,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_OPEN_ACTION,
@@ -36,7 +37,11 @@ CONF_HAS_POSITION = "has_position"
CONF_TOGGLE_ACTION = "toggle_action"
CONFIG_SCHEMA = (
valve.valve_schema(TemplateValve)
cv.with_visibility(
valve.valve_schema(TemplateValve),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
@@ -1,6 +1,7 @@
#include "zigbee_time_zephyr.h"
#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::zigbee {
@@ -47,6 +48,7 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) {
this->synchronize_epoch_(epoch);
this->has_time_ = true;
});
App.wake_loop_threadsafe();
}
void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) {
+4 -1
View File
@@ -49,7 +49,8 @@ void ZigbeeComponent::factory_reset() {
void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) {
if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); });
global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); });
App.wake_loop_threadsafe();
return;
}
if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) {
@@ -88,6 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) {
global_zigbee->set_timeout("zb_init", 1000, []() {
ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION);
});
App.wake_loop_threadsafe();
}
} break;
case EZB_BDB_SIGNAL_STEERING: {
@@ -113,6 +115,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) {
ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING);
});
}
App.wake_loop_threadsafe();
}
} break;
case EZB_ZDO_SIGNAL_LEAVE: {
+4 -2
View File
@@ -1,10 +1,10 @@
#include "zigbee_zephyr.h"
#if defined(USE_ZIGBEE) && defined(USE_NRF52)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include <zephyr/settings/settings.h>
#include <zephyr/storage/flash_map.h>
#include "esphome/core/hal.h"
#include "esphome/core/wake.h"
extern "C" {
#include <zboss_api.h>
@@ -120,7 +120,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) {
/* Set default response value. */
p_device_cb_param->status = RET_OK;
esphome::wake_loop_threadsafe();
App.wake_loop_threadsafe();
// endpoints are enumerated from 1
if (global_zigbee->callbacks_.size() >= endpoint) {
@@ -138,6 +138,7 @@ void ZigbeeComponent::on_join_(bool factory_new) {
ESP_LOGD(TAG, "Joined the network");
this->join_cb_.call(factory_new);
});
App.wake_loop_threadsafe();
}
void ZigbeeComponent::on_start_() {
@@ -145,6 +146,7 @@ void ZigbeeComponent::on_start_() {
ESP_LOGD(TAG, "Started zigbee stack");
this->start_cb_.call();
});
App.wake_loop_threadsafe();
}
#ifdef USE_ZIGBEE_WIPE_ON_BOOT
+32
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import contextmanager, suppress
import copy
from datetime import datetime
from ipaddress import (
AddressValueError,
@@ -419,6 +420,37 @@ class Required(vol.Required):
self.visibility: Visibility | None = visibility
def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema:
"""Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``.
Lets a platform override the editor :class:`Visibility` of fields it
inherits from a shared schema builder — without that builder needing a
visibility parameter of its own. The canonical use is a ``template``
platform promoting the value metadata its user is expected to define
(``device_class``, ``unit_of_measurement``, …) onto the main form:
CONFIG_SCHEMA = cv.with_visibility(
sensor.sensor_schema(TemplateSensor),
cv.Visibility.UI,
CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT,
)
The original marker's key, default and validator are preserved; only the
visibility changes, and the input ``schema`` is left untouched. Raises if
a requested key is not present so typos fail at schema-build time.
"""
wanted = {str(k) for k in keys}
overrides = {}
for marker, validator in schema.schema.items():
if str(marker) in wanted:
marker = copy.copy(marker)
marker.visibility = visibility
overrides[marker] = validator
if missing := wanted - {str(m) for m in overrides}:
raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}")
return schema.extend(overrides)
class FinalExternalInvalid(Invalid):
"""Represents an invalid value in the final validation phase where the path should not be prepended."""
-1
View File
@@ -231,7 +231,6 @@
#define USE_IMPROV_SERIAL_NEXT_URL
#define USE_MD5
#define USE_NOISE
#define USE_NOISE_SPARE_EPHEMERAL
#define USE_SHA256
#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2
#define USE_MQTT
+9
View File
@@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui
this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3;
}
void EntityBase::set_internal(bool internal) {
// Remove the after-setup path in 2027.3.0 and ignore the call instead.
if (App.is_setup_complete()) {
ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0",
this->get_name().c_str());
}
this->flags_.internal = internal;
}
// Weak default lookup functions — overridden by generated code in main.cpp
__attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; }
__attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; }
+20 -7
View File
@@ -88,13 +88,26 @@ class EntityBase {
// Get whether this Entity should be hidden outside ESPHome
bool is_internal() const { return this->flags_.internal; }
// Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients
// are NOT notified of the change, the flag may have already been read during setup, and there
// is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead.
ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT "
"notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.",
"2026.3.0")
void set_internal(bool internal) { this->flags_.internal = internal; }
// Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key
// whenever possible: it is guaranteed and has none of the limitations below. Use this only when
// the decision can only be made at boot. Must be called before MQTT and the API read the flag:
// from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI.
// If the answer comes from a device handshake, hold setup with can_proceed() until it arrives.
// Calls after setup finishes are undefined behavior: the flag is still written and an error is
// logged, and from 2027.3.0 the call will be ignored.
//
// Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM
// or performance cost would be considered.
// - No consumer is notified of a change, so the flag can only be decided once per boot.
// - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority,
// or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT
// (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was
// stalled on a slow component has already listed the entities, so they keep the old value.
// - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that
// codegen runs for exposed entities, so a name collision can surface at runtime. Entities with
// only an 'id:' are forced internal and use the id as their name.
// - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee.
void set_internal(bool internal);
// Check if this object is declared to be disabled by default.
// That means that when the device gets added to Home Assistant (or other clients) it should
+37 -9
View File
@@ -1,27 +1,49 @@
#!/bin/sh
# Prepare the dev environment for a new checkout or worktree.
#
# Installed into the git hooks directory by script/setup. Deliberately tiny and
# self-contained: it stays valid on branches where script/setup does not exist,
# and simply does nothing there.
# Installed into the git hooks directory by script/setup.py. Deliberately tiny
# and self-contained: it stays valid on branches where the setup script does not
# exist, and simply does nothing there.
# $3 is 1 for a branch checkout, 0 for a file checkout.
[ "$3" = "1" ] || exit 0
top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
# This also runs on ordinary branch switches, where there is nothing to do.
# This also runs on ordinary branch switches, where there is nothing to do. Both
# layouts are checked because git for Windows runs hooks under its own bundled
# shell, where the environment lives in venv/Scripts rather than venv/bin.
[ -x "$top/venv/bin/python" ] && exit 0
[ -x "$top/script/setup" ] || exit 0
[ -f "$top/venv/Scripts/python.exe" ] && exit 0
# Branches from before the setup script moved to Python carry only the shell
# entry point, so whichever one the checked out branch has is used.
py=
if [ -f "$top/script/setup.py" ]; then
# The interpreter goes by different names across platforms, and on Windows
# "python3" is often a stub that opens the app store instead of running
# anything, so each candidate is tried before it is used. Doing nothing is the
# right outcome when none of them work.
for candidate in "python3" "python" "py -3"; do
# Unquoted on purpose: the launcher candidate is a command plus a flag.
if $candidate -c "" >/dev/null 2>&1; then
py=$candidate
break
fi
done
[ -n "$py" ] || exit 0
elif ! [ -x "$top/script/setup" ]; then
exit 0
fi
# Every worktree shares the hooks directory of the checkout it was created
# from, and the script/setup run below is the one from whichever branch was just
# from, and the setup script run below is the one from whichever branch was just
# checked out. Older branches install their own pre-commit hook without checking
# for a worktree: that moves the shared hook aside as pre-commit.legacy and
# replaces it with one tied to this worktree's virtual environment, so commits
# break in every checkout. To rule that out, the hooks directory is copied
# before script/setup runs and put back exactly as it was afterwards, including
# removing any file script/setup added.
# before the setup script runs and put back exactly as it was afterwards,
# including removing any file the setup script added.
hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0
snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0
cp -p "$hooks"/* "$snap"/ 2>/dev/null
@@ -29,7 +51,13 @@ cp -p "$hooks"/* "$snap"/ 2>/dev/null
# Clear VIRTUAL_ENV so a checkout made from a shell with an environment already
# activated still gets its own, rather than having the active one repointed at
# this working tree.
env -u VIRTUAL_ENV "$top/script/setup"
unset VIRTUAL_ENV
if [ -n "$py" ]; then
# Unquoted on purpose, as above.
$py "$top/script/setup.py"
else
"$top/script/setup"
fi
status=$?
for f in "$hooks"/*; do
+5 -69
View File
@@ -1,71 +1,7 @@
#!/usr/bin/env bash
# Set up ESPHome dev environment
# Set up ESPHome dev environment.
#
# The work is done by setup.py, which script/setup.bat also runs, so the Unix
# and Windows entry points share one implementation.
set -e
cd "$(dirname "$0")/.."
if [ -n "$VIRTUAL_ENV" ]; then
# A virtual environment is already active (e.g. the devcontainer's pre-provisioned
# esphome-venv). Install into it rather than creating a ./venv in the workspace.
venv_state=active
elif [ -x venv/bin/python ]; then
# Reuse the environment from an earlier run, so this script can be run again
# at any time to pick up dependency changes.
venv_state=reused
source venv/bin/activate
else
venv_state=created
# --clear replaces a partial environment left behind by an interrupted run.
if [ -x "$(command -v uv)" ]; then
uv venv --clear --seed venv
else
python3 -m venv --clear venv
fi
source venv/bin/activate
fi
if ! [ -x "$(command -v uv)" ]; then
python3 -m pip install uv
fi
uv pip install setuptools wheel
uv pip install -e ".[dev,test]" --config-settings editable_mode=compat
# A worktree shares one git hooks directory with the main checkout it was
# created from, so hooks are installed from the main checkout only. Installing
# from a worktree would point the shared hook at that worktree's virtual
# environment, breaking it for everyone once the worktree is removed.
git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)"
common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then
# --overwrite replaces any hook already in place. Without it, prek finds a
# previously installed pre-commit hook, moves it aside to
# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
# run both tools.
prek install --overwrite
# Prepares the virtual environment for new checkouts and worktrees. Installed
# once here, it covers every worktree created from this checkout.
if [ -d "$common_dir/hooks" ]; then
cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout"
chmod +x "$common_dir/hooks/post-checkout"
fi
fi
mkdir -p .temp
echo
echo
case "$venv_state" in
created)
echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it."
;;
reused)
echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it."
;;
active)
echo "Dependencies installed into the active virtual environment:"
echo " $VIRTUAL_ENV"
echo "It is already active in this shell, so no 'source venv/bin/activate' is needed."
;;
esac
exec python3 "$(dirname "$0")/setup.py" "$@"
+1 -28
View File
@@ -1,28 +1 @@
@echo off
if defined VIRTUAL_ENV goto :install
echo Starting the Virtual Environment
python -m venv venv
call venv/Scripts/activate
echo Running the Virtual Environment
:install
echo Installing required packages...
python.exe -m pip install --upgrade pip
pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.txt
pip3 install setuptools wheel
pip3 install -e ".[dev,test]" --config-settings editable_mode=compat
rem --overwrite replaces any hook already in place. Without it, prek finds a
rem previously installed pre-commit hook, moves it aside to
rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
rem run both tools.
prek install --overwrite
echo .
echo .
echo Virtual environment created. Run 'venv/Scripts/activate' to use it.
@python "%~dp0setup.py" %*
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Set up the ESPHome development environment.
Shared implementation behind script/setup and script/setup.bat, so the Unix and
Windows entry points cannot drift apart. Uses only the standard library: it runs
before any dependency has been installed.
"""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import sysconfig
MIN_PYTHON = (3, 12)
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_VENV = ROOT / "venv"
POST_CHECKOUT_HOOK = ROOT / "script" / "git-hooks" / "post-checkout"
# State of the environment the dependencies end up in, used for the closing
# message.
VENV_ACTIVE = "active"
VENV_REUSED = "reused"
VENV_CREATED = "created"
def bin_dir(venv: Path) -> Path:
"""Return the directory holding a virtual environment's executables.
The "venv" scheme resolves to bin on Unix and Scripts on Windows, so the
layout does not have to be hardcoded here.
"""
base = str(venv)
return Path(
sysconfig.get_path("scripts", "venv", vars={"base": base, "platbase": base})
)
def venv_python(venv: Path) -> Path:
"""Return the path to a virtual environment's interpreter."""
name = "python.exe" if os.name == "nt" else "python"
return bin_dir(venv) / name
def run(command: list[str], env: dict[str, str] | None = None) -> None:
"""Run a command, aborting the whole script if it fails."""
print(f"+ {' '.join(command)}", flush=True)
result = subprocess.run(command, cwd=ROOT, env=env, check=False)
if result.returncode != 0:
# Some tools fail without printing anything, so name the step that broke.
print(
f"Failed with exit code {result.returncode}: {command[0]}", file=sys.stderr
)
raise SystemExit(result.returncode)
def git_output(*args: str) -> str:
"""Return the trimmed output of a git command, or "" if it cannot be run."""
try:
result = subprocess.run(
["git", *args], cwd=ROOT, capture_output=True, text=True, check=False
)
except OSError:
# Git is not required to install the dependencies, only to install hooks.
return ""
if result.returncode != 0:
return ""
return result.stdout.strip()
def create_venv(venv: Path) -> None:
"""Create a virtual environment, replacing anything already at the path."""
# --clear replaces a partial environment left behind by an interrupted run.
if (uv := shutil.which("uv")) is not None:
run([uv, "venv", "--clear", "--seed", str(venv)])
else:
run([sys.executable, "-m", "venv", "--clear", str(venv)])
def venv_environment(venv: Path) -> dict[str, str]:
"""Return the environment child processes need to target a virtual env.
Equivalent to sourcing the environment's activate script: tools such as uv
and prek pick the environment up from VIRTUAL_ENV and PATH.
"""
env = dict(os.environ)
env["VIRTUAL_ENV"] = str(venv)
env.pop("PYTHONHOME", None)
path = str(bin_dir(venv))
# An empty entry would be appended if PATH is unset, and on Unix that means
# the working directory is searched for executables.
if existing := env.get("PATH"):
path = os.pathsep.join([path, existing])
env["PATH"] = path
return env
def find_uv(venv: Path, env: dict[str, str]) -> str:
"""Return the path to uv, installing it into the environment if needed."""
if (uv := shutil.which("uv", path=env["PATH"])) is not None:
return uv
run([str(venv_python(venv)), "-m", "pip", "install", "uv"], env=env)
if (uv := shutil.which("uv", path=env["PATH"])) is not None:
return uv
raise SystemExit("uv could not be installed, aborting.")
def install_dependencies(venv: Path, env: dict[str, str]) -> None:
"""Install ESPHome and its development dependencies into the environment."""
uv = find_uv(venv, env)
run([uv, "pip", "install", "setuptools", "wheel"], env=env)
# The dev and test extras pull in requirements_dev.txt and
# requirements_test.txt, and the package itself pulls in requirements.txt,
# so this single install covers every requirements file.
run(
[
uv,
"pip",
"install",
"-e",
".[dev,test]",
"--config-settings",
"editable_mode=compat",
],
env=env,
)
def install_git_hooks(env: dict[str, str]) -> None:
"""Install the git hooks, but only when run from the main checkout.
A worktree shares one git hooks directory with the main checkout it was
created from. Installing from a worktree would point the shared hook at that
worktree's virtual environment, breaking it for everyone once the worktree is
removed.
"""
git_dir = git_output("rev-parse", "--absolute-git-dir")
common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir")
if not git_dir or not common_dir or Path(git_dir) != Path(common_dir):
return
prek = shutil.which("prek", path=env["PATH"])
if prek is None:
raise SystemExit("prek was not installed, aborting.")
# --overwrite replaces any hook already in place. Without it, prek finds a
# previously installed pre-commit hook, moves it aside to
# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
# run both tools.
run([prek, "install", "--overwrite"], env=env)
# Prepares the virtual environment for new checkouts and worktrees. Installed
# once here, it covers every worktree created from this checkout.
hooks_dir = Path(common_dir) / "hooks"
if hooks_dir.is_dir():
installed = hooks_dir / "post-checkout"
shutil.copyfile(POST_CHECKOUT_HOOK, installed)
installed.chmod(0o755)
def activate_hint() -> str:
"""Return the command that activates the environment this script creates."""
activate = bin_dir(DEFAULT_VENV).relative_to(ROOT) / "activate"
if os.name == "nt":
return str(activate)
return f"source {activate.as_posix()}"
def report(state: str, venv: Path) -> None:
"""Print the closing message for the environment that was set up."""
location = f"./{DEFAULT_VENV.name}"
print()
print()
if state == VENV_ACTIVE:
print("Dependencies installed into the active virtual environment:")
print(f" {venv}")
print(
f"It is already active in this shell, so no '{activate_hint()}' is needed."
)
elif state == VENV_REUSED:
print(
f"Dependencies updated in the existing {location}. "
f"Run '{activate_hint()}' to use it."
)
else:
print(
f"Virtual environment created at {location}. "
f"Run '{activate_hint()}' to use it."
)
def main() -> None:
"""Set up the development environment."""
if sys.version_info < MIN_PYTHON:
raise SystemExit(
f"ESPHome needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer, "
f"but this is Python {sys.version.split()[0]}."
)
# A virtual environment that is already active (for example the
# devcontainer's pre-provisioned esphome-venv) is installed into rather than
# creating a ./venv in the workspace.
if active := os.environ.get("VIRTUAL_ENV"):
state, venv = VENV_ACTIVE, Path(active)
elif venv_python(DEFAULT_VENV).is_file():
# Reuse the environment from an earlier run, so this script can be run
# again at any time to pick up dependency changes.
state, venv = VENV_REUSED, DEFAULT_VENV
else:
state, venv = VENV_CREATED, DEFAULT_VENV
create_venv(venv)
env = venv_environment(venv)
install_dependencies(venv, env)
install_git_hooks(env)
(ROOT / ".temp").mkdir(exist_ok=True)
report(state, venv)
if __name__ == "__main__":
main()
+2
View File
@@ -17,6 +17,8 @@ CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
CONFIG_PM_ENABLE=y
# esp32_ble
CONFIG_BT_ENABLED=y
@@ -0,0 +1,76 @@
"""The template platforms surface value-describing metadata on the main form.
Hardware platforms get sensible defaults for unit/device_class/etc., so those
fields fall through to the editor's advanced disclosure. A ``template`` entity
has no such defaults -- the user is expected to define them -- so the template
platforms pass ``visibility=cv.Visibility.UI`` to promote them onto the form.
"""
from __future__ import annotations
import importlib
import pytest
import esphome.config_validation as cv
def _markers(schema: cv.Schema) -> dict[str, object]:
s = schema
if hasattr(s, "validators"):
# cv.All -> the schema is the first validator.
s = s.validators[0]
return {str(k): k for k in s.schema}
@pytest.mark.parametrize(
("platform", "fields"),
[
(
"sensor",
[
"unit_of_measurement",
"accuracy_decimals",
"device_class",
"state_class",
"force_update",
],
),
("binary_sensor", ["device_class"]),
("switch", ["device_class"]),
("cover", ["device_class"]),
("button", ["device_class"]),
("valve", ["device_class"]),
("event", ["device_class"]),
("text_sensor", ["device_class"]),
("number", ["device_class", "unit_of_measurement"]),
],
)
def test_template_metadata_is_ui(platform: str, fields: list[str]) -> None:
mod = importlib.import_module(f"esphome.components.template.{platform}")
markers = _markers(mod.CONFIG_SCHEMA)
for field in fields:
assert markers[field].visibility is cv.Visibility.UI, f"{platform}.{field}"
def test_template_sensor_promotion_preserves_defaults() -> None:
"""Promoting to UI must not drop the fields' defaults."""
from esphome.components.template.sensor import CONFIG_SCHEMA
markers = _markers(CONFIG_SCHEMA)
assert markers["accuracy_decimals"].default() == 1
assert markers["force_update"].default() is False
def test_hardware_platform_metadata_not_promoted() -> None:
"""Without ``visibility=`` the builders leave metadata unset.
Unset markers fall through to the consumer's ``Optional`` default of
advanced, so hardware platforms are unaffected by the template promotion.
"""
from esphome.components import binary_sensor, sensor
hw_sensor = _markers(sensor.sensor_schema(device_class="temperature"))
assert hw_sensor["device_class"].visibility is None
hw_bs = _markers(binary_sensor.binary_sensor_schema(device_class="motion"))
assert hw_bs["device_class"].visibility is None
-8
View File
@@ -1,4 +1,3 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
@@ -6,10 +5,3 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
manifest.to_code = to_code_testing
@@ -157,74 +157,6 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
noise_cipherstate_free(recv_cipher);
}
// Drive one full NNpsk0 handshake between a fresh initiator and responder;
// responder_e receives the ephemeral public key the responder put on the
// wire (the clear text start of its message, taken before the initiator
// consumes the buffer in place)
static void run_handshake(NoiseResponderHandshake &responder, uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE]) {
const psk_t psk = make_psk(7);
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GE(reply_len, SPARE_EPHEMERAL_KEY_SIZE);
std::memcpy(responder_e, msg, SPARE_EPHEMERAL_KEY_SIZE);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
}
TEST(SpareEphemeralTest, EmptySlotLeavesHandshakeToGenerate) {
ASSERT_FALSE(has_spare_ephemeral());
NoiseResponderHandshake responder;
uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE];
run_handshake(responder, responder_e);
EXPECT_FALSE(has_spare_ephemeral());
}
TEST(SpareEphemeralTest, ConsumeHandsTheKeyToANewState) {
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
NoiseHandshakeState *state = nullptr;
ASSERT_EQ(noise_handshakestate_new_by_id(&state, &nid, NOISE_ROLE_RESPONDER), 0);
const psk_t psk = make_psk(7);
ASSERT_EQ(noise_handshakestate_set_pre_shared_key(state, psk.data(), psk.size()), 0);
ASSERT_EQ(noise_handshakestate_set_prologue(state, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(consume_spare_ephemeral(state), 0);
EXPECT_FALSE(has_spare_ephemeral());
noise_handshakestate_free(state);
}
TEST(SpareEphemeralTest, SlotKeyIsOnTheWireAndConsumedOnce) {
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
uint8_t expected_pub[SPARE_EPHEMERAL_KEY_SIZE];
std::memcpy(expected_pub, spare_ephemeral + SPARE_EPHEMERAL_KEY_SIZE, sizeof(expected_pub));
NoiseResponderHandshake first;
uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE];
run_handshake(first, responder_e);
// The spare, not a generated key, went out; and it went out once
EXPECT_EQ(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0);
EXPECT_FALSE(has_spare_ephemeral());
NoiseResponderHandshake second;
run_handshake(second, responder_e);
EXPECT_NE(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0);
EXPECT_FALSE(has_spare_ephemeral());
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
@@ -0,0 +1,34 @@
esphome:
name: set-internal-at-boot
on_boot:
then:
- lambda: |-
id(hidden_at_boot).set_internal(true);
id(shown_at_boot).set_internal(false);
host:
api:
actions:
- action: set_internal_late
then:
- lambda: id(untouched).set_internal(true);
logger:
sensor:
- platform: template
name: "Hidden At Boot"
id: hidden_at_boot
lambda: return 1.0;
- platform: template
name: "Shown At Boot"
id: shown_at_boot
internal: true
lambda: return 2.0;
- platform: template
name: "Untouched"
id: untouched
lambda: return 3.0;
@@ -0,0 +1,41 @@
"""Integration test for set_internal() called during and after setup."""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_set_internal_at_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""set_internal() in on_boot changes API exposure, later calls log an error."""
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
names = {entity.name for entity in entities}
assert "Hidden At Boot" not in names
assert "Shown At Boot" in names
assert "Untouched" in names
late = next(s for s in services if s.name == "set_internal_late")
await client.execute_service(late, {})
await waiter.wait_for(
"'Untouched'",
"set_internal() after setup is undefined behavior",
timeout=5.0,
)
# Still written during the deprecation window, ignored from 2027.3.0
entities, _ = await client.list_entities_services()
assert "Untouched" not in {entity.name for entity in entities}
+562
View File
@@ -0,0 +1,562 @@
"""Tests for script/setup.py."""
import importlib.util
import os
from pathlib import Path, PurePosixPath, PureWindowsPath
import runpy
import sys
from types import ModuleType
from unittest.mock import Mock, call, patch
import pytest
_SCRIPT = Path(__file__).parents[2] / "script" / "setup.py"
def _load_module() -> ModuleType:
spec = importlib.util.spec_from_file_location("script_setup", _SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def script_setup() -> ModuleType:
"""Fresh import of script/setup.py, isolated from other tests."""
return _load_module()
# --- bin_dir / venv_python / activate_hint -----------------------------------
def test_bin_dir_matches_host_layout(script_setup: ModuleType, tmp_path: Path) -> None:
"""The venv scheme resolves to Scripts on Windows and bin everywhere else."""
expected = "Scripts" if os.name == "nt" else "bin"
assert script_setup.bin_dir(tmp_path) == tmp_path / expected
# Both flavours are exercised on every host. Pure paths are used because a real
# Path refuses to change flavour: PosixPath cannot be built on Windows, and
# WindowsPath cannot be built on Unix.
def test_venv_python_posix(script_setup: ModuleType, tmp_path: Path) -> None:
with (
patch.object(
script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin")
),
patch.object(script_setup.os, "name", "posix"),
):
result = script_setup.venv_python(tmp_path)
assert result == PurePosixPath("/x/venv/bin/python")
def test_venv_python_nt(script_setup: ModuleType, tmp_path: Path) -> None:
with (
patch.object(
script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts")
),
patch.object(script_setup.os, "name", "nt"),
):
result = script_setup.venv_python(tmp_path)
assert result == PureWindowsPath(r"C:\x\venv\Scripts\python.exe")
def test_activate_hint_posix(script_setup: ModuleType) -> None:
with (
patch.object(script_setup, "ROOT", PurePosixPath("/x")),
patch.object(
script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin")
),
patch.object(script_setup.os, "name", "posix"),
):
hint = script_setup.activate_hint()
assert hint == "source venv/bin/activate"
def test_activate_hint_nt(script_setup: ModuleType) -> None:
with (
patch.object(script_setup, "ROOT", PureWindowsPath(r"C:\x")),
patch.object(
script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts")
),
patch.object(script_setup.os, "name", "nt"),
):
hint = script_setup.activate_hint()
# The nt branch returns str(activate) as-is, skipping the "source " prefix.
assert hint == r"venv\Scripts\activate"
# --- run -----------------------------------------------------------------
def test_run_success(script_setup: ModuleType) -> None:
with patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run:
script_setup.run(["echo", "hi"])
mock_run.assert_called_once_with(
["echo", "hi"], cwd=script_setup.ROOT, env=None, check=False
)
def test_run_failure_raises_system_exit_with_code(
script_setup: ModuleType, capsys: pytest.CaptureFixture[str]
) -> None:
with (
patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=7)),
pytest.raises(SystemExit) as excinfo,
):
script_setup.run(["false"])
assert excinfo.value.code == 7
assert "Failed with exit code 7: false" in capsys.readouterr().err
# --- git_output ------------------------------------------------------------
def test_git_output_success_strips_stdout(script_setup: ModuleType) -> None:
with patch.object(
script_setup.subprocess,
"run",
return_value=Mock(returncode=0, stdout=" /repo/.git \n"),
) as mock_run:
result = script_setup.git_output("rev-parse", "--absolute-git-dir")
assert result == "/repo/.git"
mock_run.assert_called_once_with(
["git", "rev-parse", "--absolute-git-dir"],
cwd=script_setup.ROOT,
capture_output=True,
text=True,
check=False,
)
def test_git_output_nonzero_returncode_is_empty(script_setup: ModuleType) -> None:
with patch.object(
script_setup.subprocess,
"run",
return_value=Mock(returncode=1, stdout="whatever"),
):
assert script_setup.git_output("status") == ""
def test_git_output_oserror_is_empty(script_setup: ModuleType) -> None:
with patch.object(script_setup.subprocess, "run", side_effect=OSError("no git")):
assert script_setup.git_output("status") == ""
# --- create_venv -----------------------------------------------------------
def test_create_venv_uses_uv_when_present(
script_setup: ModuleType, tmp_path: Path
) -> None:
venv = tmp_path / "venv"
with (
patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"),
patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run,
):
script_setup.create_venv(venv)
mock_run.assert_called_once_with(
["/usr/bin/uv", "venv", "--clear", "--seed", str(venv)],
cwd=script_setup.ROOT,
env=None,
check=False,
)
def test_create_venv_falls_back_to_venv_module(
script_setup: ModuleType, tmp_path: Path
) -> None:
venv = tmp_path / "venv"
with (
patch.object(script_setup.shutil, "which", return_value=None),
patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run,
):
script_setup.create_venv(venv)
mock_run.assert_called_once_with(
[sys.executable, "-m", "venv", "--clear", str(venv)],
cwd=script_setup.ROOT,
env=None,
check=False,
)
# --- venv_environment --------------------------------------------------------
def test_venv_environment_sets_virtual_env_and_prepends_path(
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
venv = tmp_path / "venv"
monkeypatch.setenv("PYTHONHOME", "/somewhere")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
env = script_setup.venv_environment(venv)
assert env["VIRTUAL_ENV"] == str(venv)
assert "PYTHONHOME" not in env
expected_prefix = str(script_setup.bin_dir(venv)) + os.pathsep
assert env["PATH"] == expected_prefix + "/usr/bin:/bin"
def test_venv_environment_path_fallback_when_unset(
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
venv = tmp_path / "venv"
monkeypatch.delenv("PATH", raising=False)
env = script_setup.venv_environment(venv)
# No trailing separator: an empty PATH entry means "search the cwd".
assert env["PATH"] == str(script_setup.bin_dir(venv))
# --- find_uv -----------------------------------------------------------------
def test_find_uv_found_immediately(script_setup: ModuleType, tmp_path: Path) -> None:
venv = tmp_path / "venv"
env = {"PATH": "/usr/bin"}
with (
patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"),
patch.object(script_setup.subprocess, "run") as mock_run,
):
result = script_setup.find_uv(venv, env)
assert result == "/usr/bin/uv"
mock_run.assert_not_called()
def test_find_uv_installed_then_found(script_setup: ModuleType, tmp_path: Path) -> None:
venv = tmp_path / "venv"
env = {"PATH": "/usr/bin"}
with (
patch.object(script_setup.shutil, "which", side_effect=[None, "/usr/bin/uv"]),
patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run,
):
result = script_setup.find_uv(venv, env)
assert result == "/usr/bin/uv"
mock_run.assert_called_once_with(
[str(script_setup.venv_python(venv)), "-m", "pip", "install", "uv"],
cwd=script_setup.ROOT,
env=env,
check=False,
)
def test_find_uv_still_missing_raises_system_exit(
script_setup: ModuleType, tmp_path: Path
) -> None:
venv = tmp_path / "venv"
env = {"PATH": "/usr/bin"}
with (
patch.object(script_setup.shutil, "which", side_effect=[None, None]),
patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=0)),
pytest.raises(SystemExit, match="uv could not be installed"),
):
script_setup.find_uv(venv, env)
# --- install_dependencies -----------------------------------------------------
def test_install_dependencies_installs_setuptools_then_project(
script_setup: ModuleType, tmp_path: Path
) -> None:
venv = tmp_path / "venv"
env = {"PATH": "/usr/bin"}
with (
patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"),
patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run,
):
script_setup.install_dependencies(venv, env)
assert mock_run.call_args_list == [
call(
["/usr/bin/uv", "pip", "install", "setuptools", "wheel"],
cwd=script_setup.ROOT,
env=env,
check=False,
),
call(
[
"/usr/bin/uv",
"pip",
"install",
"-e",
".[dev,test]",
"--config-settings",
"editable_mode=compat",
],
cwd=script_setup.ROOT,
env=env,
check=False,
),
]
# --- install_git_hooks ---------------------------------------------------------
def _fake_git_output(git_dir: str, common_dir: str):
def _run(*args: str) -> str:
if "--absolute-git-dir" in args:
return git_dir
return common_dir
return _run
def test_install_git_hooks_returns_early_when_git_dir_empty(
script_setup: ModuleType,
) -> None:
env = {"PATH": "/usr/bin"}
with (
patch.object(
script_setup, "git_output", side_effect=_fake_git_output("", "/repo/.git")
),
patch.object(script_setup.subprocess, "run") as mock_run,
):
script_setup.install_git_hooks(env)
mock_run.assert_not_called()
def test_install_git_hooks_returns_early_when_common_dir_empty(
script_setup: ModuleType,
) -> None:
env = {"PATH": "/usr/bin"}
with (
patch.object(
script_setup, "git_output", side_effect=_fake_git_output("/repo/.git", "")
),
patch.object(script_setup.subprocess, "run") as mock_run,
):
script_setup.install_git_hooks(env)
mock_run.assert_not_called()
def test_install_git_hooks_returns_early_for_worktree(
script_setup: ModuleType,
) -> None:
"""A worktree's git-dir differs from the shared common-dir."""
env = {"PATH": "/usr/bin"}
with (
patch.object(
script_setup,
"git_output",
side_effect=_fake_git_output("/repo/.git/worktrees/wt", "/repo/.git"),
),
patch.object(script_setup.subprocess, "run") as mock_run,
):
script_setup.install_git_hooks(env)
mock_run.assert_not_called()
def test_install_git_hooks_missing_prek_raises_system_exit(
script_setup: ModuleType,
) -> None:
env = {"PATH": "/usr/bin"}
with (
patch.object(
script_setup,
"git_output",
side_effect=_fake_git_output("/repo/.git", "/repo/.git"),
),
patch.object(script_setup.shutil, "which", return_value=None),
patch.object(script_setup.subprocess, "run") as mock_run,
pytest.raises(SystemExit, match="prek was not installed"),
):
script_setup.install_git_hooks(env)
mock_run.assert_not_called()
def test_install_git_hooks_happy_path_installs_hook(
script_setup: ModuleType, tmp_path: Path
) -> None:
env = {"PATH": "/usr/bin"}
common_dir = tmp_path / "repo" / ".git"
hooks_dir = common_dir / "hooks"
hooks_dir.mkdir(parents=True)
source_hook = tmp_path / "post-checkout"
source_hook.write_text("#!/bin/sh\necho post-checkout\n")
with (
patch.object(script_setup, "POST_CHECKOUT_HOOK", source_hook),
patch.object(
script_setup,
"git_output",
side_effect=_fake_git_output(str(common_dir), str(common_dir)),
),
patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"),
patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run,
):
script_setup.install_git_hooks(env)
mock_run.assert_called_once_with(
["/usr/bin/prek", "install", "--overwrite"],
cwd=script_setup.ROOT,
env=env,
check=False,
)
installed = hooks_dir / "post-checkout"
assert installed.read_text() == source_hook.read_text()
if os.name != "nt":
# Windows has no POSIX permission bits for chmod to set.
assert (installed.stat().st_mode & 0o777) == 0o755
def test_install_git_hooks_skips_copy_when_hooks_dir_missing(
script_setup: ModuleType, tmp_path: Path
) -> None:
"""The prek install still runs when the hooks directory does not exist."""
env = {"PATH": "/usr/bin"}
common_dir = tmp_path / "repo" / ".git"
common_dir.mkdir(parents=True) # no "hooks" subdirectory created
with (
patch.object(
script_setup,
"git_output",
side_effect=_fake_git_output(str(common_dir), str(common_dir)),
),
patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"),
patch.object(
script_setup.subprocess, "run", return_value=Mock(returncode=0)
) as mock_run,
):
script_setup.install_git_hooks(env)
mock_run.assert_called_once()
assert not (common_dir / "hooks").exists()
# --- report ------------------------------------------------------------------
def test_report_active_state(
script_setup: ModuleType, capsys: pytest.CaptureFixture
) -> None:
venv = Path("/opt/esphome-venv")
script_setup.report(script_setup.VENV_ACTIVE, venv)
out = capsys.readouterr().out
assert "Dependencies installed into the active virtual environment:" in out
assert str(venv) in out
assert "is already active in this shell" in out
def test_report_reused_state(
script_setup: ModuleType, capsys: pytest.CaptureFixture
) -> None:
script_setup.report(script_setup.VENV_REUSED, script_setup.DEFAULT_VENV)
out = capsys.readouterr().out
assert "Dependencies updated in the existing ./venv" in out
def test_report_created_state(
script_setup: ModuleType, capsys: pytest.CaptureFixture
) -> None:
script_setup.report(script_setup.VENV_CREATED, script_setup.DEFAULT_VENV)
out = capsys.readouterr().out
assert "Virtual environment created at ./venv" in out
# --- main --------------------------------------------------------------------
def test_main_raises_system_exit_when_python_too_old(
script_setup: ModuleType,
) -> None:
with (
patch.object(script_setup.sys, "version_info", (3, 11, 5)),
pytest.raises(SystemExit, match="ESPHome needs Python 3.12"),
):
script_setup.main()
def test_main_uses_active_virtual_env(
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
active_venv = tmp_path / "active-venv"
monkeypatch.setenv("VIRTUAL_ENV", str(active_venv))
with (
patch.object(script_setup, "ROOT", tmp_path),
patch.object(script_setup, "create_venv") as mock_create_venv,
patch.object(script_setup, "install_dependencies") as mock_install_deps,
patch.object(script_setup, "install_git_hooks") as mock_install_hooks,
patch.object(script_setup, "report") as mock_report,
):
script_setup.main()
mock_create_venv.assert_not_called()
mock_install_deps.assert_called_once()
mock_install_hooks.assert_called_once()
mock_report.assert_called_once_with(script_setup.VENV_ACTIVE, active_venv)
assert (tmp_path / ".temp").is_dir()
def test_main_reuses_existing_venv(
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
default_venv = tmp_path / "venv"
python_path = script_setup.venv_python(default_venv)
python_path.parent.mkdir(parents=True)
python_path.touch()
with (
patch.object(script_setup, "ROOT", tmp_path),
patch.object(script_setup, "DEFAULT_VENV", default_venv),
patch.object(script_setup, "create_venv") as mock_create_venv,
patch.object(script_setup, "install_dependencies") as mock_install_deps,
patch.object(script_setup, "install_git_hooks") as mock_install_hooks,
patch.object(script_setup, "report") as mock_report,
):
script_setup.main()
mock_create_venv.assert_not_called()
mock_install_deps.assert_called_once()
mock_install_hooks.assert_called_once()
mock_report.assert_called_once_with(script_setup.VENV_REUSED, default_venv)
assert (tmp_path / ".temp").is_dir()
def test_main_creates_new_venv(
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
default_venv = tmp_path / "venv" # does not exist yet
with (
patch.object(script_setup, "ROOT", tmp_path),
patch.object(script_setup, "DEFAULT_VENV", default_venv),
patch.object(script_setup, "create_venv") as mock_create_venv,
patch.object(script_setup, "install_dependencies") as mock_install_deps,
patch.object(script_setup, "install_git_hooks") as mock_install_hooks,
patch.object(script_setup, "report") as mock_report,
):
script_setup.main()
mock_create_venv.assert_called_once_with(default_venv)
mock_install_deps.assert_called_once()
mock_install_hooks.assert_called_once()
mock_report.assert_called_once_with(script_setup.VENV_CREATED, default_venv)
assert (tmp_path / ".temp").is_dir()
def test_run_as_script_calls_main(tmp_path: Path) -> None:
"""The __main__ guard runs the whole flow, with every side effect stubbed."""
completed = Mock(returncode=0, stdout="")
with (
patch("subprocess.run", return_value=completed) as mock_run,
patch("shutil.which", return_value="/usr/bin/uv"),
patch("pathlib.Path.mkdir") as mock_mkdir,
patch.dict(os.environ, {"VIRTUAL_ENV": str(tmp_path / "env")}),
):
runpy.run_path(str(_SCRIPT), run_name="__main__")
# The dependency install ran, and git reported no hooks directory to touch.
assert mock_run.called
mock_mkdir.assert_called_once_with(exist_ok=True)
@@ -1394,6 +1394,35 @@ def test_entity_metadata_visibility_hints() -> None:
assert web["web_server"].visibility is advanced
def test_with_visibility_remarks_keys() -> None:
"""``with_visibility`` re-marks the named keys, preserving each field's
default and validator, without touching the other keys or the input schema.
"""
base = cv.Schema(
{
cv.Optional("a", default=7): cv.int_,
cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string,
}
)
promoted = cv.with_visibility(base, cv.Visibility.UI, "a")
pm = {str(k): k for k in promoted.schema}
assert pm["a"].visibility is cv.Visibility.UI # re-marked
assert pm["a"].default() == 7 # default preserved
assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched
assert promoted({}) == {"a": 7} # validator/default still applied
# The input schema is left untouched (no shared-marker mutation).
assert {str(k): k for k in base.schema}["a"].visibility is None
def test_with_visibility_unknown_key_raises() -> None:
"""A key not present in the schema is a typo — fail at build time."""
base = cv.Schema({cv.Optional("a"): cv.int_})
with pytest.raises(ValueError, match="not in schema"):
cv.with_visibility(base, cv.Visibility.UI, "nope")
def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value)