diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 6fb5d6d..2bc6c6a 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -489,26 +489,36 @@ client.disconnect(SendspinGoodbyeReason::SHUTDOWN); ## Sending Commands -If you added the controller role, use it to send playback commands: +If you added the controller role, use it to send playback commands. `send_command` takes a `ClientCommandControllerObject`, built with designated initializers - set only the field the command uses: ```cpp -controller.send_command(SendspinControllerCommand::PLAY); -controller.send_command(SendspinControllerCommand::PAUSE); -controller.send_command(SendspinControllerCommand::NEXT); -controller.send_command(SendspinControllerCommand::PREVIOUS); -controller.send_command(SendspinControllerCommand::STOP); -controller.send_command(SendspinControllerCommand::SHUFFLE); -controller.send_command(SendspinControllerCommand::UNSHUFFLE); -controller.send_command(SendspinControllerCommand::REPEAT_OFF); -controller.send_command(SendspinControllerCommand::REPEAT_ONE); -controller.send_command(SendspinControllerCommand::REPEAT_ALL); - -// Volume and mute take additional arguments -controller.send_command(SendspinControllerCommand::VOLUME, 75); // Volume 0-100 -controller.send_command(SendspinControllerCommand::MUTE, {}, true); // Mute on -controller.send_command(SendspinControllerCommand::MUTE, {}, false); // Mute off +controller.send_command({.command = SendspinControllerCommand::PLAY}); +controller.send_command({.command = SendspinControllerCommand::PAUSE}); +controller.send_command({.command = SendspinControllerCommand::NEXT}); +controller.send_command({.command = SendspinControllerCommand::PREVIOUS}); +controller.send_command({.command = SendspinControllerCommand::STOP}); +controller.send_command({.command = SendspinControllerCommand::SHUFFLE}); +controller.send_command({.command = SendspinControllerCommand::UNSHUFFLE}); +controller.send_command({.command = SendspinControllerCommand::REPEAT_OFF}); +controller.send_command({.command = SendspinControllerCommand::REPEAT_ONE}); +controller.send_command({.command = SendspinControllerCommand::REPEAT_ALL}); + +// Commands that carry a parameter set the matching field: +controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75}); +controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = true}); +controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = false}); + +// Seek to an absolute position (0 to the controller state's seek_max_ms): +controller.send_command({.command = SendspinControllerCommand::SEEK, .position_ms = 30000}); + +// Seek by a signed offset from the current position (negative seeks backward): +controller.send_command({.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = -10000}); ``` +Fields that do not match the command are ignored when the message is serialized. The server clamps seeks to the seekable range and ignores any command not present in the controller state's `supported_commands`. + +> **Deprecated:** the earlier positional overload `send_command(cmd, volume, mute)` still works but cannot carry seek parameters and will be removed in v0.8.0. Migrate to the struct form above. + ## Accessing Roles In addition to the references returned by `add_*()`, you can access roles at any time through the client's accessor methods. These return `nullptr` if the role was not added. @@ -518,7 +528,7 @@ if (auto* p = client.player()) { p->update_volume(75); } if (auto* c = client.controller()) { - c->send_command(SendspinControllerCommand::NEXT); + c->send_command({.command = SendspinControllerCommand::NEXT}); } if (auto* m = client.metadata()) { uint32_t progress = m->get_track_progress_ms(); @@ -574,7 +584,7 @@ int32_t fixed = player.get_fixed_delay_us(); auto& stream = player.get_current_stream_params(); // Controller state -auto& ctrl = controller.get_controller_state(); // volume, muted, repeat, shuffle, supported_commands +auto& ctrl = controller.get_controller_state(); // volume, muted, repeat, shuffle, supported_commands, seek_max_ms // Metadata uint32_t progress = metadata.get_track_progress_ms(); // Interpolated @@ -820,14 +830,16 @@ Configuration passed to `client.add_visualizer()`. | `STOP` | Stop playback | | `NEXT` | Skip to next track | | `PREVIOUS` | Skip to previous track | -| `VOLUME` | Set volume (pass value via volume parameter) | -| `MUTE` | Set mute state (pass value via mute parameter) | +| `VOLUME` | Set volume (pass value via the `volume` field) | +| `MUTE` | Set mute state (pass value via the `muted` field) | | `REPEAT_OFF` | Disable repeat | | `REPEAT_ONE` | Repeat current track | | `REPEAT_ALL` | Repeat all tracks | | `SHUFFLE` | Enable shuffle | | `UNSHUFFLE` | Disable shuffle | | `SWITCH` | Switch source | +| `SEEK` | Seek to an absolute position (pass value via the `position_ms` field) | +| `SEEK_RELATIVE` | Seek by a signed offset from the current position (pass value via the `offset_ms` field) | ### SendspinPlayerCommand diff --git a/examples/tui_client/tui.cpp b/examples/tui_client/tui.cpp index 6d76e2e..62928e0 100644 --- a/examples/tui_client/tui.cpp +++ b/examples/tui_client/tui.cpp @@ -27,6 +27,14 @@ namespace sendspin { using namespace ftxui; +// How far a single Shift+Arrow relative-seek jumps, in milliseconds. +constexpr int32_t SEEK_STEP_MS = 10000; + +// Shift+Arrow key events. FTXUI predefines Ctrl+Arrow but not Shift+Arrow, so match the raw +// xterm-style CSI sequences directly (modifier 2 = Shift): ESC [ 1 ; 2 D/C for Left/Right. +static const Event kSeekBackEvent = Event::Special("\x1B[1;2D"); // Shift+Left +static const Event kSeekForwardEvent = Event::Special("\x1B[1;2C"); // Shift+Right + // Immutable snapshot of TuiState for rendering without holding the mutex. struct TuiSnapshot { std::string title; @@ -193,7 +201,9 @@ static Element render_now_playing(const TuiSnapshot& snap) { shortcut_label(snap, "Space", ""), text(" play/pause ") | color(Color::White) | dim, shortcut_label(snap, ">", "\u2192"), - text(" next") | color(Color::White) | dim, + text(" next ") | color(Color::White) | dim, + shortcut_label(snap, "Seek", "\u21e7\u2190/\u21e7\u2192"), + text(" seek") | color(Color::White) | dim, }), }); }; @@ -746,9 +756,9 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta set_highlight(state, "Space"); } if (current == SendspinPlaybackState::PLAYING) { - client.controller()->send_command(SendspinControllerCommand::PAUSE); + client.controller()->send_command({.command = SendspinControllerCommand::PAUSE}); } else { - client.controller()->send_command(SendspinControllerCommand::PLAY); + client.controller()->send_command({.command = SendspinControllerCommand::PLAY}); } return true; } @@ -759,7 +769,7 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta std::lock_guard lock(state.mutex); set_highlight(state, ">"); } - client.controller()->send_command(SendspinControllerCommand::NEXT); + client.controller()->send_command({.command = SendspinControllerCommand::NEXT}); return true; } @@ -769,7 +779,30 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta std::lock_guard lock(state.mutex); set_highlight(state, "<"); } - client.controller()->send_command(SendspinControllerCommand::PREVIOUS); + client.controller()->send_command({.command = SendspinControllerCommand::PREVIOUS}); + return true; + } + + // Seek backward (relative). Server clamps to the seekable range and ignores 'seek_relative' if + // it isn't in the controller's supported_commands. + if (event == kSeekBackEvent) { + { + std::lock_guard lock(state.mutex); + set_highlight(state, "Seek"); + } + client.controller()->send_command( + {.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = -SEEK_STEP_MS}); + return true; + } + + // Seek forward (relative) + if (event == kSeekForwardEvent) { + { + std::lock_guard lock(state.mutex); + set_highlight(state, "Seek"); + } + client.controller()->send_command( + {.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = SEEK_STEP_MS}); return true; } @@ -805,7 +838,8 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta } auto& cs = client.controller()->get_controller_state(); uint8_t new_vol = static_cast(std::min(100, cs.volume + 5)); - client.controller()->send_command(SendspinControllerCommand::VOLUME, new_vol); + client.controller()->send_command( + {.command = SendspinControllerCommand::VOLUME, .volume = new_vol}); return true; } @@ -817,7 +851,8 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta } auto& cs = client.controller()->get_controller_state(); uint8_t new_vol = static_cast(std::max(0, cs.volume - 5)); - client.controller()->send_command(SendspinControllerCommand::VOLUME, new_vol); + client.controller()->send_command( + {.command = SendspinControllerCommand::VOLUME, .volume = new_vol}); return true; } @@ -838,7 +873,8 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta set_highlight(state, "M"); } auto& cs = client.controller()->get_controller_state(); - client.controller()->send_command(SendspinControllerCommand::MUTE, std::nullopt, !cs.muted); + client.controller()->send_command( + {.command = SendspinControllerCommand::MUTE, .muted = !cs.muted}); return true; } @@ -852,13 +888,16 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta } switch (current) { case SendspinRepeatMode::OFF: - client.controller()->send_command(SendspinControllerCommand::REPEAT_ALL); + client.controller()->send_command( + {.command = SendspinControllerCommand::REPEAT_ALL}); break; case SendspinRepeatMode::ALL: - client.controller()->send_command(SendspinControllerCommand::REPEAT_ONE); + client.controller()->send_command( + {.command = SendspinControllerCommand::REPEAT_ONE}); break; case SendspinRepeatMode::ONE: - client.controller()->send_command(SendspinControllerCommand::REPEAT_OFF); + client.controller()->send_command( + {.command = SendspinControllerCommand::REPEAT_OFF}); break; } return true; @@ -872,8 +911,9 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta current = state.shuffle; set_highlight(state, "x"); } - client.controller()->send_command(current ? SendspinControllerCommand::UNSHUFFLE - : SendspinControllerCommand::SHUFFLE); + client.controller()->send_command({.command = current + ? SendspinControllerCommand::UNSHUFFLE + : SendspinControllerCommand::SHUFFLE}); return true; } @@ -883,7 +923,7 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta std::lock_guard lock(state.mutex); set_highlight(state, "g"); } - client.controller()->send_command(SendspinControllerCommand::SWITCH); + client.controller()->send_command({.command = SendspinControllerCommand::SWITCH}); return true; } diff --git a/include/sendspin/controller_role.h b/include/sendspin/controller_role.h index 42bc560..923e636 100644 --- a/include/sendspin/controller_role.h +++ b/include/sendspin/controller_role.h @@ -64,6 +64,30 @@ struct ServerStateControllerObject { bool muted{}; SendspinRepeatMode repeat{SendspinRepeatMode::OFF}; bool shuffle{}; + // Maximum absolute position (ms) a 'seek' may target. Present only when the server offers the + // 'seek' command and the seekable range is known; absent for live/unknown-duration streams. + std::optional seek_max_ms{}; +}; + +/// @brief A playback command sent from the client to the server via client/command messages. +/// +/// Construct with designated initializers, setting only the field the command uses: +/// @code +/// controller.send_command({.command = SendspinControllerCommand::PLAY}); +/// controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75}); +/// controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = true}); +/// controller.send_command({.command = SendspinControllerCommand::SEEK, .position_ms = 30000}); +/// controller.send_command({.command = SendspinControllerCommand::SEEK_RELATIVE, +/// .offset_ms = -10000}); +/// @endcode +/// Fields not relevant to the command are ignored when the message is serialized. +struct ClientCommandControllerObject { + SendspinControllerCommand command{}; + std::optional volume{}; // only for VOLUME (0-100) + std::optional muted{}; // only for MUTE + std::optional position_ms{}; // only for SEEK (0 to ServerStateControllerObject:: + // seek_max_ms) + std::optional offset_ms{}; // only for SEEK_RELATIVE (signed offset from current) }; /// @brief Listener for controller role events. All methods fire on the main loop thread. @@ -108,8 +132,8 @@ class ControllerRoleListener { * MyControllerListener listener; * auto& controller = client.add_controller(); * controller.set_listener(&listener); - * controller.send_command(SendspinControllerCommand::PLAY); - * controller.send_command(SendspinControllerCommand::VOLUME, 75); // volume range is 0-100 + * controller.send_command({.command = SendspinControllerCommand::PLAY}); + * controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75}); * @endcode */ class ControllerRole { @@ -131,6 +155,15 @@ class ControllerRole { void set_listener(ControllerRoleListener* listener); /// @brief Sends a controller command to the server + /// @param cmd The command plus any command-specific parameters + void send_command(const ClientCommandControllerObject& cmd); + + /// @brief Sends a controller command to the server + /// @deprecated Use send_command(const ClientCommandControllerObject&) instead. This overload + /// cannot carry seek parameters and will be removed in v0.8.0. + [[deprecated( + "use send_command(const ClientCommandControllerObject&); this overload cannot carry seek " + "parameters and will be removed in v0.8.0")]] void send_command(SendspinControllerCommand cmd, std::optional volume = {}, std::optional mute = {}); diff --git a/src/controller_role.cpp b/src/controller_role.cpp index 7816e9e..f11896f 100644 --- a/src/controller_role.cpp +++ b/src/controller_role.cpp @@ -43,9 +43,13 @@ void ControllerRole::set_listener(ControllerRoleListener* listener) { this->impl_->listener = listener; } +void ControllerRole::send_command(const ClientCommandControllerObject& cmd) { + this->impl_->send_command(cmd); +} + void ControllerRole::send_command(SendspinControllerCommand cmd, std::optional volume, std::optional mute) { - this->impl_->send_command(cmd, volume, mute); + this->impl_->send_command({.command = cmd, .volume = volume, .muted = mute}); } // ============================================================================ @@ -57,10 +61,8 @@ void ControllerRole::Impl::attach_inbox(Inbox& inbox) { this->event_state->slot.bind(inbox, INBOX_TOPIC_CONTROLLER); } -void ControllerRole::Impl::send_command(SendspinControllerCommand cmd, - std::optional volume, - std::optional mute) const { - std::string command_message = format_client_command_message(cmd, volume, mute); +void ControllerRole::Impl::send_command(const ClientCommandControllerObject& cmd) const { + std::string command_message = format_client_command_message(cmd); this->client->send_text(command_message); } diff --git a/src/controller_role_impl.h b/src/controller_role_impl.h index e24d539..9681cb2 100644 --- a/src/controller_role_impl.h +++ b/src/controller_role_impl.h @@ -59,8 +59,7 @@ struct ControllerRole::Impl { // Consumer-facing method implementations // ======================================== - void send_command(SendspinControllerCommand cmd, std::optional volume, - std::optional mute) const; + void send_command(const ClientCommandControllerObject& cmd) const; // ======================================== // Fields diff --git a/src/protocol.cpp b/src/protocol.cpp index 4af1429..6a5a7bb 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -594,6 +594,12 @@ bool process_server_state_message(JsonObject root, ServerStateMessage* state_msg controller_state.shuffle = *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; + } + state_msg->controller = std::move(controller_state); } @@ -1118,18 +1124,24 @@ size_t format_client_time_message(char* buf, size_t cap, int64_t client_transmit return static_cast(p - buf); } -std::string format_client_command_message(SendspinControllerCommand command, - std::optional volume, std::optional mute) { +std::string format_client_command_message(const ClientCommandControllerObject& cmd) { JsonDocument doc = make_json_document(); JsonObject root = doc.to(); root["type"] = "client/command"; - root["payload"]["controller"]["command"] = to_cstr(command); - if (command == SendspinControllerCommand::VOLUME && volume.has_value()) { - root["payload"]["controller"]["volume"] = volume.value(); + JsonObject controller = root["payload"]["controller"].to(); + controller["command"] = to_cstr(cmd.command); + if (cmd.command == SendspinControllerCommand::VOLUME && cmd.volume.has_value()) { + controller["volume"] = cmd.volume.value(); + } + if (cmd.command == SendspinControllerCommand::MUTE && cmd.muted.has_value()) { + controller["mute"] = cmd.muted.value(); + } + if (cmd.command == SendspinControllerCommand::SEEK && cmd.position_ms.has_value()) { + controller["position_ms"] = cmd.position_ms.value(); } - if (command == SendspinControllerCommand::MUTE && mute.has_value()) { - root["payload"]["controller"]["mute"] = mute.value(); + if (cmd.command == SendspinControllerCommand::SEEK_RELATIVE && cmd.offset_ms.has_value()) { + controller["offset_ms"] = cmd.offset_ms.value(); } std::string output; diff --git a/src/protocol_messages.h b/src/protocol_messages.h index 54cf76c..e02ccc9 100644 --- a/src/protocol_messages.h +++ b/src/protocol_messages.h @@ -409,13 +409,6 @@ inline std::optional repeat_mode_from_string(const std::stri return std::nullopt; } -/// @brief A playback command sent from the client to the server via client/command messages -struct ClientCommandControllerObject { - SendspinControllerCommand command{}; - std::optional volume; - std::optional mute; -}; - // --- artwork_role.h --- inline const char* to_cstr(SendspinImageFormat format) { @@ -634,11 +627,6 @@ struct ClientStateMessage { std::optional player{}; }; -/// @brief Outgoing client/command message carrying a playback command to the server -struct ClientCommandMessage { - std::optional controller; -}; - /// @brief Parsed server/state message containing per-role state updates struct ServerStateMessage { std::optional controller; @@ -800,12 +788,9 @@ static constexpr size_t TIME_MESSAGE_BUF_SIZE = 96; size_t format_client_time_message(char* buf, size_t cap, int64_t client_transmitted); /// @brief Formats a client/command message as a JSON string for sending to the server -/// @param command The playback command to send. -/// @param volume Optional volume level to include (0-100). -/// @param mute Optional mute state to include. +/// @param cmd The playback command plus any command-specific parameters. Only the parameter +/// relevant to the command is serialized (e.g. position_ms for SEEK); others are ignored. /// @return Command message serialized into JSON format. -std::string format_client_command_message(SendspinControllerCommand command, - std::optional volume = std::nullopt, - std::optional mute = std::nullopt); +std::string format_client_command_message(const ClientCommandControllerObject& cmd); } // namespace sendspin diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 2ca2973..94c6abf 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -482,6 +482,33 @@ TEST(Protocol, ControllerSupportedCommandsValidation) { EXPECT_EQ(commands[1], SendspinControllerCommand::MUTE); } +// seek_max_ms is parsed when the server includes it (the seekable upper bound for absolute seeks). +TEST(Protocol, ControllerSeekMaxParsed) { + JsonDocument doc; + JsonObject root; + 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); +} + +// seek_max_ms stays absent (nullopt) when omitted, so consumers can tell "unknown range" from 0. +TEST(Protocol, ControllerSeekMaxAbsentWhenOmitted) { + JsonDocument doc; + JsonObject root; + 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()); +} + // ============================================================================ // format_client_time_message: hand-rolled int64 formatter checked against snprintf // ============================================================================ @@ -531,7 +558,8 @@ TEST(Protocol, FormatTimeMessageRejectsTooSmallBuffer) { // ============================================================================ TEST(Protocol, FormatClientCommandVolume) { - const std::string out = format_client_command_message(SendspinControllerCommand::VOLUME, 50); + const std::string out = format_client_command_message( + {.command = SendspinControllerCommand::VOLUME, .volume = 50}); JsonDocument doc; ASSERT_FALSE(deserializeJson(doc, out)); @@ -545,7 +573,7 @@ TEST(Protocol, FormatClientCommandVolume) { // MUTE carries a boolean payload (a separate branch from VOLUME's uint8_t). TEST(Protocol, FormatClientCommandMute) { const std::string out = - format_client_command_message(SendspinControllerCommand::MUTE, std::nullopt, true); + format_client_command_message({.command = SendspinControllerCommand::MUTE, .muted = true}); JsonDocument doc; ASSERT_FALSE(deserializeJson(doc, out)); @@ -555,9 +583,49 @@ TEST(Protocol, FormatClientCommandMute) { EXPECT_FALSE(doc["payload"]["controller"]["volume"].is()); } -// A no-argument command (PLAY) emits just the command, with neither payload field present. +// SEEK carries an absolute position_ms; unrelated payload fields must not leak in. +TEST(Protocol, FormatClientCommandSeek) { + const std::string out = format_client_command_message( + {.command = SendspinControllerCommand::SEEK, .position_ms = 30000}); + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, out)); + EXPECT_STREQ(doc["payload"]["controller"]["command"], "seek"); + ASSERT_TRUE(doc["payload"]["controller"]["position_ms"].is()); + EXPECT_EQ(doc["payload"]["controller"]["position_ms"].as(), 30000u); + EXPECT_FALSE(doc["payload"]["controller"]["offset_ms"].is()); + EXPECT_FALSE(doc["payload"]["controller"]["volume"].is()); +} + +// SEEK_RELATIVE carries a signed offset_ms (negative offsets seek backward). +TEST(Protocol, FormatClientCommandSeekRelative) { + const std::string out = format_client_command_message( + {.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = -10000}); + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, out)); + EXPECT_STREQ(doc["payload"]["controller"]["command"], "seek_relative"); + ASSERT_TRUE(doc["payload"]["controller"]["offset_ms"].is()); + EXPECT_EQ(doc["payload"]["controller"]["offset_ms"].as(), -10000); + EXPECT_FALSE(doc["payload"]["controller"]["position_ms"].is()); +} + +// A parameter that does not match the command is dropped at serialization (position_ms on VOLUME). +TEST(Protocol, FormatClientCommandDropsMismatchedParam) { + const std::string out = format_client_command_message( + {.command = SendspinControllerCommand::VOLUME, .volume = 40, .position_ms = 99999}); + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, out)); + EXPECT_STREQ(doc["payload"]["controller"]["command"], "volume"); + EXPECT_EQ(doc["payload"]["controller"]["volume"].as(), 40); + EXPECT_FALSE(doc["payload"]["controller"]["position_ms"].is()); +} + +// A no-argument command (PLAY) emits just the command, with no payload fields present. TEST(Protocol, FormatClientCommandNoArgs) { - const std::string out = format_client_command_message(SendspinControllerCommand::PLAY); + const std::string out = + format_client_command_message({.command = SendspinControllerCommand::PLAY}); JsonDocument doc; ASSERT_FALSE(deserializeJson(doc, out));