Merge branch 'ota-watchdogmanager' into integration

This commit is contained in:
J. Nick Koston
2026-04-29 14:34:36 -05:00
17 changed files with 398 additions and 71 deletions
+17 -2
View File
@@ -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);
}
+8 -8
View File
@@ -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 ================================================
+14 -13
View File
@@ -67,6 +67,7 @@ template<typename... Ts> class SendAction : public Action<Ts...>, public Parente
}
}
protected:
void play(const Ts &...x) override { /* ignore - see play_complex */
}
@@ -75,7 +76,6 @@ template<typename... Ts> class SendAction : public Action<Ts...>, public Parente
this->error_.stop();
}
protected:
ActionList<Ts...> sent_;
ActionList<Ts...> error_;
@@ -89,7 +89,7 @@ template<typename... Ts> class SendAction : public Action<Ts...>, public Parente
template<typename... Ts> class AddPeerAction : public Action<Ts...>, public Parented<ESPNowComponent> {
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<typename... Ts> class AddPeerAction : public Action<Ts...>, public Pare
template<typename... Ts> class DeletePeerAction : public Action<Ts...>, public Parented<ESPNowComponent> {
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<typename... Ts> class DeletePeerAction : public Action<Ts...>, public P
};
template<typename... Ts> class SetChannelAction : public Action<Ts...>, public Parented<ESPNowComponent> {
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 Trigger<const ESPNowRecvInfo &, const uint8_t *,
memcpy(this->address_, 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<const ESPNowRecvInfo &, const uint8_t *,
protected:
bool has_address_{false};
uint8_t address_[ESP_NOW_ETH_ALEN];
uint8_t address_[ESP_NOW_ETH_ALEN]{};
};
class OnUnknownPeerTrigger : public Trigger<const ESPNowRecvInfo &, const uint8_t *, uint8_t>,
public ESPNowUnknownPeerHandler {
@@ -148,15 +149,15 @@ class OnUnknownPeerTrigger : public Trigger<const ESPNowRecvInfo &, const uint8_
return false; // Return false to continue processing other internal handlers
}
};
class OnBroadcastedTrigger : public Trigger<const ESPNowRecvInfo &, const uint8_t *, uint8_t>,
public ESPNowBroadcastedHandler {
class OnBroadcastTrigger : public Trigger<const ESPNowRecvInfo &, const uint8_t *, uint8_t>,
public ESPNowBroadcastHandler {
public:
explicit OnBroadcastedTrigger(std::array<uint8_t, ESP_NOW_ETH_ALEN> address) : has_address_(true) {
explicit OnBroadcastTrigger(std::array<uint8_t, ESP_NOW_ETH_ALEN> 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 Trigger<const ESPNowRecvInfo &, const uint8_
protected:
bool has_address_{false};
uint8_t address_[ESP_NOW_ETH_ALEN];
uint8_t address_[ESP_NOW_ETH_ALEN]{};
};
} // namespace esphome::espnow
@@ -299,13 +299,13 @@ void ESPNowComponent::loop() {
format_hex_pretty_to(hex_buf, packet->packet_.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
}
}
+11 -13
View File
@@ -31,8 +31,8 @@ using peer_address_t = std::array<uint8_t, ESP_NOW_ETH_ALEN>;
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<ESPNowUnknownPeerHandler *> unknown_peer_handlers_;
std::vector<ESPNowReceivedPacketHandler *> received_handlers_;
std::vector<ESPNowBroadcastedHandler *> broadcasted_handlers_;
std::vector<ESPNowReceivedPacketHandler *> receive_handlers_;
std::vector<ESPNowBroadcastHandler *> broadcast_handlers_;
std::vector<ESPNowPeer> peers_{};
@@ -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<uint8_t> &buf) const {
@@ -56,7 +56,7 @@ void ESPNowTransport::send_packet(const std::vector<uint8_t> &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]);
@@ -15,7 +15,7 @@ namespace espnow {
class ESPNowTransport : public packet_transport::PacketTransport,
public Parented<ESPNowComponent>,
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<uint8_t> &buf) const override;
+2
View File
@@ -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
+2 -21
View File
@@ -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;
+11 -2
View File
@@ -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
+31
View File
@@ -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;'
@@ -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:
@@ -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);"
@@ -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
@@ -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
@@ -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
+1
View File
@@ -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