diff --git a/src/client.cpp b/src/client.cpp index 6efbc00..0f2c27c 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -838,28 +838,37 @@ void SendspinClient::process_json_message(SendspinConnection* conn, const char* break; } case SendspinServerToClientMessageType::SERVER_STATE: { - ServerStateMessage state_msg; - if (process_server_state_message(root, &state_msg)) { + // Parse and hand off one section at a time, each in its own scope. Parsing the whole + // message into an aggregate would hold every section's storage (a metadata delta alone + // is 200 bytes) in this frame at once, and this runs on the network task, whose stack + // is small on ESP-IDF. Scoping the sections lets the compiler reuse the same slots, and + // a section is only parsed at all when its role is present. #ifdef SENDSPIN_ENABLE_CONTROLLER - if (this->controller_ && state_msg.controller.has_value()) { - this->controller_->impl_->handle_server_state( - std::move(state_msg.controller.value())); + if (this->controller_ != nullptr) { + ServerStateControllerObject controller_state; + if (process_server_state_controller(root, &controller_state)) { + this->controller_->impl_->handle_server_state(std::move(controller_state)); } + } #endif #ifdef SENDSPIN_ENABLE_METADATA - if (this->metadata_ && state_msg.metadata.has_value()) { - this->metadata_->impl_->handle_server_state( - std::move(state_msg.metadata.value())); + if (this->metadata_ != nullptr) { + ServerMetadataStateDelta metadata_delta; + if (process_server_state_metadata(root, &metadata_delta)) { + this->metadata_->impl_->handle_server_state(std::move(metadata_delta)); } + } #endif #ifdef SENDSPIN_ENABLE_COLOR - if (this->color_ && state_msg.color.has_value()) { - this->color_->impl_->handle_server_state(state_msg.color.value()); + if (this->color_ != nullptr) { + ServerColorStateDelta color_delta; + if (process_server_state_color(root, &color_delta)) { + this->color_->impl_->handle_server_state(color_delta); } -#endif } +#endif break; } case SendspinServerToClientMessageType::SERVER_COMMAND: { diff --git a/src/color_role.cpp b/src/color_role.cpp index 507299e..2427141 100644 --- a/src/color_role.cpp +++ b/src/color_role.cpp @@ -91,7 +91,7 @@ void ColorRole::Impl::build_hello_fields(ClientHelloMessage& msg) { msg.supported_roles.push_back(SendspinRole::COLOR); } -void ColorRole::Impl::handle_server_state(ServerColorStateDelta delta) const { +void ColorRole::Impl::handle_server_state(const ServerColorStateDelta& delta) const { // Merge incoming wire delta into the accumulated delta in the inbox slot; see // merge_color_state_delta for the field-overlay semantics. this->event_state->slot.merge(merge_color_state_delta, delta); diff --git a/src/color_role_impl.h b/src/color_role_impl.h index f351bab..9c07f73 100644 --- a/src/color_role_impl.h +++ b/src/color_role_impl.h @@ -50,7 +50,10 @@ struct ColorRole::Impl { void attach_inbox(Inbox& inbox); void build_hello_fields(ClientHelloMessage& msg); - void handle_server_state(ServerColorStateDelta delta) const; + // Takes a const reference, unlike the metadata and controller overloads: a + // ServerColorStateDelta is trivially copyable (see merge_color_state_delta), so there is + // nothing for an rvalue reference to move out of. + void handle_server_state(const ServerColorStateDelta& delta) const; // True if a slot delta needs folding in, or a delta already held from a prior tick (see // held_delta) is still waiting out its server-clock deadline -- the deadline itself sets no // inbox bit, so held_delta must be polled every tick until it fires. diff --git a/src/controller_role.cpp b/src/controller_role.cpp index f11896f..680ad76 100644 --- a/src/controller_role.cpp +++ b/src/controller_role.cpp @@ -70,7 +70,7 @@ void ControllerRole::Impl::build_hello_fields(ClientHelloMessage& msg) { msg.supported_roles.push_back(SendspinRole::CONTROLLER); } -void ControllerRole::Impl::handle_server_state(ServerStateControllerObject state) const { +void ControllerRole::Impl::handle_server_state(ServerStateControllerObject&& state) const { this->event_state->slot.write(std::move(state)); } diff --git a/src/controller_role_impl.h b/src/controller_role_impl.h index 9681cb2..c46cd39 100644 --- a/src/controller_role_impl.h +++ b/src/controller_role_impl.h @@ -46,7 +46,7 @@ struct ControllerRole::Impl { void attach_inbox(Inbox& inbox); void build_hello_fields(ClientHelloMessage& msg); - void handle_server_state(ServerStateControllerObject state) const; + void handle_server_state(ServerStateControllerObject&& state) const; // True if a controller-state delta is waiting in the inbox slot. bool needs_drain(uint32_t pending_bits) const { return (pending_bits & INBOX_TOPIC_CONTROLLER) != 0; diff --git a/src/metadata_role.cpp b/src/metadata_role.cpp index 6e1d09f..b8da21b 100644 --- a/src/metadata_role.cpp +++ b/src/metadata_role.cpp @@ -147,7 +147,7 @@ void MetadataRole::Impl::build_hello_fields(ClientHelloMessage& msg) { msg.supported_roles.push_back(SendspinRole::METADATA); } -void MetadataRole::Impl::handle_server_state(ServerMetadataStateDelta delta) const { +void MetadataRole::Impl::handle_server_state(ServerMetadataStateDelta&& delta) const { // Merge incoming wire delta into the accumulated delta in the inbox slot; see // merge_metadata_state_delta for the field-overlay semantics. this->event_state->slot.merge(merge_metadata_state_delta, std::move(delta)); diff --git a/src/metadata_role_impl.h b/src/metadata_role_impl.h index dbe12d5..7dcb383 100644 --- a/src/metadata_role_impl.h +++ b/src/metadata_role_impl.h @@ -48,7 +48,7 @@ struct MetadataRole::Impl { void attach_inbox(Inbox& inbox); void build_hello_fields(ClientHelloMessage& msg); - void handle_server_state(ServerMetadataStateDelta delta) const; + void handle_server_state(ServerMetadataStateDelta&& delta) const; // True if a slot delta needs folding in, or a delta already held from a prior tick (see // held_delta) is still waiting out its server-clock deadline -- the deadline itself sets no // inbox bit, so held_delta must be polled every tick until it fires. diff --git a/src/protocol.cpp b/src/protocol.cpp index 6a5a7bb..fa35e0f 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -243,54 +243,6 @@ static void parse_metadata_uint16_field(JsonVariantConst var, const char* name, } } -static bool process_server_metadata_state_object(const JsonObject metadata_object, - ServerMetadataStateDelta* metadata_delta) { - if (metadata_delta == nullptr) { - return false; - } - - // timestamp is required (not optional) - if (!metadata_object["timestamp"].is()) { - SS_LOGE(TAG, "Invalid metadata state object: missing timestamp"); - return false; - } - metadata_delta->timestamp = metadata_object["timestamp"].as(); - - parse_metadata_string_field(metadata_object["title"], "title", &metadata_delta->title); - parse_metadata_string_field(metadata_object["artist"], "artist", &metadata_delta->artist); - parse_metadata_string_field(metadata_object["album_artist"], "album_artist", - &metadata_delta->album_artist); - parse_metadata_string_field(metadata_object["album"], "album", &metadata_delta->album); - parse_metadata_string_field(metadata_object["artwork_url"], "artwork_url", - &metadata_delta->artwork_url); - parse_metadata_uint16_field(metadata_object["year"], "year", &metadata_delta->year); - parse_metadata_uint16_field(metadata_object["track"], "track", &metadata_delta->track); - - // Parse progress object - present object engages inner; explicit null clears; absent leaves - // outer nullopt. - if (metadata_object["progress"].is()) { - JsonObject progress_object = metadata_object["progress"]; - MetadataProgressObject progress{}; - if (auto v = - read_uint_field(progress_object["track_progress"], "track_progress")) { - progress.track_progress = *v; - } - if (auto v = - read_uint_field(progress_object["track_duration"], "track_duration")) { - progress.track_duration = *v; - } - if (auto v = - read_uint_field(progress_object["playback_speed"], "playback_speed")) { - progress.playback_speed = *v; - } - metadata_delta->progress = progress; - } else if (!metadata_object["progress"].isUnbound() && metadata_object["progress"].isNull()) { - metadata_delta->progress = std::optional{}; - } - - return true; -} - // Parses a single `[R, G, B]` color field into a tri-state delta entry. Absent leaves `out` // untouched (outer nullopt); explicit `null` writes outer-engaged + inner-nullopt (clear); a // 3-element array of 0-255 integers writes the color. Any malformed value is logged and skipped @@ -325,30 +277,6 @@ static void parse_color_field(JsonVariantConst var, const char* name, *out = color; } -static bool process_server_color_state_object(const JsonObject color_object, - ServerColorStateDelta* color_delta) { - if (color_delta == nullptr) { - return false; - } - - if (!color_object["timestamp"].is()) { - SS_LOGE(TAG, "Invalid color state object: missing timestamp"); - return false; - } - color_delta->timestamp = color_object["timestamp"].as(); - - parse_color_field(color_object["background_dark"], "background_dark", - &color_delta->background_dark); - parse_color_field(color_object["background_light"], "background_light", - &color_delta->background_light); - parse_color_field(color_object["primary"], "primary", &color_delta->primary); - parse_color_field(color_object["accent"], "accent", &color_delta->accent); - parse_color_field(color_object["on_dark"], "on_dark", &color_delta->on_dark); - parse_color_field(color_object["on_light"], "on_light", &color_delta->on_light); - - return true; -} - // ============================================================================ // Protocol functions // ============================================================================ @@ -532,75 +460,131 @@ bool process_server_command_message(JsonObject root, ServerCommandMessage* cmd_m return true; } -bool process_server_state_message(JsonObject root, ServerStateMessage* state_msg) { - if (state_msg == nullptr) { - 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 +// parser built a second copy of the same fields in its own. Each section here is an out-of-line +// function that fills a caller-owned struct directly, so only one section's storage is live at a +// time and nothing is materialized twice. + +bool process_server_state_metadata(JsonObject root, ServerMetadataStateDelta* metadata_delta) { + if (metadata_delta == nullptr || !root["payload"]["metadata"].is()) { + return false; } + const JsonObject metadata_object = root["payload"]["metadata"]; - (void)root; + // timestamp is required (not optional) + if (!metadata_object["timestamp"].is()) { + SS_LOGE(TAG, "Invalid metadata state object: missing timestamp"); + return false; + } + metadata_delta->timestamp = metadata_object["timestamp"].as(); + + parse_metadata_string_field(metadata_object["title"], "title", &metadata_delta->title); + parse_metadata_string_field(metadata_object["artist"], "artist", &metadata_delta->artist); + parse_metadata_string_field(metadata_object["album_artist"], "album_artist", + &metadata_delta->album_artist); + parse_metadata_string_field(metadata_object["album"], "album", &metadata_delta->album); + parse_metadata_string_field(metadata_object["artwork_url"], "artwork_url", + &metadata_delta->artwork_url); + parse_metadata_uint16_field(metadata_object["year"], "year", &metadata_delta->year); + parse_metadata_uint16_field(metadata_object["track"], "track", &metadata_delta->track); - // Parse optional metadata object - if (root["payload"]["metadata"].is()) { - ServerMetadataStateDelta metadata_delta{}; - if (process_server_metadata_state_object(root["payload"]["metadata"], &metadata_delta)) { - state_msg->metadata = std::move(metadata_delta); + // Parse progress object - present object engages inner; explicit null clears; absent leaves + // outer nullopt. + if (metadata_object["progress"].is()) { + JsonObject progress_object = metadata_object["progress"]; + MetadataProgressObject progress{}; + if (auto v = + read_uint_field(progress_object["track_progress"], "track_progress")) { + progress.track_progress = *v; + } + if (auto v = + read_uint_field(progress_object["track_duration"], "track_duration")) { + progress.track_duration = *v; + } + if (auto v = + read_uint_field(progress_object["playback_speed"], "playback_speed")) { + progress.playback_speed = *v; } + metadata_delta->progress = progress; + } else if (!metadata_object["progress"].isUnbound() && metadata_object["progress"].isNull()) { + metadata_delta->progress = std::optional{}; } - if (root["payload"]["color"].is()) { - ServerColorStateDelta color_delta{}; - if (process_server_color_state_object(root["payload"]["color"], &color_delta)) { - state_msg->color = color_delta; - } + return true; +} + +bool process_server_state_color(JsonObject root, ServerColorStateDelta* color_delta) { + if (color_delta == nullptr || !root["payload"]["color"].is()) { + return false; } + const JsonObject color_object = root["payload"]["color"]; - if (root["payload"]["controller"].is()) { - ServerStateControllerObject controller_state{}; - JsonObject controller_object = root["payload"]["controller"]; + if (!color_object["timestamp"].is()) { + SS_LOGE(TAG, "Invalid color state object: missing timestamp"); + return false; + } + color_delta->timestamp = color_object["timestamp"].as(); - // Parse supported_commands array. The controller role is frozen at v1, so an unrecognized - // command is a non-compliant value rather than a forward-compatible one: drop and log it. - if (controller_object["supported_commands"].is()) { - std::vector commands; - for (JsonVariantConst command_var : - controller_object["supported_commands"].as()) { - if (auto command = read_enum_field(command_var, "supported_commands", - controller_command_from_string)) { - commands.push_back(*command); - } - } - controller_state.supported_commands = std::move(commands); - } + parse_color_field(color_object["background_dark"], "background_dark", + &color_delta->background_dark); + parse_color_field(color_object["background_light"], "background_light", + &color_delta->background_light); + parse_color_field(color_object["primary"], "primary", &color_delta->primary); + parse_color_field(color_object["accent"], "accent", &color_delta->accent); + parse_color_field(color_object["on_dark"], "on_dark", &color_delta->on_dark); + parse_color_field(color_object["on_light"], "on_light", &color_delta->on_light); - // Parse volume - if (auto v = - read_uint_field(controller_object["volume"], "volume", 0, VOLUME_MAX)) { - controller_state.volume = *v; - } + return true; +} - // Parse muted - if (auto v = read_bool_field(controller_object["muted"], "muted")) { - controller_state.muted = *v; +bool process_server_state_controller(JsonObject root, + ServerStateControllerObject* controller_state) { + if (controller_state == nullptr || !root["payload"]["controller"].is()) { + return false; + } + const JsonObject controller_object = root["payload"]["controller"]; + + // Parse supported_commands array. The controller role is frozen at v1, so an unrecognized + // command is a non-compliant value rather than a forward-compatible one: drop and log it. + if (controller_object["supported_commands"].is()) { + std::vector commands; + for (JsonVariantConst command_var : + controller_object["supported_commands"].as()) { + if (auto command = read_enum_field(command_var, "supported_commands", + controller_command_from_string)) { + commands.push_back(*command); + } } + controller_state->supported_commands = std::move(commands); + } - // Parse repeat - if (auto repeat = - read_enum_field(controller_object["repeat"], "repeat", repeat_mode_from_string)) { - controller_state.repeat = *repeat; - } + // Parse volume + if (auto v = read_uint_field(controller_object["volume"], "volume", 0, VOLUME_MAX)) { + controller_state->volume = *v; + } - // Parse shuffle - if (auto v = read_bool_field(controller_object["shuffle"], "shuffle")) { - controller_state.shuffle = *v; - } + // Parse muted + if (auto v = read_bool_field(controller_object["muted"], "muted")) { + controller_state->muted = *v; + } - // Parse seek_max_ms. Present only when the server offers 'seek' and the range is known; - // left absent (nullopt) otherwise so consumers can distinguish "unknown range" from 0. - if (auto v = read_uint_field(controller_object["seek_max_ms"], "seek_max_ms")) { - controller_state.seek_max_ms = v; - } + // Parse repeat + if (auto repeat = + read_enum_field(controller_object["repeat"], "repeat", repeat_mode_from_string)) { + controller_state->repeat = *repeat; + } + + // Parse shuffle + if (auto v = read_bool_field(controller_object["shuffle"], "shuffle")) { + controller_state->shuffle = *v; + } - state_msg->controller = std::move(controller_state); + // Parse seek_max_ms. Present only when the server offers 'seek' and the range is known; + // left absent (nullopt) otherwise so consumers can distinguish "unknown range" from 0. + if (auto v = read_uint_field(controller_object["seek_max_ms"], "seek_max_ms")) { + controller_state->seek_max_ms = v; } return true; @@ -611,24 +595,26 @@ bool process_stream_start_message(JsonObject root, StreamStartMessage* stream_ms return true; } - (void)root; + // Each section is parsed straight into the caller's struct rather than into a local that is + // then moved in. Both copies would otherwise be live at once in this frame, which matters on + // the ESP httpd task (4 KB stack); on a parse failure the section is reset instead. if (root["payload"]["player"].is()) { - ServerPlayerStreamObject player_obj{}; - if (process_player_stream_object(root["payload"]["player"], &player_obj, true)) { - if (!player_obj.is_complete()) { - SS_LOGE(TAG, "Invalid stream/start message: incomplete player object"); - return false; - } - stream_msg->player = std::move(player_obj); - } else { + ServerPlayerStreamObject& player_obj = stream_msg->player.emplace(); + if (!process_player_stream_object(root["payload"]["player"], &player_obj, true)) { + stream_msg->player.reset(); + return false; + } + if (!player_obj.is_complete()) { + SS_LOGE(TAG, "Invalid stream/start message: incomplete player object"); + stream_msg->player.reset(); return false; } } if (root["payload"]["artwork"]["channels"].is()) { - ServerArtworkStreamObject artwork_obj{}; - std::vector channels; + std::vector& channels = + stream_msg->artwork.emplace().channels.emplace(); JsonArray channels_array = root["payload"]["artwork"]["channels"].as(); for (JsonObject channel_json : channels_array) { @@ -636,19 +622,19 @@ bool process_stream_start_message(JsonObject root, StreamStartMessage* stream_ms if (process_artwork_channel_object(channel_json, &channel, true)) { if (!channel.is_complete()) { SS_LOGE(TAG, "Invalid stream/start message: incomplete artwork channel"); + stream_msg->artwork.reset(); return false; } channels.push_back(channel); } else { + stream_msg->artwork.reset(); return false; } } - artwork_obj.channels = std::move(channels); - stream_msg->artwork = std::move(artwork_obj); } if (root["payload"]["visualizer"].is()) { - ServerVisualizerStreamObject vis_obj{}; + ServerVisualizerStreamObject& vis_obj = stream_msg->visualizer.emplace(); JsonObject vis_json = root["payload"]["visualizer"]; // Parse types array @@ -699,8 +685,7 @@ bool process_stream_start_message(JsonObject root, StreamStartMessage* stream_ms if (advertises_spectrum && !vis_obj.spectrum.has_value()) { SS_LOGE(TAG, "Ignoring visualizer stream config: SPECTRUM advertised without valid " "spectrum config"); - } else { - stream_msg->visualizer = std::move(vis_obj); + stream_msg->visualizer.reset(); } } diff --git a/src/protocol_messages.h b/src/protocol_messages.h index e02ccc9..8fdeedb 100644 --- a/src/protocol_messages.h +++ b/src/protocol_messages.h @@ -627,13 +627,6 @@ struct ClientStateMessage { std::optional player{}; }; -/// @brief Parsed server/state message containing per-role state updates -struct ServerStateMessage { - std::optional controller; - std::optional metadata; - std::optional color; -}; - /// @brief Parsed server/hello handshake message received at connection startup struct ServerHelloMessage { ServerInformationObject server{}; @@ -713,11 +706,30 @@ 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 a server/state JSON message into the provided struct +/// @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 +/// caller runs on the network task, whose stack is small on ESP-IDF (the httpd task gets 4 KB), and +/// an aggregate would keep every section's storage live in the caller's frame for the whole parse. +/// Each function fills a caller-owned struct in place and reports whether that section was present. +/// /// @param root Parsed JSON object from the message. -/// @param state_msg [out] Struct to populate with parsed fields. -/// @return true if parsing succeeded, false on missing required fields. -bool process_server_state_message(JsonObject root, ServerStateMessage* state_msg); +/// @param metadata_delta [out] Struct to populate with the parsed delta. +/// @return true if the message carried a metadata section that parsed successfully. +bool process_server_state_metadata(JsonObject root, ServerMetadataStateDelta* metadata_delta); + +/// @brief Parses the color section of a server/state JSON message +/// @param root Parsed JSON object from the message. +/// @param color_delta [out] Struct to populate with the parsed delta. +/// @return true if the message carried a color section that parsed successfully. +bool process_server_state_color(JsonObject root, ServerColorStateDelta* color_delta); + +/// @brief Parses the controller section of a server/state JSON message +/// @param root Parsed JSON object from the message. +/// @param controller_state [out] Struct to populate with parsed fields. +/// @return true if the message carried a controller section that parsed successfully. +bool process_server_state_controller(JsonObject root, + ServerStateControllerObject* controller_state); /// @brief Parses a stream/start JSON message into the provided struct /// @param root Parsed JSON object from the message. diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 94c6abf..5d453c0 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -145,12 +145,11 @@ TEST(Protocol, MetadataValueUpdate) { R"({"timestamp":123,"title":"Song","artist":"Band"}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.metadata.has_value()); + ServerMetadataStateDelta delta; + ASSERT_TRUE(process_server_state_metadata(root, &delta)); ServerMetadataStateObject current; - apply_metadata_state_deltas(¤t, msg.metadata.value()); + apply_metadata_state_deltas(¤t, delta); EXPECT_EQ(current.timestamp, 123); ASSERT_TRUE(current.title.has_value()); @@ -172,10 +171,9 @@ TEST(Protocol, MetadataNullClearsAndAbsentPreserves) { R"({"timestamp":200,"title":null}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.metadata.has_value()); - apply_metadata_state_deltas(¤t, msg.metadata.value()); + ServerMetadataStateDelta delta; + ASSERT_TRUE(process_server_state_metadata(root, &delta)); + apply_metadata_state_deltas(¤t, delta); EXPECT_FALSE(current.title.has_value()); // explicit null cleared it ASSERT_TRUE(current.artist.has_value()); // absent left it untouched @@ -188,10 +186,9 @@ TEST(Protocol, MetadataMissingTimestampIsRejected) { ASSERT_TRUE( parse(R"({"type":"server/state","payload":{"metadata":{"title":"X"}}})", doc, root)); - ServerStateMessage msg; - // The top-level call still succeeds, but the malformed metadata sub-object is dropped. - ASSERT_TRUE(process_server_state_message(root, &msg)); - EXPECT_FALSE(msg.metadata.has_value()); + // The malformed metadata section is reported as absent rather than partially applied. + ServerMetadataStateDelta delta; + EXPECT_FALSE(process_server_state_metadata(root, &delta)); } // ============================================================================ @@ -210,10 +207,9 @@ TEST(Protocol, ColorRangeValidationAndMerge) { R"("primary":[10,20,30],"accent":[300,0,0],"on_dark":null}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.color.has_value()); - apply_color_state_deltas(¤t, msg.color.value()); + ServerColorStateDelta delta; + ASSERT_TRUE(process_server_state_color(root, &delta)); + apply_color_state_deltas(¤t, delta); ASSERT_TRUE(current.primary.has_value()); EXPECT_EQ(current.primary.value(), (RgbColor{10, 20, 30})); @@ -454,12 +450,11 @@ TEST(Protocol, ColorRejectsNonIntegerComponent) { ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"color":)" R"({"timestamp":1,"primary":[10,"x",30]}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.color.has_value()); + ServerColorStateDelta delta; + ASSERT_TRUE(process_server_state_color(root, &delta)); ServerColorStateObject current; - apply_color_state_deltas(¤t, msg.color.value()); + apply_color_state_deltas(¤t, delta); EXPECT_FALSE(current.primary.has_value()); // malformed component -> whole color dropped } @@ -472,11 +467,10 @@ TEST(Protocol, ControllerSupportedCommandsValidation) { ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"controller":)" R"({"supported_commands":["play","bogus","mute"]}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.controller.has_value()); + ServerStateControllerObject controller; + ASSERT_TRUE(process_server_state_controller(root, &controller)); - const auto& commands = msg.controller->supported_commands; + const auto& commands = controller.supported_commands; ASSERT_EQ(commands.size(), 2u); // "bogus" dropped EXPECT_EQ(commands[0], SendspinControllerCommand::PLAY); EXPECT_EQ(commands[1], SendspinControllerCommand::MUTE); @@ -489,11 +483,10 @@ TEST(Protocol, ControllerSeekMaxParsed) { ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"controller":)" R"({"supported_commands":["seek"],"seek_max_ms":215000}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.controller.has_value()); - ASSERT_TRUE(msg.controller->seek_max_ms.has_value()); - EXPECT_EQ(*msg.controller->seek_max_ms, 215000u); + ServerStateControllerObject controller; + ASSERT_TRUE(process_server_state_controller(root, &controller)); + ASSERT_TRUE(controller.seek_max_ms.has_value()); + EXPECT_EQ(*controller.seek_max_ms, 215000u); } // seek_max_ms stays absent (nullopt) when omitted, so consumers can tell "unknown range" from 0. @@ -503,10 +496,9 @@ TEST(Protocol, ControllerSeekMaxAbsentWhenOmitted) { ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"controller":)" R"({"supported_commands":["seek_relative"]}}})", doc, root)); - ServerStateMessage msg; - ASSERT_TRUE(process_server_state_message(root, &msg)); - ASSERT_TRUE(msg.controller.has_value()); - EXPECT_FALSE(msg.controller->seek_max_ms.has_value()); + ServerStateControllerObject controller; + ASSERT_TRUE(process_server_state_controller(root, &controller)); + EXPECT_FALSE(controller.seek_max_ms.has_value()); } // ============================================================================