diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index e1cee5005cb..42baa68b35d 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -5,6 +5,15 @@ // Implementation based on: // https://github.com/sciosense/ENS160_driver +// For best performance, the sensor shall be operated in normal indoor air in the range -5 to 60°C +// (typical: 25°C); relative humidity: 20 to 80%RH (typical: 50%RH), non-condensing with no aggressive +// or poisonous gases present. Prolonged exposure to environments outside these conditions can affect +// performance and lifetime of the sensor. +// The sensor is designed for indoor use and is not waterproof or dustproof. It should be protected from +// water, condensation, dust, and aggressive gases. Note that the status will only be stored in non-volatile +// memory after an initial 24 h of continuous operation. If unpowered before the conclusion of that period, +// the ENS160 will resume "Initial Start-up" mode after re-powering. + #include "ens160_base.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -14,7 +23,9 @@ namespace ens160_base { static const char *const TAG = "ens160"; -static const uint8_t ENS160_BOOTING = 10; +// Datasheet specifies 10ms, but some users report that 10ms is not sufficient for the +// sensor to boot and be ready for commands. 11ms seems to be a safe value. +static const uint8_t ENS160_BOOTING = 11; static const uint16_t ENS160_PART_ID = 0x0160; @@ -91,6 +102,8 @@ void ENS160Component::setup() { this->mark_failed(); return; } + delay(ENS160_BOOTING); + // clear command if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) { this->error_code_ = WRITE_FAILED; @@ -102,6 +115,7 @@ void ENS160Component::setup() { this->mark_failed(); return; } + delay(ENS160_BOOTING); // read firmware version if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) { @@ -109,6 +123,8 @@ void ENS160Component::setup() { this->mark_failed(); return; } + delay(ENS160_BOOTING); + uint8_t version_data[3]; if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) { this->error_code_ = READ_FAILED; @@ -223,7 +239,6 @@ void ENS160Component::update() { if (this->aqi_ != nullptr) { // remove reserved bits, just in case they are used in future data_aqi = ENS160_DATA_AQI & data_aqi; - this->aqi_->publish_state(data_aqi); } diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index a9624734d02..7861c0affa1 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -26,9 +26,9 @@ espnow_ns = cg.esphome_ns.namespace("espnow") ESPNowComponent = espnow_ns.class_("ESPNowComponent", cg.Component) # Handler interfaces that other components can use to register callbacks -ESPNowReceivedPacketHandler = espnow_ns.class_("ESPNowReceivedPacketHandler") +ESPNowReceivePacketHandler = espnow_ns.class_("ESPNowReceivePacketHandler") ESPNowUnknownPeerHandler = espnow_ns.class_("ESPNowUnknownPeerHandler") -ESPNowBroadcastedHandler = espnow_ns.class_("ESPNowBroadcastedHandler") +ESPNowBroadcastHandler = espnow_ns.class_("ESPNowBroadcastHandler") ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") @@ -48,10 +48,10 @@ OnUnknownPeerTrigger = espnow_ns.class_( "OnUnknownPeerTrigger", ESPNowHandlerTrigger, ESPNowUnknownPeerHandler ) OnReceiveTrigger = espnow_ns.class_( - "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivedPacketHandler + "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivePacketHandler ) -OnBroadcastedTrigger = espnow_ns.class_( - "OnBroadcastedTrigger", ESPNowHandlerTrigger, ESPNowBroadcastedHandler +OnBroadcastTrigger = espnow_ns.class_( + "OnBroadcastTrigger", ESPNowHandlerTrigger, ESPNowBroadcastHandler ) @@ -94,7 +94,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastedTrigger), + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastTrigger), cv.Optional(CONF_ADDRESS): cv.mac_address, } ), @@ -140,11 +140,11 @@ async def to_code(config): for on_receive in config.get(CONF_ON_RECEIVE, []): trigger = await _trigger_to_code(on_receive) - cg.add(var.register_received_handler(trigger)) + cg.add(var.register_receive_handler(trigger)) for on_receive in config.get(CONF_ON_BROADCAST, []): trigger = await _trigger_to_code(on_receive) - cg.add(var.register_broadcasted_handler(trigger)) + cg.add(var.register_broadcast_handler(trigger)) # ========================================== A C T I O N S ================================================ diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 0fbb14e3888..9c3c55e4eff 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -67,6 +67,7 @@ template class SendAction : public Action, public Parente } } + protected: void play(const Ts &...x) override { /* ignore - see play_complex */ } @@ -75,7 +76,6 @@ template class SendAction : public Action, public Parente this->error_.stop(); } - protected: ActionList sent_; ActionList error_; @@ -89,7 +89,7 @@ template class SendAction : public Action, public Parente template class AddPeerAction : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); - public: + protected: void play(const Ts &...x) override { peer_address_t address = this->address_.value(x...); this->parent_->add_peer(address.data()); @@ -99,7 +99,7 @@ template class AddPeerAction : public Action, public Pare template class DeletePeerAction : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); - public: + protected: void play(const Ts &...x) override { peer_address_t address = this->address_.value(x...); this->parent_->del_peer(address.data()); @@ -107,8 +107,9 @@ template class DeletePeerAction : public Action, public P }; template class SetChannelAction : public Action, public Parented { - public: TEMPLATABLE_VALUE(uint8_t, channel) + + protected: void play(const Ts &...x) override { if (this->parent_->is_wifi_enabled()) { return; @@ -125,9 +126,9 @@ class OnReceiveTrigger : public Triggeraddress_, address.data(), ESP_NOW_ETH_ALEN); } - explicit OnReceiveTrigger() : has_address_(false) {} + explicit OnReceiveTrigger() {} - bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -138,7 +139,7 @@ class OnReceiveTrigger : public Trigger, public ESPNowUnknownPeerHandler { @@ -148,15 +149,15 @@ class OnUnknownPeerTrigger : public Trigger, - public ESPNowBroadcastedHandler { +class OnBroadcastTrigger : public Trigger, + public ESPNowBroadcastHandler { public: - explicit OnBroadcastedTrigger(std::array address) : has_address_(true) { + explicit OnBroadcastTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); } - explicit OnBroadcastedTrigger() : has_address_(false) {} + explicit OnBroadcastTrigger() {} - bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -167,7 +168,7 @@ class OnBroadcastedTrigger : public Triggerpacket_.receive.data, packet->packet_.receive.size)); #endif if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { - for (auto *handler : this->broadcasted_handlers_) { - if (handler->on_broadcasted(info, packet->packet_.receive.data, packet->packet_.receive.size)) + for (auto *handler : this->broadcast_handlers_) { + if (handler->on_broadcast(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers } } else { - for (auto *handler : this->received_handlers_) { - if (handler->on_received(info, packet->packet_.receive.data, packet->packet_.receive.size)) + for (auto *handler : this->receive_handlers_) { + if (handler->on_receive(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers } } diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ee4adc1b4d4..ff9581ec2ff 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -31,8 +31,8 @@ using peer_address_t = std::array; enum class ESPNowTriggers : uint8_t { TRIGGER_NONE = 0, ON_NEW_PEER = 1, - ON_RECEIVED = 2, - ON_BROADCASTED = 3, + ON_RECEIVE = 2, + ON_BROADCAST = 3, ON_SUCCEED = 10, ON_FAILED = 11, }; @@ -74,18 +74,18 @@ class ESPNowReceivedPacketHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; -/// Handler interface for receiving broadcasted ESPNow packets +/// Handler interface for receiving ESPNow broadcast packets /// Components should inherit from this class to handle incoming ESPNow data -class ESPNowBroadcastedHandler { +class ESPNowBroadcastHandler { public: - /// Called when a broadcasted ESPNow packet is received + /// Called when an ESPNow broadcast packet is received /// @param info Information about the received packet (sender MAC, etc.) /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; class ESPNowComponent : public Component { @@ -136,13 +136,11 @@ class ESPNowComponent : public Component { esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback = nullptr); - void register_received_handler(ESPNowReceivedPacketHandler *handler) { this->received_handlers_.push_back(handler); } + void register_receive_handler(ESPNowReceivedPacketHandler *handler) { this->receive_handlers_.push_back(handler); } void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { this->unknown_peer_handlers_.push_back(handler); } - void register_broadcasted_handler(ESPNowBroadcastedHandler *handler) { - this->broadcasted_handlers_.push_back(handler); - } + void register_broadcast_handler(ESPNowBroadcastHandler *handler) { this->broadcast_handlers_.push_back(handler); } protected: friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); @@ -156,8 +154,8 @@ class ESPNowComponent : public Component { void send_(); std::vector unknown_peer_handlers_; - std::vector received_handlers_; - std::vector broadcasted_handlers_; + std::vector receive_handlers_; + std::vector broadcast_handlers_; std::vector peers_{}; diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 6e4f606466f..384e3fe2a9a 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -26,10 +26,10 @@ void ESPNowTransport::setup() { this->peer_address_[5]); // Register received handler - this->parent_->register_received_handler(this); + this->parent_->register_receive_handler(this); - // Register broadcasted handler - this->parent_->register_broadcasted_handler(this); + // Register broadcast handler + this->parent_->register_broadcast_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { @@ -56,7 +56,7 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { }); } -bool ESPNowTransport::on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { +bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); @@ -71,7 +71,7 @@ bool ESPNowTransport::on_received(const ESPNowRecvInfo &info, const uint8_t *dat return false; // Allow other handlers to run } -bool ESPNowTransport::on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { +bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index d85119db7dd..98c33f01fdb 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -15,7 +15,7 @@ namespace espnow { class ESPNowTransport : public packet_transport::PacketTransport, public Parented, public ESPNowReceivedPacketHandler, - public ESPNowBroadcastedHandler { + public ESPNowBroadcastHandler { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } @@ -25,8 +25,8 @@ class ESPNowTransport : public packet_transport::PacketTransport, } // ESPNow handler interface - bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; - bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; protected: void send_packet(const std::vector &buf) const override; diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd3..579491fe1a3 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -24,6 +24,8 @@ def AUTO_LOAD() -> list[str]: components = ["safe_mode"] if not CORE.using_zephyr: components.extend(["md5"]) + if CORE.is_esp32: + components.extend(["watchdog"]) return components diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 598fce1562a..b4b38a192f0 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -2,6 +2,7 @@ #include "ota_backend_esp_idf.h" #include "esphome/components/md5/md5.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -28,29 +29,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 - wdtc.idle_core_mask |= (1 << 0); -#endif -#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); -#endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); -#endif - + watchdog::WatchdogManager watchdog(15000); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index 545d83a6791..edf113b0b4a 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -39,9 +39,18 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { #ifdef USE_ESP32 esp_task_wdt_config_t wdt_config = { .timeout_ms = timeout_ms, - .idle_core_mask = (1U << CONFIG_FREERTOS_NUMBER_OF_CORES) - 1U, - .trigger_panic = true, + .idle_core_mask = 0, + .trigger_panic = false, }; +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 + wdt_config.idle_core_mask |= (1U << 0U); +#endif +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 + wdt_config.idle_core_mask |= (1U << 1U); +#endif +#if CONFIG_ESP_TASK_WDT_PANIC + wdt_config.trigger_panic = true; +#endif esp_task_wdt_reconfigure(&wdt_config); #endif // USE_ESP32 diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 099bbfef08e..6cabbd24f8d 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -57,3 +57,34 @@ binary_sensor: return true; } return false; + +# Exercise fan.turn_on with various field combinations so the +# TurnOnAction codegen paths get build coverage. +button: + - platform: template + name: "Fan Speed Only" + on_press: + - fan.turn_on: + id: test_fan + speed: 2 + - platform: template + name: "Fan Oscillating + Direction" + on_press: + - fan.turn_on: + id: test_fan + oscillating: true + direction: REVERSE + - platform: template + name: "Fan All Fields" + on_press: + - fan.turn_on: + id: test_fan + oscillating: false + speed: 3 + direction: FORWARD + - platform: template + name: "Fan Lambda Speed" + on_press: + - fan.turn_on: + id: test_fan + speed: !lambda 'return 1;' diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index daa6f53d42f..819eaa8bbfe 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -442,6 +442,20 @@ valve: state: CLOSED stop_action: - logger.log: stop_action + # Exercise valve.control with various field combinations so the + # ControlAction codegen paths get build coverage. + - valve.control: + id: template_valve + stop: true + - valve.control: + id: template_valve + position: 50% + - valve.control: + id: template_valve + state: OPEN + - valve.control: + id: template_valve + position: !lambda 'return 0.25f;' optimistic: true text: diff --git a/tests/integration/fixtures/fan_turn_on_action.yaml b/tests/integration/fixtures/fan_turn_on_action.yaml new file mode 100644 index 00000000000..11bf033e48c --- /dev/null +++ b/tests/integration/fixtures/fan_turn_on_action.yaml @@ -0,0 +1,59 @@ +esphome: + name: fan-turn-on-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_speed + type: int + initial_value: "2" + +fan: + - platform: template + id: test_fan + name: "Test Fan" + has_oscillating: true + has_direction: true + speed_count: 5 + +button: + # fan.turn_on: speed only + - platform: template + id: btn_speed + name: "Set Speed" + on_press: + - fan.turn_on: + id: test_fan + speed: 3 + + # fan.turn_on: oscillating + direction (no speed) + - platform: template + id: btn_oscillate_direction + name: "Set Oscillate Direction" + on_press: + - fan.turn_on: + id: test_fan + oscillating: true + direction: REVERSE + + # fan.turn_on: all three fields + - platform: template + id: btn_all_fields + name: "Set All Fields" + on_press: + - fan.turn_on: + id: test_fan + oscillating: false + speed: 4 + direction: FORWARD + + # fan.turn_on: lambda for speed (exercises lambda path) + - platform: template + id: btn_lambda_speed + name: "Lambda Speed" + on_press: + - fan.turn_on: + id: test_fan + speed: !lambda "return id(test_speed);" diff --git a/tests/integration/fixtures/valve_control_action.yaml b/tests/integration/fixtures/valve_control_action.yaml new file mode 100644 index 00000000000..4f43d16289c --- /dev/null +++ b/tests/integration/fixtures/valve_control_action.yaml @@ -0,0 +1,69 @@ +esphome: + name: valve-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_position + type: float + initial_value: "0.42" + +valve: + - platform: template + name: "Test Valve" + id: test_valve + has_position: true + optimistic: true + assumed_state: true + open_action: + - valve.template.publish: + id: test_valve + position: 1.0 + close_action: + - valve.template.publish: + id: test_valve + position: 0.0 + stop_action: + - valve.template.publish: + id: test_valve + current_operation: IDLE + +button: + # valve.control: position only + - platform: template + id: btn_position + name: "Set Position" + on_press: + - valve.control: + id: test_valve + position: 50% + + # valve.control: state alias for position 1.0 + - platform: template + id: btn_open_state + name: "Open State" + on_press: + - valve.control: + id: test_valve + state: OPEN + + # valve.control: lambda position (exercises lambda path) + - platform: template + id: btn_lambda_position + name: "Lambda Position" + on_press: + - valve.control: + id: test_valve + position: !lambda "return id(test_position);" + + # valve.control: stop only — template valve's stop_action publishes + # current_operation: IDLE. + - platform: template + id: btn_stop + name: "Stop Valve" + on_press: + - valve.control: + id: test_valve + stop: true diff --git a/tests/integration/test_fan_turn_on_action.py b/tests/integration/test_fan_turn_on_action.py new file mode 100644 index 00000000000..bce258cb5cd --- /dev/null +++ b/tests/integration/test_fan_turn_on_action.py @@ -0,0 +1,75 @@ +"""Integration test for fan TurnOnAction. + +Tests that fan.turn_on automation actions work correctly across multiple +field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, FanDirection, FanInfo, FanState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_fan_turn_on_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test fan TurnOnAction with constants and a lambda.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + fan_state_future: asyncio.Future[FanState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, FanState) + and fan_state_future is not None + and not fan_state_future.done() + ): + fan_state_future.set_result(state) + + async def wait_for_fan_state(timeout: float = 5.0) -> FanState: + nonlocal fan_state_future + fan_state_future = loop.create_future() + try: + return await asyncio.wait_for(fan_state_future, timeout) + finally: + fan_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_fan", FanInfo) + + async def press_and_wait(name: str) -> FanState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_fan_state() + + # speed only + state = await press_and_wait("Set Speed") + assert state.state is True + assert state.speed_level == 3 + + # oscillating + direction + state = await press_and_wait("Set Oscillate Direction") + assert state.oscillating is True + assert state.direction == FanDirection.REVERSE + + # all three fields + state = await press_and_wait("Set All Fields") + assert state.oscillating is False + assert state.speed_level == 4 + assert state.direction == FanDirection.FORWARD + + # lambda path: speed computed at runtime (test_speed global = 2) + state = await press_and_wait("Lambda Speed") + assert state.speed_level == 2 diff --git a/tests/integration/test_valve_control_action.py b/tests/integration/test_valve_control_action.py new file mode 100644 index 00000000000..d6515b8960e --- /dev/null +++ b/tests/integration/test_valve_control_action.py @@ -0,0 +1,72 @@ +"""Integration test for valve ControlAction. + +Tests that valve.control automation actions work correctly across multiple +field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, ValveInfo, ValveOperation, ValveState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_valve_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test valve ControlAction with constants and a lambda.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + valve_state_future: asyncio.Future[ValveState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, ValveState) + and valve_state_future is not None + and not valve_state_future.done() + ): + valve_state_future.set_result(state) + + async def wait_for_valve_state(timeout: float = 5.0) -> ValveState: + nonlocal valve_state_future + valve_state_future = loop.create_future() + try: + return await asyncio.wait_for(valve_state_future, timeout) + finally: + valve_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_valve", ValveInfo) + + async def press_and_wait(name: str) -> ValveState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_valve_state() + + # valve.control: position only + state = await press_and_wait("Set Position") + assert state.position == pytest.approx(0.5, abs=0.01) + + # valve.control: state alias for position 1.0 + state = await press_and_wait("Open State") + assert state.position == pytest.approx(1.0, abs=0.01) + + # valve.control: lambda position (test_position global = 0.42) + state = await press_and_wait("Lambda Position") + assert state.position == pytest.approx(0.42, abs=0.01) + + # valve.control: stop only — template valve's stop_action publishes + # current_operation: IDLE. + state = await press_and_wait("Stop Valve") + assert state.current_operation == ValveOperation.IDLE diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 0b545f9ed8b..865487f617a 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -479,6 +479,7 @@ class _BodyReadErrorResponse: def __init__(self, exc: Exception) -> None: self._exc = exc + self.headers: dict[str, str] = {} def raise_for_status(self) -> None: return None