[api][sendspin] Add a request for the Sendspin pairing token (#20355)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Kevin Ahrendt
2026-10-08 19:12:20 +00:00
committed by GitHub
co-authored by J. Nick Koston
parent f9b5eca2b9
commit e395e626ec
13 changed files with 301 additions and 3 deletions
+39
View File
@@ -81,6 +81,8 @@ service APIConnection {
rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {}
}
@@ -398,6 +400,11 @@ message WizardCapabilities {
bool configured = 1;
}
message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
@@ -408,6 +415,7 @@ message DeviceCapabilitiesResponse {
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"];
SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"];
}
// ==================== DEVICE WIZARD ====================
@@ -3069,3 +3077,34 @@ message BluetoothSetConnectionParamsResponse {
uint64 address = 1;
int32 error = 2;
}
// ==================== SENDSPIN ====================
enum SendspinPairingTokenStatus {
// The Sendspin client has not started yet, so the token does not exist yet. Ask again later.
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0;
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1;
// The device has no API encryption key, so it will not send the token. Ask again once a key is set.
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2;
// Sendspin is turned off and has not started since boot, so the token does not exist yet.
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3;
// The Sendspin client failed to start, and will not start until the device reboots.
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4;
}
// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device
message SendspinPairingTokenRequest {
option (id) = 159;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SENDSPIN";
// Empty
}
message SendspinPairingTokenResponse {
option (id) = 160;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENDSPIN";
SendspinPairingTokenStatus status = 1;
// The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK.
string token = 2 [(max_data_length) = 127];
}
+41 -1
View File
@@ -48,6 +48,9 @@
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
#include "esphome/components/sendspin/sendspin_hub.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -1402,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
bool APIConnection::send_sendspin_pairing_token_response_() {
SendspinPairingTokenResponse resp;
#ifdef USE_API_NOISE
// has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions
// never reach hello, and activating a key closes every session opened before it.
if (!this->parent_->get_noise_ctx().has_psk()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
return this->send_message(resp);
}
auto token = sendspin_::global_sendspin_hub->get_pairing_token();
if (token.has_value()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK;
resp.token = StringRef(*token);
} else if (sendspin_::global_sendspin_hub->status_has_error()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED;
} else if (sendspin_::global_sendspin_hub->is_disabled()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED;
} else {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY;
}
#else
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
#endif
return this->send_message(resp);
}
void APIConnection::on_sendspin_pairing_token_request() {
if (!this->send_sendspin_pairing_token_response_()) {
this->on_fatal_error();
}
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
@@ -1872,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 18;
resp.api_version_minor = 19;
// 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());
@@ -2088,6 +2125,9 @@ bool APIConnection::send_device_capabilities_response_() {
#endif
#ifdef USE_API_WIZARD
resp.wizard.configured = true;
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
#endif
return this->send_message(resp);
}
+7
View File
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
void on_sendspin_pairing_token_request();
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
@@ -432,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_VOICE_ASSISTANT
bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg);
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
bool send_sendspin_pairing_token_response_();
#endif
#ifdef USE_CAMERA
void try_send_camera_image_();
+35
View File
@@ -306,6 +306,19 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) {
return size;
}
#endif
#ifdef USE_SENDSPIN
uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags);
return pos;
}
uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.feature_flags);
return size;
}
#endif
uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceCapabilitiesResponse *>(self);
#ifdef USE_BLUETOOTH_PROXY
@@ -324,6 +337,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res
#endif
#ifdef USE_API_WIZARD
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard);
#endif
#ifdef USE_SENDSPIN
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin);
#endif
return pos;
}
@@ -346,6 +362,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_WIZARD
size += ProtoSize::calc_message(1, msg.wizard.calculate_size());
#endif
#ifdef USE_SENDSPIN
size += ProtoSize::calc_message(1, msg.sendspin.calculate_size());
#endif
return size;
}
@@ -4229,6 +4248,22 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) {
return size;
}
#endif
#ifdef USE_SENDSPIN
uint8_t *SendspinPairingTokenResponse::encode_msg(const void *self,
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinPairingTokenResponse *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(msg.status));
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.token);
return pos;
}
uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinPairingTokenResponse *>(self);
uint32_t size = 0;
size += msg.status ? 2 : 0;
size += !msg.token.empty() ? 2 + msg.token.size() : 0;
return size;
}
#endif
#ifndef HAS_PROTO_MESSAGE_DUMP
static_assert(!std::is_polymorphic_v<HelloRequest>, "decodable messages carry no vtable");
static_assert(!std::is_polymorphic_v<DisconnectRequest>, "decodable messages carry no vtable");
+53 -1
View File
@@ -383,6 +383,15 @@ enum SerialProxyIdentityFlag : uint32_t {
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1,
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2,
};
#ifdef USE_SENDSPIN
enum SendspinPairingTokenStatus : uint32_t {
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0,
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1,
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2,
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3,
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4,
};
#endif
} // namespace enums
@@ -737,10 +746,27 @@ class WizardCapabilities final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_SENDSPIN
class SendspinCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
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);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 119;
static constexpr uint8_t ESTIMATED_SIZE = 136;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
@@ -758,6 +784,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage {
#endif
#ifdef USE_API_WIZARD
WizardCapabilities wizard{};
#endif
#ifdef USE_SENDSPIN
SendspinCapabilities sendspin{};
#endif
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 {
@@ -4138,5 +4167,28 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_SENDSPIN
class SendspinPairingTokenResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 160;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("sendspin_pairing_token_response"); }
#endif
enums::SendspinPairingTokenStatus status{};
StringRef token{nullptr, 0}; // null until set, encode only
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);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
} // namespace esphome::api
+39
View File
@@ -928,6 +928,25 @@ template<> const char *proto_enum_to_string<enums::SerialProxyIdentityFlag>(enum
return ESPHOME_PSTR("UNKNOWN");
}
}
#ifdef USE_SENDSPIN
template<>
const char *proto_enum_to_string<enums::SendspinPairingTokenStatus>(enums::SendspinPairingTokenStatus value) {
switch (value) {
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest"));
@@ -1096,6 +1115,13 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_SENDSPIN
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
@@ -1124,6 +1150,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": ");
this->wizard.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SENDSPIN
out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": ");
this->sendspin.dump_to(out);
out.append("\n");
#endif
return out.c_str();
}
@@ -2944,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
return out.c_str();
}
#endif
#ifdef USE_SENDSPIN
const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse"));
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SendspinPairingTokenStatus>(this->status));
dump_field(out, ESPHOME_PSTR("token"), this->token);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -751,6 +751,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_wizard_input_set_request(msg);
break;
}
#endif
#ifdef USE_SENDSPIN
case 159 /* SendspinPairingTokenRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request"));
#endif
this->on_sendspin_pairing_token_request();
break;
}
#endif
default:
break;
+4
View File
@@ -252,6 +252,10 @@ class APIServerConnectionBase {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
#ifdef USE_SENDSPIN
void on_sendspin_pairing_token_request(){};
#endif
};
} // namespace esphome::api
+1 -1
View File
@@ -424,7 +424,7 @@ async def to_code(config: ConfigType) -> None:
# esp_websocket_client links esp_tls even for ws:// connections.
esp32.request_tls()
cg.add_define("USE_SENDSPIN", True) # for MDNS
cg.add_define("USE_SENDSPIN", True) # for MDNS and the native API
# Service starts disabled and the hub enables it; always advertised where unsupported
if mdns.request_service_enable_disable():
@@ -113,6 +113,10 @@ static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"};
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"};
#endif
SendspinHub *global_sendspin_hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
SendspinHub::SendspinHub() { global_sendspin_hub = this; }
void SendspinHub::setup() {
auto config = this->build_client_config_();
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
@@ -231,6 +235,14 @@ void SendspinHub::set_enabled(bool enabled) {
this->enabled_ = enabled;
}
// THREAD CONTEXT: Main loop
std::optional<std::string> SendspinHub::get_pairing_token() const {
if (this->client_ == nullptr) {
return std::nullopt;
}
return this->client_->pairing_token();
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
void SendspinHub::set_unpaired_access_enabled(bool enabled) {
this->unpaired_access_ = enabled;
@@ -62,6 +62,11 @@ inline constexpr float CHILD = HUB - 1.0f;
/// Pairing-record slots, handed to the client as max_pairing_records.
inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS;
/// Feature flags reported to API clients in DeviceCapabilitiesResponse.
enum SendspinFeature : uint32_t {
SENDSPIN_FEATURE_PAIRING_TOKEN = 1 << 0,
};
/// @brief Thin adapter over sendspin::SendspinClient.
///
/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for
@@ -91,6 +96,8 @@ class SendspinHub final : public Component,
public sendspin::SendspinNetworkProvider,
public sendspin::SendspinPersistenceProvider {
public:
SendspinHub();
float get_setup_priority() const override { return sendspin_priority::HUB; }
void setup() override;
void loop() override;
@@ -179,6 +186,18 @@ class SendspinHub final : public Component,
/// @brief Returns whether the Sendspin client is running.
bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); }
/// @brief Returns whether the client has been turned off with set_enabled().
bool is_disabled() const { return this->enabled_.has_value() && !this->enabled_.value(); }
/// @brief Returns the SendspinFeature flags reported to API clients.
uint32_t get_feature_flags() const { return SENDSPIN_FEATURE_PAIRING_TOKEN; }
/// @brief Returns the pairing token a Sendspin server uses to pair with this device.
///
/// The token is a long-lived secret, so this component never logs it. Returns std::nullopt until the client has
/// started once; it stays available after the client stops. Main loop only.
std::optional<std::string> get_pairing_token() const;
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
@@ -424,6 +443,8 @@ class SendspinPollingChild : public PollingComponent, public Parented<SendspinHu
float get_setup_priority() const override { return sendspin_priority::CHILD; }
};
extern SendspinHub *global_sendspin_hub; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::sendspin_
#endif // USE_ESP_IDF
@@ -12,3 +12,8 @@ sendspin:
static_pairing_code: "01234567"
# Covers the hub's unpaired access option, which is only valid without an unpaired access switch.
unpaired_access: false
# Builds the API request for the Sendspin pairing token.
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
@@ -68,8 +68,10 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"zwave_proxy": 3,
"serial_proxies": 4,
"wizard": 5,
"sendspin": 6,
},
"WizardCapabilities": {"configured": 1},
"SendspinCapabilities": {"feature_flags": 1},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,
@@ -446,6 +448,39 @@ def test_device_wizard_rpc_requires_authentication() -> None:
assert "needs_authentication" not in body
def test_sendspin_pairing_token_messages_keep_their_wire_ids() -> None:
"""Message ids are part of the wire protocol and must not change."""
for message, expected in (
("SendspinPairingTokenRequest", 159),
("SendspinPairingTokenResponse", 160),
):
body = _extract_proto_message(PROTO_TEXT, message)
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, f"{message} is missing `option (id)`"
assert int(match.group(1)) == expected, (
f"{message} has id {match.group(1)}, expected {expected}"
)
def test_sendspin_pairing_token_response_fields_keep_their_wire_numbers() -> None:
"""Clients decode the response purely by field number."""
body = _extract_proto_message(PROTO_TEXT, "SendspinPairingTokenResponse")
for field_name, number in (("status", 1), ("token", 2)):
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), (
f"{field_name} in SendspinPairingTokenResponse is no longer field "
f"number {number}"
)
def test_sendspin_pairing_token_rpc_requires_authentication() -> None:
"""The pairing token is a long-lived secret, so the rpc must not set
`needs_authentication` and inherits the default of true.
"""
body = _extract_rpc_body(PROTO_TEXT, "sendspin_pairing_token")
assert "needs_authentication" not in body
# ==================== Group C: advertised API version ====================