[api] Add DeviceCapabilities message for optional-feature flags (#17984)

This commit is contained in:
Jesse Hills
2026-08-11 14:52:19 +12:00
committed by GitHub
parent 8728aaa616
commit f697cf2011
9 changed files with 682 additions and 1 deletions
+72
View File
@@ -19,6 +19,7 @@ service APIConnection {
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false;
}
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -243,6 +244,12 @@ message SerialProxyInfo {
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
//
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
// have moved to that message as of API 1.15, but are still sent here so that
// older clients keep working. Do NOT mark them (deprecated) until the removal
// release: in this repo (deprecated) makes the generator drop the field
// entirely, so the device would stop sending it.
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
@@ -280,6 +287,8 @@ message DeviceInfoResponse {
// Deprecated in API version 1.9
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
@@ -288,11 +297,14 @@ message DeviceInfoResponse {
// Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key
@@ -305,10 +317,13 @@ message DeviceInfoResponse {
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Serial proxy instance metadata
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
@@ -317,6 +332,63 @@ message DeviceInfoResponse {
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
// ==================== DEVICE CAPABILITIES ====================
// Asks the device which optional features it supports.
//
// This message exists so that DeviceInfoResponse does not have to keep growing
// a flat list of feature flags. DeviceInfoResponse is served before
// authentication, so it is limited to identity information. Capabilities are
// only served on an authenticated connection (encrypted as well, when
// encryption is configured).
//
// Clients that see api_version >= 1.15 should read these values from
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
// Older clients keep reading DeviceInfoResponse, which still carries the same
// values, so this is not a breaking change.
message DeviceCapabilitiesRequest {
option (id) = 149;
option (source) = SOURCE_CLIENT;
// Empty
}
// Each feature gets its own sub-message so that it can gain fields over time
// without crowding the top-level field numbering.
//
// Note: a sub-message whose fields are all at their default value is not sent
// at all, so the presence of a sub-message is not a reliable test for "this
// feature is compiled in". Clients should test a value inside it, for example
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
message BluetoothProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
string mac_address = 2 [(max_data_length) = 17, (force) = true];
}
message VoiceAssistantCapabilities {
// Bitmask of the features this voice assistant supports
uint32 feature_flags = 1;
}
message ZWaveProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
uint32 home_id = 2;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
}
message ListEntitiesRequest {
option (id) = 11;
option (source) = SOURCE_CLIENT;
+35 -1
View File
@@ -1736,7 +1736,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
resp.api_version_minor = 15;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1904,6 +1904,35 @@ bool APIConnection::send_device_info_response_() {
return this->send_message(resp);
}
bool APIConnection::send_device_capabilities_response_() {
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
// below in sync with send_device_info_response_() until those copies are removed.
DeviceCapabilitiesResponse resp;
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
#endif
#ifdef USE_VOICE_ASSISTANT
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
#endif
#ifdef USE_ZWAVE_PROXY
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
#endif
#ifdef USE_SERIAL_PROXY
size_t serial_proxy_index = 0;
for (auto const &proxy : App.get_serial_proxies()) {
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
break;
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
}
#endif
return this->send_message(resp);
}
void APIConnection::on_hello_request(const HelloRequest &msg) {
if (!this->send_hello_response_(msg)) {
this->on_fatal_error();
@@ -1925,6 +1954,11 @@ void APIConnection::on_device_info_request() {
this->on_fatal_error();
}
}
void APIConnection::on_device_capabilities_request() {
if (!this->send_device_capabilities_response_()) {
this->on_fatal_error();
}
}
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
+2
View File
@@ -266,6 +266,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_device_capabilities_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
this->flags_.state_subscription = true;
@@ -385,6 +386,7 @@ class APIConnection final : public APIServerConnectionBase {
bool send_disconnect_response_();
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
+76
View File
@@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
return size;
}
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
return pos;
}
uint32_t BluetoothProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += 2 + this->mac_address.size();
return size;
}
#endif
#ifdef USE_VOICE_ASSISTANT
uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
return pos;
}
uint32_t VoiceAssistantCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
return size;
}
#endif
#ifdef USE_ZWAVE_PROXY
uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
return pos;
}
uint32_t ZWaveProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += ProtoSize::calc_uint32(1, this->home_id);
return size;
}
#endif
uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
#ifdef USE_BLUETOOTH_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
#endif
#ifdef USE_VOICE_ASSISTANT
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
#endif
#ifdef USE_ZWAVE_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
}
#endif
return pos;
}
uint32_t DeviceCapabilitiesResponse::calculate_size() const {
uint32_t size = 0;
#ifdef USE_BLUETOOTH_PROXY
size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
#endif
#ifdef USE_VOICE_ASSISTANT
size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
#endif
#ifdef USE_ZWAVE_PROXY
size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(1, it.calculate_size());
}
#endif
return size;
}
#ifdef USE_BINARY_SENSOR
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
+68
View File
@@ -600,6 +600,74 @@ class DeviceInfoResponse final : public ProtoMessage {
protected:
};
#ifdef USE_BLUETOOTH_PROXY
class BluetoothProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef mac_address{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_VOICE_ASSISTANT
class VoiceAssistantCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_ZWAVE_PROXY
class ZWaveProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint32_t home_id{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 102;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
#ifdef USE_BLUETOOTH_PROXY
BluetoothProxyCapabilities bluetooth_proxy{};
#endif
#ifdef USE_VOICE_ASSISTANT
VoiceAssistantCapabilities voice_assistant{};
#endif
#ifdef USE_ZWAVE_PROXY
ZWaveProxyCapabilities zwave_proxy{};
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
class ListEntitiesDoneResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 19;
+49
View File
@@ -988,6 +988,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
return out.c_str();
}
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
return out.c_str();
}
#endif
#ifdef USE_VOICE_ASSISTANT
const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
#ifdef USE_ZWAVE_PROXY
const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
this->bluetooth_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_VOICE_ASSISTANT
out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
this->voice_assistant.dump_to(out);
out.append("\n");
#endif
#ifdef USE_ZWAVE_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
this->zwave_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
it.dump_to(out);
out.append("\n");
}
#endif
return out.c_str();
}
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
return out.c_str();
@@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
case 149 /* DeviceCapabilitiesRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
#endif
this->on_device_capabilities_request();
break;
}
default:
break;
}
+2
View File
@@ -27,6 +27,8 @@ class APIServerConnectionBase {
void on_ping_response(){};
void on_device_info_request(){};
void on_device_capabilities_request(){};
void on_list_entities_request(){};
void on_subscribe_states_request(){};
@@ -0,0 +1,371 @@
"""Invariant tests for esphome/components/api/api.proto and its generated code.
These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition
(API 1.15) against regressions that protoc-based codegen would not catch on
its own, without requiring protoc to be installed at test time:
* script/api_protobuf/api_protobuf.py skips any field marked
`[deprecated = true]` completely -- it generates no C++ for it at all, so
the device silently stops sending that value. Six DeviceInfoResponse fields
were superseded by DeviceCapabilitiesResponse but must keep being sent for
backward compatibility with clients older than API 1.15. If a future edit
"tidies up" by marking one of them deprecated, this file breaks that field
for every existing client with nothing else in CI noticing.
* Field numbers are the wire protocol, not the field names. Renaming a field
is harmless; renumbering it is a silent breaking change, because an old
client still decodes by number. This file pins the field number of each of
the six superseded DeviceInfoResponse fields and of every field on the new
DeviceCapabilitiesResponse/BluetoothProxyCapabilities/
VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a
well-intentioned reshuffle of api.proto gets caught here instead of on a
device in the field.
* Message wire ids must be unique, and the new capabilities RPC must stay
authenticated-only.
Group A below asserts on the checked-in generated files (api_pb2.h /
api_pb2.cpp), since "the field is present in the generated C++" is exactly
equivalent to "the device still sends it". Group B parses api.proto as plain
text (no protoc). Group C checks the advertised API minor version.
"""
from __future__ import annotations
from pathlib import Path
import re
import esphome
API_DIR = Path(esphome.__file__).parent / "components" / "api"
PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8")
HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8")
CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8")
API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8")
# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse
# as of API 1.15 but must still be generated (and therefore still sent) for
# backward compatibility with older clients.
SUPERSEDED_FIELDS: dict[str, int] = {
"bluetooth_proxy_feature_flags": 15,
"voice_assistant_feature_flags": 17,
"bluetooth_mac_address": 18,
"zwave_proxy_feature_flags": 23,
"zwave_home_id": 24,
"serial_proxies": 25,
}
# Field numbers on the new capability messages. These are a frozen wire
# contract from the moment they ship: an old client decodes a sub-message
# field purely by number, so renumbering any of these -- even without
# touching a name -- silently corrupts what every already-deployed client
# reads. Keyed by message name so the next capability sub-message is a
# data-only addition here.
NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"DeviceCapabilitiesResponse": {
"bluetooth_proxy": 1,
"voice_assistant": 2,
"zwave_proxy": 3,
"serial_proxies": 4,
},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,
},
"VoiceAssistantCapabilities": {
"feature_flags": 1,
},
"ZWaveProxyCapabilities": {
"feature_flags": 1,
"home_id": 2,
},
}
# Fields that are genuinely dead and are expected to carry `deprecated=true`.
# Used to prove the deprecated-detection logic below actually detects
# deprecation rather than trivially passing.
GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = (
"legacy_bluetooth_proxy_version",
"legacy_voice_assistant_version",
)
DEPRECATED_FIELD_TRAP = (
"script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = "
"true]` completely, generating no C++ for them at all. Marking this field "
"deprecated would silently stop the device from ever sending it, breaking "
"every existing client that still reads it from DeviceInfoResponse."
)
def _extract_braced_region(text: str, anchor_pattern: str) -> str:
"""Return the region of `text` starting at the first match of
`anchor_pattern` up to the matching closing brace (inclusive), using
brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop
inside a function body) don't cause a premature stop.
"""
anchor_match = re.search(anchor_pattern, text)
if anchor_match is None:
raise AssertionError(f"could not find a match for {anchor_pattern!r}")
start = anchor_match.start()
open_brace = text.index("{", start)
depth = 0
for i in range(open_brace, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}")
def _extract_class_body(header_text: str, class_name: str) -> str:
"""Return the body of a generated C++ class, scoped so a field name that
also happens to exist on some other class cannot satisfy the assertion.
"""
return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b")
def _extract_function_body(cpp_text: str, qualified_name: str) -> str:
"""Return the body of a generated `Class::method(...)` definition."""
return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(")
def _extract_proto_message(proto_text: str, message_name: str) -> str:
"""Return the body of a top-level `message Name { ... }` block from the
.proto source. Proto message bodies here contain no nested `{`/`}` of
their own (options use parens, not braces), so a non-greedy match up to
the first line that is just `}` is sufficient and keeps the parsing
simple.
"""
match = re.search(
rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}",
proto_text,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError(f"could not find `message {message_name}` in api.proto")
return match.group(1)
def _extract_rpc_body(proto_text: str, rpc_name: str) -> str:
"""Return the option body of an `rpc name (...) returns (...) { ... }`
declaration from the APIConnection service, robust to it being written
on one line (`{}`) or spread across several with options inside.
"""
match = re.search(
rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}",
proto_text,
re.DOTALL,
)
if match is None:
raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto")
return match.group(1)
def _field_declaration_line(message_body: str, field_name: str) -> str:
"""Return the single source line declaring `field_name` inside a proto
message body (all fields here are declared on one line).
"""
for line in message_body.splitlines():
if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line):
return line
raise AssertionError(
f"could not find a field declaration for {field_name!r} in the given message body"
)
# ==================== Group A: generated files ====================
def test_superseded_device_info_fields_still_declared_in_header() -> None:
"""Each superseded field must still be a real member of DeviceInfoResponse
in api_pb2.h -- not merely present somewhere in the file. Several of these
names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an
unscoped substring search over the whole header would pass even if the
field were removed from DeviceInfoResponse.
"""
class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse")
for field_name in SUPERSEDED_FIELDS:
assert re.search(rf"\b{field_name}\b", class_body), (
f"{field_name} is missing from the DeviceInfoResponse class body in "
f"api_pb2.h. {DEPRECATED_FIELD_TRAP}"
)
def test_superseded_device_info_fields_still_encoded_and_sized() -> None:
"""Each superseded field must still be touched by DeviceInfoResponse's
generated encode() and calculate_size(), i.e. it is still put on the wire.
"""
encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode")
size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size")
for field_name in SUPERSEDED_FIELDS:
assert f"this->{field_name}" in encode_body, (
f"DeviceInfoResponse::encode() no longer references {field_name}. "
f"{DEPRECATED_FIELD_TRAP}"
)
assert f"this->{field_name}" in size_body, (
f"DeviceInfoResponse::calculate_size() no longer references "
f"{field_name}. {DEPRECATED_FIELD_TRAP}"
)
def test_new_capability_classes_present_in_header() -> None:
"""The new response message and its capability sub-messages must exist as
generated classes.
"""
for class_name in (
"DeviceCapabilitiesResponse",
"BluetoothProxyCapabilities",
"VoiceAssistantCapabilities",
"ZWaveProxyCapabilities",
):
assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), (
f"expected a generated class named {class_name} in api_pb2.h"
)
# ==================== Group B: api.proto source text ====================
def test_all_message_ids_are_unique() -> None:
"""Every `option (id) = N;` in api.proto must be unique. Two messages
sharing a wire id would make the client and server misinterpret each
other's messages -- nothing else currently checks this.
"""
ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)]
assert ids, "did not find any `option (id) = N;` declarations in api.proto"
duplicates = sorted({value for value in ids if ids.count(value) > 1})
assert not duplicates, (
f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each "
"message must have a unique wire id."
)
def test_device_capabilities_request_has_id_149() -> None:
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`"
assert int(match.group(1)) == 149, (
f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_device_capabilities_response_has_id_150() -> None:
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`"
assert int(match.group(1)) == 150, (
f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
"""The six superseded fields must not carry `[deprecated = true]` in
api.proto, or the generator drops them and old clients stop receiving
them (see module docstring). The second half of this test proves the
deprecated-detection itself works: two genuinely dead fields
(legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must
still be detected as deprecated, so the first half isn't vacuously true.
"""
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
for field_name in SUPERSEDED_FIELDS:
line = _field_declaration_line(body, field_name)
assert "deprecated" not in line, (
f"{field_name} in DeviceInfoResponse is marked deprecated in "
f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}"
)
for field_name in GENUINELY_DEPRECATED_FIELDS:
line = _field_declaration_line(body, field_name)
assert "deprecated" in line, (
f"expected {field_name} to still carry `deprecated=true` in "
f"api.proto ({line.strip()!r}). If this fails, the deprecated "
"detection used above is broken, and the sibling assertion that "
"the superseded fields are NOT deprecated is not testing anything."
)
def test_superseded_fields_keep_their_wire_numbers() -> None:
"""Each superseded field must stay on the field number recorded in
SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field
number, so renumbering one of these -- even without touching its name --
would make an old client read a completely different value out of the
wire, with nothing else in CI noticing.
"""
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
for field_name, field_number in SUPERSEDED_FIELDS.items():
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
f"{field_name} in DeviceInfoResponse is no longer declared at "
f"field number {field_number} ({line.strip()!r}). Field numbers "
"are the wire protocol -- renumbering this field silently breaks "
"every existing client that still decodes DeviceInfoResponse by "
"the old numbering."
)
def test_capability_message_fields_keep_their_wire_numbers() -> None:
"""Every field on DeviceCapabilitiesResponse and its three capability
sub-messages must stay on the field number recorded in
NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but
the moment a device ships with them, their field numbers are a frozen
wire contract -- a client decodes a sub-message field purely by number,
so a later "cleanup" that renumbers one of these would silently corrupt
what every already-deployed client reads, with nothing else in CI
noticing.
"""
for message_name, fields in NEW_CAPABILITY_FIELDS.items():
body = _extract_proto_message(PROTO_TEXT, message_name)
for field_name, field_number in fields.items():
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
f"{field_name} on {message_name} is no longer declared at "
f"field number {field_number} ({line.strip()!r}). Field "
"numbers are the wire protocol -- renumbering this field "
"silently breaks every existing client that decodes this "
"message by the old numbering."
)
def test_device_capabilities_rpc_requires_authentication() -> None:
"""The `device_capabilities` RPC must not set
`option (needs_authentication) = false;` (or set it to anything at all).
Leaving it unset makes it inherit needs_authentication = true, keeping
capability data behind authentication (and encryption, when configured).
"""
body = _extract_rpc_body(PROTO_TEXT, "device_capabilities")
assert "needs_authentication" not in body, (
"rpc device_capabilities sets a `needs_authentication` option in "
"api.proto. It must stay unset so it inherits needs_authentication = "
"true; otherwise device capability data could be requested over an "
"unauthenticated connection."
)
# ==================== Group C: advertised API version ====================
def test_api_version_minor_is_at_least_15() -> None:
"""Clients gate sending DeviceCapabilitiesRequest on seeing
api_version >= 1.15 in HelloResponse. Regressing api_version_minor below
15 would make every client believe capabilities are unsupported even
though the RPC exists, so this must never go backwards. Use >= rather
than == so the next unrelated minor-version bump doesn't need to touch
this test.
"""
match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT)
assert match is not None, (
"could not find `resp.api_version_minor = N;` in api_connection.cpp"
)
minor = int(match.group(1))
assert minor >= 15, (
f"api_version_minor is {minor}, but device_capabilities requires "
"clients to see api_version >= 1.15 in HelloResponse before they will "
"ever request it."
)