diff --git a/src/artwork_role.cpp b/src/artwork_role.cpp index ea4f576..137b9ce 100644 --- a/src/artwork_role.cpp +++ b/src/artwork_role.cpp @@ -29,28 +29,12 @@ static const char* const TAG = "sendspin.artwork"; // Constants // ============================================================================ -/// @brief Size of the big-endian 64-bit timestamp at the start of artwork binary messages -static constexpr size_t BINARY_TIMESTAMP_SIZE = 8; - /// @brief Timeout for blocking queue receive in decode thread (allows periodic command checks) static constexpr uint32_t DRAIN_RECEIVE_TIMEOUT_MS = 100U; // Event flag bits for decode thread signaling static constexpr uint32_t COMMAND_STOP = (1 << 0); -// ============================================================================ -// Big-endian helpers -// ============================================================================ - -/// @brief Swaps bytes of a big-endian 64-bit value to host byte order -static int64_t be64_to_host(const uint8_t* bytes) { - uint64_t val = 0; - for (int i = 0; i < 8; ++i) { - val = (val << 8) | bytes[i]; - } - return static_cast(val); -} - namespace sendspin { // ============================================================================ diff --git a/src/player_role.cpp b/src/player_role.cpp index 8254a2e..e3c9241 100644 --- a/src/player_role.cpp +++ b/src/player_role.cpp @@ -22,22 +22,11 @@ static const char* const TAG = "sendspin.player"; -/// @brief Size of the big-endian 64-bit timestamp at the start of player binary messages. -static constexpr size_t BINARY_TIMESTAMP_SIZE = 8; static constexpr uint16_t MAX_STATIC_DELAY_MS = 5000U; static constexpr uint32_t HEADER_SEND_TIMEOUT_MS = 100U; // Denominator for the advertised buffer capacity fraction: advertises (N-1)/N of capacity static constexpr size_t AUDIO_BUFFER_ADVERTISE_DENOMINATOR = 5; -/// @brief Swaps bytes of a big-endian 64-bit value to host byte order. -static int64_t be64_to_host(const uint8_t* bytes) { - uint64_t val = 0; - for (int i = 0; i < 8; ++i) { - val = (val << 8) | bytes[i]; - } - return static_cast(val); -} - namespace sendspin { // ============================================================================ diff --git a/src/protocol.cpp b/src/protocol.cpp index eace564..3bdc5e9 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -460,6 +460,24 @@ bool process_server_command_message(JsonObject root, ServerCommandMessage* cmd_m return true; } +// A standalone section parser (server/state style) rather than a ServerCommandMessage field: +// the source command types are internal-only +bool process_server_command_source(JsonObject root, SourceCommand* source_cmd) { + if (source_cmd == nullptr || !root["payload"]["source"].is()) { + return false; + } + // "command" is required with no default; a bad value rejects the whole source object + // (Sendspin spec, Source messages — Server command object) + auto command = read_enum_field(root["payload"]["source"]["command"], "command", + source_command_from_string); + if (!command) { + SS_LOGW(TAG, "Rejecting server/command source object: missing or invalid 'command'"); + return false; + } + *source_cmd = command.value(); + return true; +} + // server/state is parsed one section at a time rather than into a single aggregate struct. The // caller runs on the network task (the ESP httpd task has a 4 KB stack), and an aggregate would // keep every section's fields alive in the caller's frame for the whole parse while the section @@ -883,6 +901,15 @@ std::string format_client_hello_message(const ClientHelloMessage* msg) { } } + // Required whenever source@v1 is advertised; features emitted only when set (Sendspin + // spec, Source messages — Hello support object) + if (msg->source_v1_support.has_value()) { + JsonObject source_json = root["payload"]["source@v1_support"].to(); + if (msg->source_v1_support.value().line_sense) { + source_json["features"]["line_sense"] = true; + } + } + std::string output; serializeJson(doc, output); return output; @@ -909,11 +936,47 @@ std::string format_client_state_message(const ClientStateMessage* msg) { } } + // The source object may legitimately be empty; signal only when set (Sendspin spec, + // Source messages — Client state object) + if (msg->source.has_value()) { + JsonObject source_json = root["payload"]["source"].to(); + if (msg->source.value().signal.has_value()) { + source_json["signal"] = to_cstr(msg->source.value().signal.value()); + } + } + std::string output; serializeJson(doc, output); return output; } +std::string format_client_stream_start_message(const ClientStreamStartMessage* msg) { + JsonDocument doc = make_json_document(); + JsonObject root = doc.to(); + + // Hyphenated type string per the spec; codec_header only when present, bit_depth always + // (Sendspin spec, Source messages — client-stream/start) + root["type"] = "client-stream/start"; + JsonObject source_json = root["payload"]["source"].to(); + source_json["codec"] = to_cstr(msg->codec); + source_json["channels"] = msg->channels; + source_json["sample_rate"] = msg->sample_rate; + source_json["bit_depth"] = msg->bit_depth; + if (msg->codec_header.has_value()) { + source_json["codec_header"] = msg->codec_header.value(); + } + + std::string output; + serializeJson(doc, output); + return output; +} + +std::string format_client_stream_end_message() { + // Every message carries a payload object, empty when the message defines no fields + // (Sendspin spec, Message Format); a literal, pinned by the exact-string unit test + return R"({"type":"client-stream/end","payload":{}})"; +} + std::string format_stream_request_format_message(const StreamRequestFormatMessage* msg) { (void)msg; diff --git a/src/protocol_messages.h b/src/protocol_messages.h index 8fdeedb..18745ba 100644 --- a/src/protocol_messages.h +++ b/src/protocol_messages.h @@ -47,32 +47,60 @@ namespace sendspin { enum SendspinBinaryRole : uint8_t { SENDSPIN_ROLE_PLAYER = 1, // 000001xx (IDs 4-7) SENDSPIN_ROLE_ARTWORK = 2, // 000010xx (IDs 8-11) + SENDSPIN_ROLE_SOURCE = 3, // 000011xx (IDs 12-15), outbound-only: never inbound-dispatched }; /// @brief Extracts the role field from a standard 4-slot binary message type byte /// @param type Binary message type byte. /// @return Role portion of the type (bits 7-2). -/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK, IDs 4-11). The visualizer -/// range (IDs 16-23) is dispatched by range in SendspinClient::process_binary_message -/// and must not be routed through this helper: get_binary_role(16) yields 4, which -/// matches no SendspinBinaryRole enumerator. +/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK IDs 4-11, SOURCE IDs +/// 12-15). The visualizer range (IDs 16-23) is dispatched by range in +/// SendspinClient::process_binary_message and must not be routed through this helper: +/// get_binary_role(16) yields 4, which matches no SendspinBinaryRole enumerator. inline uint8_t get_binary_role(uint8_t type) { return type >> 2; } /// @brief Extracts the slot field from a standard 4-slot binary message type byte /// @param type Binary message type byte. /// @return Slot portion of the type (bits 1-0). -/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK, IDs 4-11). It masks bits -/// 1-0, so it cannot address the visualizer's 8-slot range (e.g. IDs 16 and 20 both -/// alias to slot 0); those messages are dispatched by range, not by slot. +/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK IDs 4-11, SOURCE IDs +/// 12-15). It masks bits 1-0, so it cannot address the visualizer's 8-slot range (e.g. +/// IDs 16 and 20 both alias to slot 0); those messages are dispatched by range, not by +/// slot. inline uint8_t get_binary_slot(uint8_t type) { return type & 0x03; } +/// @brief Size of the big-endian 64-bit timestamp that follows the type byte in binary messages +inline constexpr size_t BINARY_TIMESTAMP_SIZE = 8; + +/// @brief Decodes a big-endian 64-bit value to host byte order +/// @param bytes Pointer to at least BINARY_TIMESTAMP_SIZE bytes. +/// @return The decoded value as a signed 64-bit integer. +inline int64_t be64_to_host(const uint8_t* bytes) { + uint64_t val = 0; + for (size_t i = 0; i < BINARY_TIMESTAMP_SIZE; ++i) { + val = (val << 8) | bytes[i]; + } + return static_cast(val); +} + +/// @brief Encodes a host 64-bit value as big-endian bytes +/// @param value Value to encode. +/// @param bytes [out] Destination for BINARY_TIMESTAMP_SIZE bytes. +inline void host_to_be64(int64_t value, uint8_t* bytes) { + const auto val = static_cast(value); + for (size_t i = 0; i < BINARY_TIMESTAMP_SIZE; ++i) { + bytes[i] = static_cast(val >> ((BINARY_TIMESTAMP_SIZE - 1 - i) * 8U)); + } +} + /// @brief Binary message type byte values for known message kinds enum SendspinBinaryType : uint8_t { - SENDSPIN_BINARY_PLAYER_AUDIO = 4, // Player slot 0: encoded audio chunk - SENDSPIN_BINARY_ARTWORK_IMAGE = 8, // Artwork slot 0: image data + SENDSPIN_BINARY_PLAYER_AUDIO = SENDSPIN_ROLE_PLAYER << 2, // Player slot 0: encoded audio + SENDSPIN_BINARY_ARTWORK_IMAGE = SENDSPIN_ROLE_ARTWORK << 2, // Artwork slot 0: image data + SENDSPIN_BINARY_SOURCE_AUDIO = SENDSPIN_ROLE_SOURCE << 2, // Source slot 0: encoded audio + // chunk (client->server) // Visualizer expanded allocation (IDs 16-23); each data type is its own message // carrying exactly one frame of [timestamp:8][data] SENDSPIN_BINARY_VISUALIZER_LOUDNESS = 16, // uint16 A-weighted loudness @@ -105,6 +133,7 @@ enum class SendspinRole : uint8_t { ARTWORK, // Album artwork role VISUALIZER, // Audio visualization role COLOR, // Audio-derived color palette role + SOURCE, // Audio capture role (streams to the server) }; /// @brief Converts a SendspinRole value to its protocol wire string representation @@ -124,6 +153,8 @@ inline const char* to_cstr(SendspinRole role) { return "visualizer@v1"; case SendspinRole::COLOR: return "color@v1"; + case SendspinRole::SOURCE: + return "source@v1"; default: return "unknown"; } @@ -605,6 +636,53 @@ struct ServerColorStateDelta { std::optional> on_light; }; +// --- source role --- + +/// @brief Commands addressed to the source role in server/command messages +enum class SourceCommand : uint8_t { + START, // Begin capturing and streaming audio to the server + STOP, // Stop capturing and streaming audio +}; + +inline std::optional source_command_from_string(const std::string& str) { + if (str == "start") { + return SourceCommand::START; + } + if (str == "stop") { + return SourceCommand::STOP; + } + return std::nullopt; +} + +/// @brief Line-input signal state reported by the source role in client/state messages +enum class SourceSignal : uint8_t { + PRESENT, // Audio signal detected on the capture input + ABSENT, // No audio signal on the capture input +}; + +inline const char* to_cstr(SourceSignal signal) { + switch (signal) { + case SourceSignal::PRESENT: + return "present"; + case SourceSignal::ABSENT: + default: + return "absent"; + } +} + +/// @brief Source capabilities advertised to the server during the hello handshake +struct SourceSupportObject { + bool line_sense{false}; +}; + +/// @brief Source state reported by the client to the server in client/state messages +/// +/// The signal field is only meaningful when line_sense was advertised in the hello; the object +/// itself may be present and empty (Sendspin spec, Source messages — Client state object). +struct ClientSourceStateObject { + std::optional signal{}; +}; + // ============================================================================ // Message envelope structs // ============================================================================ @@ -619,12 +697,27 @@ struct ClientHelloMessage { std::optional player_v1_support{}; std::optional artwork_v1_support{}; std::optional visualizer_support{}; + std::optional source_v1_support{}; }; -/// @brief Outgoing client/state message reporting client playback state to the server +/// @brief Outgoing client/state message reporting client availability and role state to the server struct ClientStateMessage { SendspinClientState state{}; std::optional player{}; + std::optional source{}; +}; + +/// @brief Outgoing client-stream/start message announcing the source's outbound audio format +/// +/// Mirrors the AudioSupportedFormatObject field set. codec_header is required for flac and +/// absent for pcm and opus (Sendspin spec, Source messages -- codec framing); the invariant is +/// the producer's contract, upheld by the role's config validation rather than checked here. +struct ClientStreamStartMessage { + SendspinCodecFormat codec{}; + uint8_t channels{}; + uint32_t sample_rate{}; + uint8_t bit_depth{}; + std::optional codec_header{}; }; /// @brief Parsed server/hello handshake message received at connection startup @@ -706,6 +799,14 @@ void apply_group_update_deltas(GroupUpdateObject* current, const GroupUpdateObje /// @return true if parsing succeeded, false on missing required fields. bool process_server_command_message(JsonObject root, ServerCommandMessage* cmd_msg); +/// @brief Parses the source section of a server/command JSON message +/// @param root Parsed JSON object from the message. +/// @param source_cmd [out] The parsed source command. +/// @return true if the message carried a source object with a valid command; false when the +/// section is absent or rejected (a source object with a missing or invalid command is +/// rejected as a whole). +bool process_server_command_source(JsonObject root, SourceCommand* source_cmd); + /// @brief Parses the metadata section of a server/state JSON message /// /// The server/state sections are parsed individually rather than into one aggregate struct: the @@ -774,6 +875,15 @@ std::string format_client_hello_message(const ClientHelloMessage* msg); /// @return State message serialized into JSON format. std::string format_client_state_message(const ClientStateMessage* msg); +/// @brief Formats a client-stream/start message as a JSON string for sending to the server +/// @param msg Message to serialize. +/// @return Stream start message serialized into JSON format. +std::string format_client_stream_start_message(const ClientStreamStartMessage* msg); + +/// @brief Formats a client-stream/end message as a JSON string for sending to the server +/// @return Stream end message serialized into JSON format. +std::string format_client_stream_end_message(); + /// @brief Formats a stream/request_format message as a JSON string for sending to the server /// @param msg Message to serialize. /// @return Stream request format message serialized into JSON format. diff --git a/src/visualizer_role.cpp b/src/visualizer_role.cpp index b77d947..7bb91ea 100644 --- a/src/visualizer_role.cpp +++ b/src/visualizer_role.cpp @@ -33,7 +33,7 @@ static const char* const TAG = "sendspin.visualizer"; // each entry costs an aligned per-entry ItemHeader, so effective wire-data capacity is smaller // (see the buffer_capacity note in config.h). static constexpr size_t ENTRY_TYPE_SIZE = 1; -static constexpr size_t TIMESTAMP_SIZE = 8; +static constexpr size_t TIMESTAMP_SIZE = sendspin::BINARY_TIMESTAMP_SIZE; // Minimum payload bytes after the timestamp, per wire message type static constexpr size_t LOUDNESS_PAYLOAD_SIZE = 2; // uint16 value @@ -77,14 +77,6 @@ static constexpr int64_t TOO_OLD_THRESHOLD_US = 20000; // 20ms // Big-endian helpers // ============================================================================ -static int64_t read_be64(const uint8_t* p) { - uint64_t val = 0; - for (int i = 0; i < 8; ++i) { - val = (val << 8) | p[i]; - } - return static_cast(val); -} - static uint16_t read_be16(const uint8_t* p) { return static_cast(p[0]) << 8 | static_cast(p[1]); } @@ -540,7 +532,7 @@ void VisualizerRole::Impl::drain_thread_func(VisualizerRole::Impl* self) { } auto* raw = static_cast(item); uint8_t wire_type = raw[0]; - int64_t server_ts = read_be64(raw + ENTRY_TYPE_SIZE); + int64_t server_ts = be64_to_host(raw + ENTRY_TYPE_SIZE); int64_t client_ts = self->client->get_client_time(server_ts); if (client_ts == 0) { diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 5d453c0..225f89f 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -730,6 +731,39 @@ TEST(Protocol, FormatStreamRequestFormatVisualizer) { EXPECT_FALSE(doc3["payload"]["visualizer"].is()); } +// ============================================================================ +// Binary big-endian helpers +// ============================================================================ + +// host_to_be64 -> be64_to_host must be the identity across the full int64_t range. Catches a +// helper pair that only agrees on non-negative or small values. +TEST(Protocol, Be64RoundTrip) { + const int64_t values[] = { + 0, 1, -1, 255, 256, INT64_MAX, INT64_MIN, 0x0102030405060708LL, -1234567890123456789LL, + }; + for (const int64_t value : values) { + uint8_t bytes[BINARY_TIMESTAMP_SIZE]; + host_to_be64(value, bytes); + EXPECT_EQ(be64_to_host(bytes), value) << "no round-trip for " << value; + } +} + +// The encoded layout must be genuinely big-endian (most significant byte first), not host order: +// the wire format is fixed regardless of platform endianness. +TEST(Protocol, Be64ByteOrderIsBigEndian) { + uint8_t bytes[BINARY_TIMESTAMP_SIZE]; + host_to_be64(0x0102030405060708LL, bytes); + const uint8_t expected[BINARY_TIMESTAMP_SIZE] = {0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08}; + EXPECT_EQ(0, std::memcmp(bytes, expected, BINARY_TIMESTAMP_SIZE)); + + // Negative values encode as two's complement, most significant byte first. + host_to_be64(-2, bytes); + const uint8_t expected_negative[BINARY_TIMESTAMP_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFE}; + EXPECT_EQ(0, std::memcmp(bytes, expected_negative, BINARY_TIMESTAMP_SIZE)); +} + // Unset optional identity fields must not emit their keys. TEST(Protocol, FormatClientHelloDeviceInfoFieldsAbsent) { ClientHelloMessage msg; @@ -745,3 +779,162 @@ TEST(Protocol, FormatClientHelloDeviceInfoFieldsAbsent) { EXPECT_FALSE(doc["payload"]["device_info"]["software_version"].is()); EXPECT_FALSE(doc["payload"]["device_info"]["mac_address"].is()); } + +// ============================================================================ +// Source role protocol +// ============================================================================ + +// The to_cstr(SendspinRole) switch has a default arm, so a forgotten case compiles clean and +// silently advertises "unknown"; pin the wire string. +TEST(Protocol, SourceRoleWireString) { + EXPECT_STREQ(to_cstr(SendspinRole::SOURCE), "source@v1"); +} + +// Pins the source binary ID block to the documented role<<2 scheme (role 3, IDs 12-15). +TEST(Protocol, SourceBinaryIdScheme) { + EXPECT_EQ(SENDSPIN_BINARY_SOURCE_AUDIO, 12); + EXPECT_EQ(get_binary_role(SENDSPIN_BINARY_SOURCE_AUDIO), SENDSPIN_ROLE_SOURCE); + EXPECT_EQ(get_binary_slot(SENDSPIN_BINARY_SOURCE_AUDIO), 0); +} + +TEST(Protocol, FormatClientHelloSourceSupport) { + ClientHelloMessage msg; + msg.client_id = "abc"; + msg.name = "Line In"; + msg.version = 1; + msg.supported_roles = {SendspinRole::SOURCE}; + + // line_sense advertised: features.line_sense is true. + msg.source_v1_support = SourceSupportObject{.line_sense = true}; + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, format_client_hello_message(&msg))); + EXPECT_STREQ(doc["payload"]["supported_roles"][0], "source@v1"); + ASSERT_TRUE(doc["payload"]["source@v1_support"]["features"]["line_sense"].is()); + EXPECT_TRUE(doc["payload"]["source@v1_support"]["features"]["line_sense"].as()); + + // No line_sense: the support object is present but empty (features omitted, not false). + msg.source_v1_support = SourceSupportObject{}; + JsonDocument doc2; + ASSERT_FALSE(deserializeJson(doc2, format_client_hello_message(&msg))); + ASSERT_TRUE(doc2["payload"]["source@v1_support"].is()); + EXPECT_EQ(doc2["payload"]["source@v1_support"].as().size(), 0U); + + // Support object not set: the key is absent entirely. + msg.supported_roles = {}; + msg.source_v1_support.reset(); + JsonDocument doc3; + ASSERT_FALSE(deserializeJson(doc3, format_client_hello_message(&msg))); + EXPECT_FALSE(doc3["payload"]["source@v1_support"].is()); +} + +// Exact-field assertions include the spec's hyphenated "client-stream/start" type string. +TEST(Protocol, FormatClientStreamStartPcm) { + ClientStreamStartMessage msg; + msg.codec = SendspinCodecFormat::PCM; + msg.channels = 2; + msg.sample_rate = 48000; + msg.bit_depth = 16; + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, format_client_stream_start_message(&msg))); + EXPECT_STREQ(doc["type"], "client-stream/start"); + EXPECT_STREQ(doc["payload"]["source"]["codec"], "pcm"); + EXPECT_EQ(doc["payload"]["source"]["channels"].as(), 2); + EXPECT_EQ(doc["payload"]["source"]["sample_rate"].as(), 48000U); + EXPECT_EQ(doc["payload"]["source"]["bit_depth"].as(), 16); + // pcm carries no codec header; the key must be absent. + EXPECT_FALSE(doc["payload"]["source"]["codec_header"].is()); + EXPECT_EQ(doc["payload"]["source"].as().size(), 4U); +} + +TEST(Protocol, FormatClientStreamStartFlacCodecHeader) { + ClientStreamStartMessage msg; + msg.codec = SendspinCodecFormat::FLAC; + msg.channels = 2; + msg.sample_rate = 44100; + msg.bit_depth = 24; + msg.codec_header = "c2VuZHNwaW4="; // base64 payload is passed through verbatim + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, format_client_stream_start_message(&msg))); + EXPECT_STREQ(doc["type"], "client-stream/start"); + EXPECT_STREQ(doc["payload"]["source"]["codec"], "flac"); + EXPECT_STREQ(doc["payload"]["source"]["codec_header"], "c2VuZHNwaW4="); +} + +// client-stream/end defines no fields but still carries the required empty payload object +// (Sendspin spec, Message Format); pin the exact bytes. +TEST(Protocol, FormatClientStreamEndExactString) { + EXPECT_EQ(format_client_stream_end_message(), R"({"type":"client-stream/end","payload":{}})"); +} + +// Control cases for the rejection tests below: a well-formed source command must parse. +TEST(Protocol, ServerCommandSourceParsesStartAndStop) { + JsonDocument doc; + JsonObject root; + SourceCommand cmd{}; + + ASSERT_TRUE(parse(R"({"type":"server/command","payload":{"source":{"command":"start"}}})", + doc, root)); + ASSERT_TRUE(process_server_command_source(root, &cmd)); + EXPECT_EQ(cmd, SourceCommand::START); + + ASSERT_TRUE(parse(R"({"type":"server/command","payload":{"source":{"command":"stop"}}})", + doc, root)); + ASSERT_TRUE(process_server_command_source(root, &cmd)); + EXPECT_EQ(cmd, SourceCommand::STOP); +} + +// "command" is required with no default: a source object with it missing, unknown, or of the +// wrong JSON type is rejected as a whole (fail closed). +TEST(Protocol, ServerCommandSourceRejectsMalformedCommand) { + JsonDocument doc; + JsonObject root; + SourceCommand cmd{}; + + // Missing command. + ASSERT_TRUE(parse(R"({"type":"server/command","payload":{"source":{}}})", doc, root)); + EXPECT_FALSE(process_server_command_source(root, &cmd)); + + // Unknown command string. + ASSERT_TRUE(parse(R"({"type":"server/command","payload":{"source":{"command":"pause"}}})", + doc, root)); + EXPECT_FALSE(process_server_command_source(root, &cmd)); + + // Wrong JSON type. + ASSERT_TRUE(parse(R"({"type":"server/command","payload":{"source":{"command":1}}})", doc, + root)); + EXPECT_FALSE(process_server_command_source(root, &cmd)); + + // No source object at all: nothing to dispatch. + ASSERT_TRUE(parse(R"({"type":"server/command","payload":{}})", doc, root)); + EXPECT_FALSE(process_server_command_source(root, &cmd)); +} + +TEST(Protocol, FormatClientStateSourceObject) { + ClientStateMessage msg; + msg.state = SendspinClientState::SYNCHRONIZED; + + // No source state set: the key is absent. + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, format_client_state_message(&msg))); + EXPECT_FALSE(doc["payload"]["source"].is()); + + // Source state with no signal: present but empty. + msg.source = ClientSourceStateObject{}; + JsonDocument doc2; + ASSERT_FALSE(deserializeJson(doc2, format_client_state_message(&msg))); + ASSERT_TRUE(doc2["payload"]["source"].is()); + EXPECT_EQ(doc2["payload"]["source"].as().size(), 0U); + + // Signal present and absent both serialize. + msg.source = ClientSourceStateObject{.signal = SourceSignal::PRESENT}; + JsonDocument doc3; + ASSERT_FALSE(deserializeJson(doc3, format_client_state_message(&msg))); + EXPECT_STREQ(doc3["payload"]["source"]["signal"], "present"); + + msg.source = ClientSourceStateObject{.signal = SourceSignal::ABSENT}; + JsonDocument doc4; + ASSERT_FALSE(deserializeJson(doc4, format_client_state_message(&msg))); + EXPECT_STREQ(doc4["payload"]["source"]["signal"], "absent"); +}