mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
[api][cover] Expose toggle support in native API (#16506)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
J. Nick Koston
parent
cc89a011e2
commit
e1077748be
@@ -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 ====================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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("<I", buf, pos)[0]
|
||||
pos += 4
|
||||
else:
|
||||
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
|
||||
fields.setdefault(field, []).append(value)
|
||||
return fields
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_cover_toggle(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
unused_tcp_port: int,
|
||||
) -> 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("<I", key)
|
||||
|
||||
toggle = tag(FIELD_TOGGLE, WIRE_VARINT) + b"\x01"
|
||||
stop = tag(FIELD_STOP, WIRE_VARINT) + b"\x01"
|
||||
|
||||
# A toggle request runs the toggle action, which publishes 0.5
|
||||
toggled = waiter.expect(
|
||||
position_is(toggle_cover.key, 0.5), label="toggle action"
|
||||
)
|
||||
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + toggle)
|
||||
await toggled
|
||||
|
||||
# A cover without toggle_action drops the request with a warning; its state does not change
|
||||
await raw.send_raw(COVER_COMMAND, key_field(plain_cover.key) + toggle)
|
||||
await lines.wait_for("Plain Cover", "toggle unsupported", timeout=2.0)
|
||||
await asyncio.sleep(0.5)
|
||||
assert all(p == pytest.approx(1.0) for p in positions[plain_cover.key])
|
||||
|
||||
# Stop and toggle in one request: stop wins and the toggle action does not run
|
||||
before = len(positions[toggle_cover.key])
|
||||
stopped = waiter.expect(
|
||||
position_is(toggle_cover.key, 0.25), label="stop action"
|
||||
)
|
||||
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + stop + toggle)
|
||||
await stopped
|
||||
await asyncio.sleep(0.5)
|
||||
after = positions[toggle_cover.key][before:]
|
||||
assert all(p == pytest.approx(0.25, abs=0.01) for p in after), after
|
||||
Reference in New Issue
Block a user