Compare commits

..
Author SHA1 Message Date
J. Nick Koston 2b4e94901f [esp32_ble_client] Keep INIT when a connect is rejected before registration
The ble_client.connect action can reach connect() while the client is
still in INIT; dropping it to IDLE there skipped the only registration
path until the next disable cycle.
2026-09-10 09:51:47 -05:00
J. Nick Koston decbeb6a80 [esp32_ble_tracker] Quiet the teardown of a scan that is still starting
stop_scan_() logs an error for STARTING, which the settle right after it
makes moot; only ask the controller to stop a scan that is running. Fold the
two adjacent client-count guards and fix the services_released_ comment.
2026-09-10 09:34:08 -05:00
J. Nick Koston 2a9d11f14b [esp32_ble] Honour an enable made during the disable callbacks
The before-disabled callbacks can run user automations (on_scan_end fires
when the tracker settles the scanner), so an enable issued there was flipped
to ACTIVE and then overwritten by the DISABLED write after the teardown.
Turn it into a bring-up instead. ble_client also skips advertisements until
its app is registered, so the tracker does not stop the scan for a connect
that would be rejected.
2026-09-10 08:50:34 -05:00
J. Nick Koston 7f4de6c5bd [esp32_ble_tracker] Mark the disable from the hook and trim the transition code
The tracker now learns about a disable from the before-disabled handler
instead of polling is_active(), so a cancelled disable never reaches it and
the scan restart no longer needs an IDLE gate. enable() and disable() are
inline wrappers over a per-direction chain, ble_client skips the cache
clean on a stack that is going down, and a stale comment and doc are
brought up to date.
2026-09-10 08:48:00 -05:00
J. Nick Koston 8b273cc912 [esp32_ble] Fold enable and disable into one request table 2026-09-10 08:34:25 -05:00
J. Nick Koston 441a2c5ae9 [esp32_ble] Share the pending transition cancel 2026-09-10 08:11:24 -05:00
J. Nick Koston 216707f507 [esp32_ble] Cancel a pending transition instead of ignoring the opposite request
enable() while a disable was still pending was ignored, so a disable followed
by an enable before the next loop pass left BLE off. Cancel the pending
transition in both directions; nothing has been torn down or brought up yet.
The tracker only restarts its scan after a re-enable from IDLE, since a
cancelled disable never stopped it.
2026-09-10 08:06:53 -05:00
J. Nick Koston 64b4465df3 [esp32_ble] Name the event queue drain 2026-09-10 08:02:38 -05:00
J. Nick Koston 6629de81b8 [esp32_ble_client] Settle a live link and clear the interface before the stack goes down
Without the replayed close events a connected ble_client kept its services
across a ble.disable cycle and grew a second copy on reconnect. Free them and
report the disconnect from the hook, forget the old interface, and refuse an
open before the app is registered on the new stack, matching the proxy backend.
2026-09-10 07:56:27 -05:00
J. Nick Koston 68cbe019c6 [bluetooth_connection] Keep the stack-down reset in one place
The loop backstop only ran one pass earlier than the tracker hook and forced
the reset into a helper with extra branches; the hook alone now does the
reset. The unregistered-interface check moves to connect(), where it covers
both the INIT state and the window before REG_EVT with one predicate.
2026-09-10 07:51:57 -05:00
J. Nick Koston 8c772b4dd4 [esp32_ble] Drop queued events and settle the scanner when the stack is dismantled
Events the old stack queued while going down were replayed after the next
ble.enable against a fresh stack that hands out the same interface ids, and
the scanner relied on that replay to leave STOPPING. Drain the queue after
the teardown and put the scanner back to IDLE from the before-disabled
handler, since no completion can arrive anymore.
2026-09-10 07:39:51 -05:00
J. Nick Koston a84269d6b0 [esp32_ble_tracker] Reset GATT clients before the BLE stack is dismantled
An idle GATT client slot has its loop disabled, so the stack-down reset in
loop() never runs across a ble.disable/ble.enable cycle. The slot keeps the
gattc interface of the torn-down stack and never registers again; the next
esp_ble_gattc_open on that interface is dropped by Bluedroid without any
event and the slot stays in CONNECTING forever, which also blocks scanning.

Give ESPBTClient a before-disabled hook, fan it out from the tracker's
existing handler, and have both client implementations settle any link,
go back to INIT and enable their loop so they register on the new stack.
The Bluedroid backend also refuses to open on an unregistered interface
instead of waiting for an event that cannot come.
2026-09-10 07:39:22 -05: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
34 changed files with 1323 additions and 219 deletions
-3
View File
@@ -9,9 +9,6 @@ updates:
ignore:
# Hypotehsis is only used for testing and is updated quite often
- dependency-name: hypothesis
# PlatformIO 6.2.0 is not supported by the pioarduino ESP32 platform or
# LibreTiny yet, remove once both platforms support it
- dependency-name: platformio
- package-ecosystem: github-actions
labels:
- "dependencies"
+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!
@@ -45,15 +45,7 @@ void BluedroidGattClient::setup() {
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
// frees its slot, then re-register the app on the next enable.
auto down_st = this->state();
if (down_st != ClientState::IDLE && down_st != ClientState::INIT) {
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
}
this->set_state(ClientState::INIT);
// ble_before_disabled_event_handler() settles the slot.
return;
}
auto st = this->state();
@@ -65,7 +57,7 @@ void BluedroidGattClient::loop() {
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret);
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
// Do not wait for REG_EVT; connect() rejects until it lands.
this->set_idle_();
} else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) {
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
@@ -78,8 +70,8 @@ void BluedroidGattClient::loop() {
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT);
}
} else {
// The loop stays on while a link exists (stack-down watch, pre-started
// search flush); it settles only back at IDLE.
// The loop stays on while a link exists (pre-started search flush); it
// settles only back at IDLE.
this->deliver_pending_search_();
if (this->state() == ClientState::IDLE) {
this->disable_loop();
@@ -87,6 +79,22 @@ void BluedroidGattClient::loop() {
}
}
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
// frees its slot, then register the app again on the next enable.
void BluedroidGattClient::ble_before_disabled_event_handler() {
auto st = this->state();
if (st != ClientState::IDLE && st != ClientState::INIT) {
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
}
// The interface belongs to the torn-down stack.
this->gattc_if_ = ESP_GATT_IF_NONE;
this->set_state(ClientState::INIT);
// An idle slot runs no loop; the INIT branch must run to register again.
this->enable_loop();
}
void BluedroidGattClient::dump_config() {
ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_);
if (this->is_failed()) {
@@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() {
// ---- contract ops ----
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
if (this->gattc_if_ == ESP_GATT_IF_NONE) {
// Bluedroid drops an open on an unknown interface without any event.
ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_);
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
// Only from idle: clobbering DISCONNECTING would open a new link the
// stale CLOSE_EVT then tears down.
if (this->state() != ClientState::IDLE) {
@@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
void connect() override;
void disconnect() override;
void ble_before_disabled_event_handler() override;
bool wants_parsed_advertisements() override { return false; }
void on_scan_end() override {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
@@ -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;
+22 -13
View File
@@ -83,18 +83,23 @@ void ESP32BLE::setup() {
}
}
void ESP32BLE::enable() {
if (this->state_ != BLE_COMPONENT_STATE_DISABLED)
return;
this->state_ = BLE_COMPONENT_STATE_ENABLE;
}
void ESP32BLE::disable() {
if (this->state_ == BLE_COMPONENT_STATE_DISABLED)
return;
this->state_ = BLE_COMPONENT_STATE_DISABLE;
// Queue the transition for loop(). A pending transition the other way is
// cancelled instead, since nothing was torn down or brought up yet; any other
// state is already there or on its way.
void ESP32BLE::request_state_(bool enable) {
if (enable) {
if (this->state_ == BLE_COMPONENT_STATE_DISABLED) {
this->state_ = BLE_COMPONENT_STATE_ENABLE;
} else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) {
this->state_ = BLE_COMPONENT_STATE_ACTIVE;
}
} else {
if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) {
this->state_ = BLE_COMPONENT_STATE_DISABLE;
} else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) {
this->state_ = BLE_COMPONENT_STATE_DISABLED;
}
}
}
#ifdef USE_ESP32_BLE_ADVERTISING
@@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() {
this->mark_failed();
return;
}
this->state_ = BLE_COMPONENT_STATE_DISABLED;
this->drain_ble_events_();
// A status callback may have asked for BLE back; the stack is down now, so
// that request becomes a bring-up.
this->state_ =
this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED;
} else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) {
ESP_LOGD(TAG, "Enabling");
this->state_ = BLE_COMPONENT_STATE_OFF;
+11 -2
View File
@@ -102,8 +102,8 @@ class ESP32BLE final : public Component {
}
uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; }
void enable();
void disable();
void enable() { this->request_state_(true); }
void disable() { this->request_state_(false); }
ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; }
void setup() override;
void loop() override;
@@ -176,6 +176,15 @@ class ESP32BLE final : public Component {
bool ble_setup_();
bool ble_dismantle_();
void request_state_(bool enable);
// Drop what the old stack queued; the next stack reuses the same interface ids.
void drain_ble_events_() {
BLEEvent *ble_event;
while ((ble_event = this->ble_events_.pop()) != nullptr) {
this->ble_event_pool_.release(ble_event);
}
this->ble_events_.get_and_reset_dropped_count();
}
bool ble_pre_setup_();
#ifdef USE_ESP32_BLE_ADVERTISING
void advertising_init_();
@@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) {
void BLEClientBase::loop() {
if (!esp32_ble::global_ble->is_active()) {
this->set_state(espbt::ClientState::INIT);
// ble_before_disabled_event_handler() resets the client.
return;
}
if (this->state() == espbt::ClientState::INIT) {
@@ -72,6 +72,21 @@ void BLEClientBase::loop() {
float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
void BLEClientBase::ble_before_disabled_event_handler() {
auto st = this->state();
if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) {
// No CLOSE_EVT will come: free the services and settle the link.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST);
}
// The interface belongs to the torn-down stack.
this->gattc_if_ = ESP_GATT_IF_NONE;
this->set_state(espbt::ClientState::INIT);
// An idle client runs no loop; the INIT branch must run to register again.
this->enable_loop();
}
void BLEClientBase::dump_config() {
ESP_LOGCONFIG(TAG,
" Address: %s\n"
@@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) {
return false;
if (this->state() != espbt::ClientState::IDLE)
return false;
// Not registered on this stack yet; promoting now would stop the scan for a
// connect that connect() rejects anyway.
if (this->gattc_if_ == ESP_GATT_IF_NONE)
return false;
this->log_event_("Found device");
if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG)
@@ -117,6 +136,15 @@ void BLEClientBase::connect() {
this->connection_index_, this->address_str_);
return;
}
if (this->gattc_if_ == ESP_GATT_IF_NONE) {
// Bluedroid drops an open on an unknown interface without any event.
this->log_warning_("Connect rejected, GATT app not registered");
// INIT stays so loop() still registers; only a promoted client goes back.
if (this->state() == espbt::ClientState::DISCOVERED) {
this->set_state(espbt::ClientState::IDLE);
}
return;
}
ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_);
this->paired_ = false;
// A registration whose event never arrived must not block this connection's release.
@@ -199,7 +227,10 @@ void BLEClientBase::release_services() {
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
esp_ble_gattc_cache_clean(this->remote_bda_);
// A stack on its way down frees its own cache.
if (esp32_ble::global_ble->is_active()) {
esp_ble_gattc_cache_clean(this->remote_bda_);
}
#endif
}
@@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void connect() override;
esp_err_t pair();
void disconnect() override;
void ble_before_disabled_event_handler() override;
void unconditional_disconnect();
void release_services();
@@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
#endif
// Group 3: 4-byte types
int gattc_if_;
int gattc_if_{ESP_GATT_IF_NONE};
esp_gatt_status_t status_{ESP_GATT_OK};
// Group 4: Arrays
@@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
uint8_t pending_notify_regs_{0};
bool auto_connect_{false};
bool paired_{false};
// Set only when release_services() cleans the stack's GATT cache, which no API may then walk
// Always set by release_services(); the stack's GATT database must not be walked after it
bool services_released_{false};
// 8 bytes used, no padding
@@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
/// override this to release that state. `reason` is the controller reason code,
/// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST
/// for the stack going down.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
@@ -74,11 +74,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u
void ESP32BLETracker::loop() {
if (!this->parent_->is_active()) {
this->ble_was_disabled_ = true;
return;
} else if (this->ble_was_disabled_) {
}
if (this->ble_was_disabled_) {
this->ble_was_disabled_ = false;
// If the BLE stack was disabled, we need to start the scan again.
// First start after boot or after the stack came back.
if (this->scan_continuous_) {
this->start_scan();
}
@@ -218,7 +218,24 @@ void ESP32BLETracker::stop_scan() {
this->stop_scan_();
}
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
void ESP32BLETracker::ble_before_disabled_event_handler() {
// Tell the controller to stop; a scan still starting has nothing to stop yet.
if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) {
this->stop_scan_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->ble_before_disabled_event_handler();
}
this->skip_next_scan_end_ = false;
#endif
// The stop above never completes (stack torn down, events dropped); settle
// here so start_scan_() sees IDLE once the stack is back.
if (this->scanner_state_ != ScannerState::IDLE) {
this->cleanup_scan_state_(true);
}
this->ble_was_disabled_ = true;
}
bool ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
@@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener {
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0;
virtual void connect() = 0;
virtual void disconnect() = 0;
/// Called right before the BLE stack is dismantled. Nothing in flight will
/// complete, and the GATT app must register again once the stack is back.
virtual void ble_before_disabled_event_handler() {}
bool disconnect_pending() const { return this->want_disconnect_; }
void cancel_pending_disconnect() { this->want_disconnect_ = false; }
+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
@@ -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,
+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."""
+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()
@@ -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
+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)