feat(qconnect-protocol): add Android controller websocket messages

cx
This commit is contained in:
vicrodh committed 2026-02-21 13:04:17 -06:00
1 parent a0790f9a1d
commit 3e91c2f8b0
3 files changed
+424 -4

No files matched your search

+16
View File
@@ -4,6 +4,14 @@ use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum QueueCommandType {
CtrlSrvrJoinSession,
CtrlSrvrSetPlayerState,
CtrlSrvrSetActiveRenderer,
CtrlSrvrSetVolume,
CtrlSrvrSetLoopMode,
CtrlSrvrMuteVolume,
CtrlSrvrSetMaxAudioQuality,
CtrlSrvrAskForRendererState,
CtrlSrvrQueueAddTracks,
CtrlSrvrQueueLoadTracks,
CtrlSrvrQueueInsertTracks,
@@ -21,6 +29,14 @@ pub enum QueueCommandType {
impl QueueCommandType {
pub const fn as_message_type(self) -> &'static str {
match self {
Self::CtrlSrvrJoinSession => "MESSAGE_TYPE_CTRL_SRVR_JOIN_SESSION",
Self::CtrlSrvrSetPlayerState => "MESSAGE_TYPE_CTRL_SRVR_SET_PLAYER_STATE",
Self::CtrlSrvrSetActiveRenderer => "MESSAGE_TYPE_CTRL_SRVR_SET_ACTIVE_RENDERER",
Self::CtrlSrvrSetVolume => "MESSAGE_TYPE_CTRL_SRVR_SET_VOLUME",
Self::CtrlSrvrSetLoopMode => "MESSAGE_TYPE_CTRL_SRVR_SET_LOOP_MODE",
Self::CtrlSrvrMuteVolume => "MESSAGE_TYPE_CTRL_SRVR_MUTE_VOLUME",
Self::CtrlSrvrSetMaxAudioQuality => "MESSAGE_TYPE_CTRL_SRVR_SET_MAX_AUDIO_QUALITY",
Self::CtrlSrvrAskForRendererState => "MESSAGE_TYPE_CTRL_SRVR_ASK_FOR_RENDERER_STATE",
Self::CtrlSrvrQueueAddTracks => "MESSAGE_TYPE_CTRL_SRVR_QUEUE_ADD_TRACKS",
Self::CtrlSrvrQueueLoadTracks => "MESSAGE_TYPE_CTRL_SRVR_QUEUE_LOAD_TRACKS",
Self::CtrlSrvrQueueInsertTracks => "MESSAGE_TYPE_CTRL_SRVR_QUEUE_INSERT_TRACKS",
+296 -4
View File
@@ -10,14 +10,17 @@ use uuid::Uuid;
use crate::{
build_outbound_envelope,
queue_command_proto::{
AskForQueueStateMessage, AutoplayLoadTracksMessage, AutoplayRemoveTracksMessage,
ClearQueueMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType,
AskForQueueStateMessage, AskForRendererStateMessage, AutoplayLoadTracksMessage,
AutoplayRemoveTracksMessage, ClearQueueMessage, DeviceInfoMessage, JoinSessionMessage,
MuteVolumeMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType,
QConnectMessages, QueueAddTracksMessage, QueueInsertTracksMessage, QueueLoadTracksMessage,
QueueRemoveTracksMessage, QueueReorderTracksMessage, QueueVersionRef,
RendererFileAudioQualityChangedMessage, RendererMaxAudioQualityChangedMessage,
RendererStateMessage, RendererStateUpdatedMessage, RendererVolumeChangedMessage,
RendererVolumeMutedMessage, SetAutoplayModeMessage, SetQueueStateMessage,
SetQueueTrackWithContext, SetShuffleModeMessage,
RendererVolumeMutedMessage, SetActiveRendererMessage, SetAutoplayModeMessage,
SetLoopModeMessage, SetMaxAudioQualityMessage, SetPlayerStateMessage,
SetPlayerStateQueueItemMessage, SetQueueStateMessage, SetQueueTrackWithContext,
SetShuffleModeMessage, SetVolumeMessage,
},
OutboundEnvelope, ProtocolError, QueueCommand, QueueCommandType, RendererReport,
RendererReportType,
@@ -75,6 +78,81 @@ fn map_queue_command(command: &QueueCommand) -> Result<QConnectMessage, Protocol
let action_uuid = Some(action_uuid_bytes(&command.action_uuid)?);
match command.command_type {
QueueCommandType::CtrlSrvrJoinSession => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrJoinSession as i32),
ctrl_srvr_join_session: Some(JoinSessionMessage {
session_uuid: optional_uuid_bytes(
&command.payload,
&["session_uuid", "sessionUuid"],
)?,
device_info: parse_device_info(&command.payload)?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrSetPlayerState => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetPlayerState as i32),
ctrl_srvr_set_player_state: Some(SetPlayerStateMessage {
playing_state: optional_i32_any(
&command.payload,
&["playing_state", "playingState"],
)?,
current_position: optional_i32_any(
&command.payload,
&["current_position", "currentPosition"],
)?,
current_queue_item: parse_set_player_state_queue_item(&command.payload)?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrSetActiveRenderer => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetActiveRenderer as i32),
ctrl_srvr_set_active_renderer: Some(SetActiveRendererMessage {
renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrSetVolume => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetVolume as i32),
ctrl_srvr_set_volume: Some(SetVolumeMessage {
renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?,
volume: optional_i32_any(&command.payload, &["volume"])?,
volume_delta: optional_i32_any(&command.payload, &["volume_delta", "volumeDelta"])?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrSetLoopMode => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetLoopMode as i32),
ctrl_srvr_set_loop_mode: Some(SetLoopModeMessage {
loop_mode: optional_i32_any(&command.payload, &["loop_mode", "loopMode"])?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrMuteVolume => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrMuteVolume as i32),
ctrl_srvr_mute_volume: Some(MuteVolumeMessage {
renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?,
value: Some(optional_bool_any(&command.payload, &["value"], false)),
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrSetMaxAudioQuality => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetMaxAudioQuality as i32),
ctrl_srvr_set_max_audio_quality: Some(SetMaxAudioQualityMessage {
renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?,
max_audio_quality: optional_i32_any(
&command.payload,
&["max_audio_quality", "maxAudioQuality"],
)?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrAskForRendererState => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrAskForRendererState as i32),
ctrl_srvr_ask_for_renderer_state: Some(AskForRendererStateMessage {
renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?,
}),
..Default::default()
}),
QueueCommandType::CtrlSrvrQueueAddTracks => {
let track_ids = required_i32_list(&command.payload, "track_ids")?;
let shuffle_seed = optional_i32(&command.payload, "shuffle_seed")?;
@@ -530,10 +608,136 @@ fn optional_i32(payload: &Value, key: &str) -> Result<Option<i32>, ProtocolError
}
}
fn optional_i32_any(payload: &Value, keys: &[&str]) -> Result<Option<i32>, ProtocolError> {
for key in keys {
if payload.get(*key).is_some() {
return optional_i32(payload, key);
}
}
Ok(None)
}
fn optional_bool(payload: &Value, key: &str, default: bool) -> bool {
payload.get(key).and_then(Value::as_bool).unwrap_or(default)
}
fn optional_bool_any(payload: &Value, keys: &[&str], default: bool) -> bool {
for key in keys {
let Some(value) = payload.get(*key) else {
continue;
};
if let Some(as_bool) = value.as_bool() {
return as_bool;
}
if let Some(as_int) = value.as_i64() {
return as_int != 0;
}
if let Some(as_uint) = value.as_u64() {
return as_uint != 0;
}
}
default
}
fn optional_string_any(payload: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| payload.get(*key).and_then(Value::as_str))
.map(ToString::to_string)
}
fn parse_queue_version_ref(payload: &Value, key: &str) -> Option<QueueVersionRef> {
let version = payload.get(key)?;
let major = version
.get("major")
.and_then(Value::as_u64)
.and_then(|value| i32::try_from(value).ok())?;
let minor = version
.get("minor")
.and_then(Value::as_u64)
.and_then(|value| i32::try_from(value).ok())?;
Some(QueueVersionRef {
major: Some(major),
minor: Some(minor),
})
}
fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, ProtocolError> {
let nested = payload
.get("device_info")
.or_else(|| payload.get("deviceInfo"));
let source = match nested {
Some(value) => value,
None => payload,
};
if !source.is_object() {
return Err(ProtocolError::InvalidPayload(
"field 'device_info' must be an object".to_string(),
));
}
let device_uuid = optional_uuid_bytes(source, &["device_uuid", "deviceUuid", "uuid"])?;
let friendly_name = optional_string_any(source, &["friendly_name", "friendlyName"]);
let brand = optional_string_any(source, &["brand"]);
let model = optional_string_any(source, &["model"]);
let serial_number = optional_string_any(source, &["serial_number", "serialNumber"]);
let device_type = optional_i32_any(source, &["device_type", "deviceType"])?;
let software_version = optional_string_any(source, &["software_version", "softwareVersion"]);
if device_uuid.is_none()
&& friendly_name.is_none()
&& brand.is_none()
&& model.is_none()
&& serial_number.is_none()
&& device_type.is_none()
&& software_version.is_none()
{
return Ok(None);
}
Ok(Some(DeviceInfoMessage {
device_uuid,
friendly_name,
brand,
model,
serial_number,
device_type,
software_version,
}))
}
fn parse_set_player_state_queue_item(
payload: &Value,
) -> Result<Option<SetPlayerStateQueueItemMessage>, ProtocolError> {
let nested = payload
.get("current_queue_item")
.or_else(|| payload.get("currentQueueItem"));
let source = match nested {
Some(value) => value,
None => payload,
};
if !source.is_object() {
return Err(ProtocolError::InvalidPayload(
"field 'current_queue_item' must be an object".to_string(),
));
}
let queue_version = parse_queue_version_ref(source, "queue_version")
.or_else(|| parse_queue_version_ref(source, "queueVersion"));
let id = optional_i32_any(source, &["id", "queue_item_id", "queueItemId"])?;
if queue_version.is_none() && id.is_none() {
return Ok(None);
}
Ok(Some(SetPlayerStateQueueItemMessage { queue_version, id }))
}
fn parse_uuid(value: &str) -> Result<Vec<u8>, ProtocolError> {
let parsed = Uuid::parse_str(value)
.map_err(|err| ProtocolError::InvalidUuid(format!("'{value}' parse error: {err}")))?;
@@ -615,6 +819,94 @@ mod tests {
assert!(!payload.is_empty());
}
#[test]
fn encodes_join_session_message_with_device_info() {
let command = QueueCommand::new(
QueueCommandType::CtrlSrvrJoinSession,
"a043f34c-15f5-44bb-a5ad-06ce4e0c009d",
QueueVersion::new(1, 0),
json!({
"session_uuid": "a3f7be0f-3854-44c6-9194-c93cedec322a",
"device_info": {
"device_uuid": "3c2da4ec-f4b9-4600-9177-7f8a44ce69f4",
"friendly_name": "QBZ Desktop",
"brand": "QBZ",
"model": "Linux",
"serial_number": "qbz-dev-1",
"device_type": 6,
"software_version": "0.1.0"
}
}),
);
let payload = encode_queue_command_batch(&command).expect("batch payload");
let decoded = QConnectMessages::decode(payload.as_slice()).expect("decode batch");
assert_eq!(decoded.messages.len(), 1);
let message = &decoded.messages[0];
assert_eq!(
message.message_type,
Some(QConnectMessageType::MessageTypeCtrlSrvrJoinSession as i32)
);
let join = message
.ctrl_srvr_join_session
.as_ref()
.expect("join payload");
assert!(join.session_uuid.is_some());
let device = join.device_info.as_ref().expect("device_info payload");
assert_eq!(device.friendly_name.as_deref(), Some("QBZ Desktop"));
assert_eq!(device.device_type, Some(6));
}
#[test]
fn encodes_set_player_state_with_current_queue_item() {
let command = QueueCommand::new(
QueueCommandType::CtrlSrvrSetPlayerState,
"132db70b-d293-4576-bfd6-747c40ab764f",
QueueVersion::new(5, 3),
json!({
"playing_state": 2,
"current_position": 42123,
"current_queue_item": {
"queue_version": {
"major": 5,
"minor": 3
},
"id": 901
}
}),
);
let payload = encode_queue_command_batch(&command).expect("batch payload");
let decoded = QConnectMessages::decode(payload.as_slice()).expect("decode batch");
assert_eq!(decoded.messages.len(), 1);
let message = &decoded.messages[0];
assert_eq!(
message.message_type,
Some(QConnectMessageType::MessageTypeCtrlSrvrSetPlayerState as i32)
);
let player_state = message
.ctrl_srvr_set_player_state
.as_ref()
.expect("player state payload");
assert_eq!(player_state.playing_state, Some(2));
assert_eq!(player_state.current_position, Some(42_123));
let queue_item = player_state
.current_queue_item
.as_ref()
.expect("current queue item payload");
assert_eq!(queue_item.id, Some(901));
assert_eq!(
queue_item.queue_version.as_ref().and_then(|v| v.major),
Some(5)
);
assert_eq!(
queue_item.queue_version.as_ref().and_then(|v| v.minor),
Some(3)
);
}
#[test]
fn encodes_renderer_state_updated_report_with_queue_version() {
let report = RendererReport::new(
@@ -17,6 +17,10 @@ pub enum QConnectMessageType {
MessageTypeSrvrRndrSetLoopMode = 45,
MessageTypeSrvrRndrSetShuffleMode = 46,
MessageTypeSrvrRndrMuteVolume = 47,
MessageTypeCtrlSrvrJoinSession = 61,
MessageTypeCtrlSrvrSetPlayerState = 62,
MessageTypeCtrlSrvrSetActiveRenderer = 63,
MessageTypeCtrlSrvrSetVolume = 64,
MessageTypeCtrlSrvrClearQueue = 65,
MessageTypeCtrlSrvrQueueLoadTracks = 66,
MessageTypeCtrlSrvrQueueInsertTracks = 67,
@@ -24,8 +28,12 @@ pub enum QConnectMessageType {
MessageTypeCtrlSrvrQueueRemoveTracks = 69,
MessageTypeCtrlSrvrQueueReorderTracks = 70,
MessageTypeCtrlSrvrSetShuffleMode = 71,
MessageTypeCtrlSrvrSetLoopMode = 72,
MessageTypeCtrlSrvrMuteVolume = 73,
MessageTypeCtrlSrvrSetMaxAudioQuality = 74,
MessageTypeCtrlSrvrSetQueueState = 75,
MessageTypeCtrlSrvrAskForQueueState = 76,
MessageTypeCtrlSrvrAskForRendererState = 77,
MessageTypeCtrlSrvrSetAutoplayMode = 78,
MessageTypeCtrlSrvrAutoplayLoadTracks = 79,
MessageTypeCtrlSrvrAutoplayRemoveTracks = 80,
@@ -86,6 +94,94 @@ pub struct SetQueueTrackWithContext {
pub context_uuid: Option<Vec<u8>>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DeviceInfoMessage {
#[prost(bytes = "vec", optional, tag = "1")]
pub device_uuid: Option<Vec<u8>>,
#[prost(string, optional, tag = "2")]
pub friendly_name: Option<String>,
#[prost(string, optional, tag = "3")]
pub brand: Option<String>,
#[prost(string, optional, tag = "4")]
pub model: Option<String>,
#[prost(string, optional, tag = "5")]
pub serial_number: Option<String>,
#[prost(int32, optional, tag = "6")]
pub device_type: Option<i32>,
#[prost(string, optional, tag = "8")]
pub software_version: Option<String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct JoinSessionMessage {
#[prost(bytes = "vec", optional, tag = "1")]
pub session_uuid: Option<Vec<u8>>,
#[prost(message, optional, tag = "2")]
pub device_info: Option<DeviceInfoMessage>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetPlayerStateQueueItemMessage {
#[prost(message, optional, tag = "1")]
pub queue_version: Option<QueueVersionRef>,
#[prost(int32, optional, tag = "2")]
pub id: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetPlayerStateMessage {
#[prost(int32, optional, tag = "1")]
pub playing_state: Option<i32>,
#[prost(int32, optional, tag = "2")]
pub current_position: Option<i32>,
#[prost(message, optional, tag = "3")]
pub current_queue_item: Option<SetPlayerStateQueueItemMessage>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetActiveRendererMessage {
#[prost(int32, optional, tag = "1")]
pub renderer_id: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetVolumeMessage {
#[prost(int32, optional, tag = "1")]
pub renderer_id: Option<i32>,
#[prost(int32, optional, tag = "2")]
pub volume: Option<i32>,
#[prost(int32, optional, tag = "3")]
pub volume_delta: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetLoopModeMessage {
#[prost(int32, optional, tag = "1")]
pub loop_mode: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MuteVolumeMessage {
#[prost(int32, optional, tag = "1")]
pub renderer_id: Option<i32>,
#[prost(bool, optional, tag = "2")]
pub value: Option<bool>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetMaxAudioQualityMessage {
#[prost(int32, optional, tag = "1")]
pub renderer_id: Option<i32>,
#[prost(int32, optional, tag = "2")]
pub max_audio_quality: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct AskForRendererStateMessage {
#[prost(int32, optional, tag = "1")]
pub renderer_id: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ClearQueueMessage {
#[prost(message, optional, tag = "1")]
@@ -630,6 +726,14 @@ pub struct QConnectMessage {
pub srvr_rndr_set_shuffle_mode: Option<RendererSetShuffleModeMessage>,
#[prost(message, optional, tag = "47")]
pub srvr_rndr_mute_volume: Option<RendererMuteVolumeMessage>,
#[prost(message, optional, tag = "61")]
pub ctrl_srvr_join_session: Option<JoinSessionMessage>,
#[prost(message, optional, tag = "62")]
pub ctrl_srvr_set_player_state: Option<SetPlayerStateMessage>,
#[prost(message, optional, tag = "63")]
pub ctrl_srvr_set_active_renderer: Option<SetActiveRendererMessage>,
#[prost(message, optional, tag = "64")]
pub ctrl_srvr_set_volume: Option<SetVolumeMessage>,
#[prost(message, optional, tag = "65")]
pub ctrl_srvr_clear_queue: Option<ClearQueueMessage>,
#[prost(message, optional, tag = "66")]
@@ -644,10 +748,18 @@ pub struct QConnectMessage {
pub ctrl_srvr_queue_reorder_tracks: Option<QueueReorderTracksMessage>,
#[prost(message, optional, tag = "71")]
pub ctrl_srvr_set_shuffle_mode: Option<SetShuffleModeMessage>,
#[prost(message, optional, tag = "72")]
pub ctrl_srvr_set_loop_mode: Option<SetLoopModeMessage>,
#[prost(message, optional, tag = "73")]
pub ctrl_srvr_mute_volume: Option<MuteVolumeMessage>,
#[prost(message, optional, tag = "74")]
pub ctrl_srvr_set_max_audio_quality: Option<SetMaxAudioQualityMessage>,
#[prost(message, optional, tag = "75")]
pub ctrl_srvr_set_queue_state: Option<SetQueueStateMessage>,
#[prost(message, optional, tag = "76")]
pub ctrl_srvr_ask_for_queue_state: Option<AskForQueueStateMessage>,
#[prost(message, optional, tag = "77")]
pub ctrl_srvr_ask_for_renderer_state: Option<AskForRendererStateMessage>,
#[prost(message, optional, tag = "78")]
pub ctrl_srvr_set_autoplay_mode: Option<SetAutoplayModeMessage>,
#[prost(message, optional, tag = "79")]