From e1077748bee958f238dee33213ba1e01662b4efe Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Thu, 22 Oct 2026 01:11:09 -0300 Subject: [PATCH] [api][cover] Expose toggle support in native API (#16506) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 2 + esphome/components/api/api_connection.cpp | 3 + esphome/components/api/api_pb2.cpp | 5 + esphome/components/api/api_pb2.h | 6 +- esphome/components/api/api_pb2_dump.cpp | 2 + tests/components/template/common-base.yaml | 4 + .../fixtures/api_cover_toggle.yaml | 26 ++++ tests/integration/raw_api_client.py | 17 ++- tests/integration/test_api_cover_toggle.py | 129 ++++++++++++++++++ 9 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/api_cover_toggle.yaml create mode 100644 tests/integration/test_api_cover_toggle.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1932d658584..20568fc746c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -608,6 +608,7 @@ message ListEntitiesCoverResponse { bool supports_stop = 12; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; bool supports_stop_tilt = 14; + bool supports_toggle = 15; } // Deprecated in API version 1.1 @@ -670,6 +671,7 @@ message CoverCommandRequest { bool stop = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; bool stop_tilt = 10; + bool toggle = 11; } // ==================== FAN ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7028dfc7679..8f392b107eb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -555,6 +555,7 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c msg.supports_tilt = traits.get_supports_tilt(); msg.supports_stop = traits.get_supports_stop(); msg.supports_stop_tilt = traits.get_supports_stop_tilt(); + msg.supports_toggle = traits.get_supports_toggle(); return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size); } void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) { @@ -567,6 +568,8 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) { call.set_command_stop(); if (msg.stop_tilt) call.set_command_stop_tilt(); + if (msg.toggle) + call.set_command_toggle(); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 040cac2e09d..f6f5924b793 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -484,6 +484,7 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle); return pos; } uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { @@ -506,6 +507,7 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.device_id); #endif size += ProtoSize::calc_bool(1, msg.supports_stop_tilt); + size += ProtoSize::calc_bool(1, msg.supports_toggle); return size; } uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -565,6 +567,9 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * case proto_tag(10, WIRE_TYPE_VARINT): msg.stop_tilt = value.as_bool(); break; + case proto_tag(11, WIRE_TYPE_VARINT): + msg.toggle = value.as_bool(); + break; default: break; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index c9669299398..7daf707dfc6 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 13; - static constexpr uint8_t ESTIMATED_SIZE = 59; + static constexpr uint8_t ESTIMATED_SIZE = 61; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); } #endif @@ -921,6 +921,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { ProgmemStringRef device_class{}; bool supports_stop{false}; bool supports_stop_tilt{false}; + bool supports_toggle{false}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -958,7 +959,7 @@ class CoverStateResponse final : public StateResponseProtoMessage { class CoverCommandRequest final : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 30; - static constexpr uint8_t ESTIMATED_SIZE = 27; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("cover_command_request"); } #endif @@ -968,6 +969,7 @@ class CoverCommandRequest final : public CommandProtoMessage { float tilt{0.0f}; bool stop{false}; bool stop_tilt{false}; + bool toggle{false}; void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 84aac081e75..af52a25efbd 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1246,6 +1246,7 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt); + dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle); return out.c_str(); } const char *CoverStateResponse::dump_to(DumpBuffer &out) const { @@ -1271,6 +1272,7 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt); + dump_field(out, ESPHOME_PSTR("toggle"), this->toggle); return out.c_str(); } #endif diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 76f60bef7e2..49d6c7e1f75 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -409,6 +409,10 @@ cover: id: template_cover_actions position: 0.0 tilt: 0.0 + toggle_action: + - cover.template.publish: + id: template_cover_actions + current_operation: IDLE stop_action: - cover.template.publish: id: template_cover_actions diff --git a/tests/integration/fixtures/api_cover_toggle.yaml b/tests/integration/fixtures/api_cover_toggle.yaml new file mode 100644 index 00000000000..b0840706ddd --- /dev/null +++ b/tests/integration/fixtures/api_cover_toggle.yaml @@ -0,0 +1,26 @@ +esphome: + name: api-cover-toggle-test +host: +api: +logger: + level: DEBUG + +cover: + - platform: template + name: "Toggle Cover" + id: toggle_cover + toggle_action: + - cover.template.publish: + id: toggle_cover + position: 0.5 + stop_action: + - cover.template.publish: + id: toggle_cover + position: 0.25 + - platform: template + name: "Plain Cover" + id: plain_cover + stop_action: + - cover.template.publish: + id: plain_cover + position: 0.25 diff --git a/tests/integration/raw_api_client.py b/tests/integration/raw_api_client.py index cc759f88e5a..6a9fdd111d3 100644 --- a/tests/integration/raw_api_client.py +++ b/tests/integration/raw_api_client.py @@ -1,7 +1,7 @@ """Minimal plaintext native-api client over a raw socket. Reads only when told to, so tests control when the TCP pipe backs up toward -the device; payloads are skipped and only message types are counted. +the device; message types are counted and each frame is kept for inspection. """ from __future__ import annotations @@ -59,15 +59,17 @@ class FrameParser: def __init__(self) -> None: self._buf = bytearray() + self.frames: list[tuple[int, bytes]] = [] def feed(self, data: bytes) -> list[int]: self._buf.extend(data) types: list[int] = [] - while (msg_type := self._try_parse()) is not None: - types.append(msg_type) + while (frame := self._try_parse()) is not None: + self.frames.append(frame) + types.append(frame[0]) return types - def _try_parse(self) -> int | None: + def _try_parse(self) -> tuple[int, bytes] | None: buf = self._buf if not buf: return None @@ -80,8 +82,9 @@ class FrameParser: msg_type, pos = type_decoded if len(buf) - pos < size: return None + payload = bytes(buf[pos : pos + size]) del buf[: pos + size] - return msg_type + return msg_type, payload class RawApiClient: @@ -145,5 +148,9 @@ class RawApiClient: await asyncio.wait_for(_read_loop(), timeout) + def frames(self, msg_type: int) -> list[bytes]: + """Payloads of every frame of msg_type received so far.""" + return [payload for kind, payload in self._parser.frames if kind == msg_type] + def close(self) -> None: self._sock.close() diff --git a/tests/integration/test_api_cover_toggle.py b/tests/integration/test_api_cover_toggle.py new file mode 100644 index 00000000000..41c21db1d6f --- /dev/null +++ b/tests/integration/test_api_cover_toggle.py @@ -0,0 +1,129 @@ +"""The native API advertises cover toggle support and runs the toggle action on request.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import struct + +from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2 +import pytest + +from .log_utils import LineWaiter +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, decode_varint, encode_varint +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest] +LIST_COVER = MESSAGE_TYPE_OF[api_pb2.ListEntitiesCoverResponse] +LIST_DONE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse] +WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5 +# ListEntitiesCoverResponse fields; the installed client library does not know supports_toggle yet +FIELD_ENTITY_KEY, FIELD_SUPPORTS_TOGGLE = 2, 15 +# CoverCommandRequest fields +FIELD_KEY, FIELD_STOP, FIELD_TOGGLE = 1, 8, 11 + + +def tag(field: int, wire_type: int) -> bytes: + return encode_varint((field << 3) | wire_type) + + +def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]: + """Split a protobuf payload into field number -> raw values.""" + fields: dict[int, list[int | bytes]] = {} + buf = bytearray(payload) + pos = 0 + while pos < len(buf): + key, pos = decode_varint(buf, pos) + field, wire_type = key >> 3, key & 0x07 + if wire_type == WIRE_VARINT: + value, pos = decode_varint(buf, pos) + elif wire_type == WIRE_LENGTH: + length, pos = decode_varint(buf, pos) + value = bytes(buf[pos : pos + length]) + pos += length + elif wire_type == WIRE_FIXED32: + value = struct.unpack_from(" None: + lines = LineWaiter() + async with ( + run_compiled(yaml_config, line_callback=lines.callback), + api_client_connected() as client, + RawApiClient(unused_tcp_port) as raw, + ): + entities, _ = await client.list_entities_services() + toggle_cover = require_entity(entities, "toggle_cover", CoverInfo, exact=True) + plain_cover = require_entity(entities, "plain_cover", CoverInfo, exact=True) + + positions: dict[int, list[float]] = {toggle_cover.key: [], plain_cover.key: []} + waiter = StateWaiter() + + def on_state(state: EntityState) -> None: + if isinstance(state, CoverState) and state.key in positions: + positions[state.key].append(state.position) + waiter.on_state(state) + + def position_is(key: int, value: float) -> Callable[[EntityState], bool]: + return lambda s: ( + isinstance(s, CoverState) + and s.key == key + and s.position == pytest.approx(value, abs=0.01) + ) + + initial = InitialStateHelper(entities) + client.subscribe_states(initial.on_state_wrapper(on_state)) + await initial.wait_for_initial_states() + + # Capability discovery: only the cover with a toggle_action advertises toggle + await raw.connect() + await raw.send_message(api_pb2.ListEntitiesRequest()) + await raw.read_until_frame(LIST_DONE) + supports_toggle = {} + for payload in raw.frames(LIST_COVER): + fields = decode_fields(payload) + key = fields[FIELD_ENTITY_KEY][0] + supports_toggle[key] = bool(fields.get(FIELD_SUPPORTS_TOGGLE, [0])[0]) + assert supports_toggle == {toggle_cover.key: True, plain_cover.key: False} + + def key_field(key: int) -> bytes: + return tag(FIELD_KEY, WIRE_FIXED32) + struct.pack("