fix(qconnect): add device capabilities, initial state, and channel subscriptions

- Add DeviceCapabilitiesMessage to protobuf (tag 7 on DeviceInfo) with
  volume_remote_control=ALLOWED, min/max audio quality
- Extend JoinSessionMessage with reason, initial_state, is_active fields
  matching Android renderer proto (tags 3-5)
- Bootstrap sends renderer JoinSession with is_active=true and initial
  RendererState (STOPPED) so other devices see QBZ immediately
- Send initial StateUpdated, VolumeChanged, MaxAudioQualityChanged
  reports after join for full renderer presence
- Default subscribe channels to 0x01,0x02,0x03 instead of empty
- Request queue state from server after bootstrap

cc
This commit is contained in:
vicrodh committed 2026-02-21 15:11:25 -06:00
1 parent 1fdfc4eb9d
commit b160773e50
3 files changed
+232 -26

No files matched your search

+113 -18
View File
@@ -11,16 +11,16 @@ use crate::{
build_outbound_envelope, build_outbound_envelope,
queue_command_proto::{ queue_command_proto::{
AskForQueueStateMessage, AskForRendererStateMessage, AutoplayLoadTracksMessage, AskForQueueStateMessage, AskForRendererStateMessage, AutoplayLoadTracksMessage,
AutoplayRemoveTracksMessage, ClearQueueMessage, DeviceInfoMessage, JoinSessionMessage, AutoplayRemoveTracksMessage, ClearQueueMessage, DeviceCapabilitiesMessage,
MuteVolumeMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType, DeviceInfoMessage, JoinSessionMessage, MuteVolumeMessage, PlaybackPositionMessage,
QConnectMessages, QueueAddTracksMessage, QueueInsertTracksMessage, QueueLoadTracksMessage, QConnectMessage, QConnectMessageType, QConnectMessages, QueueAddTracksMessage,
QueueRemoveTracksMessage, QueueReorderTracksMessage, QueueVersionRef, QueueInsertTracksMessage, QueueLoadTracksMessage, QueueRemoveTracksMessage,
RendererFileAudioQualityChangedMessage, RendererMaxAudioQualityChangedMessage, QueueReorderTracksMessage, QueueVersionRef, RendererFileAudioQualityChangedMessage,
RendererStateMessage, RendererStateUpdatedMessage, RendererVolumeChangedMessage, RendererMaxAudioQualityChangedMessage, RendererStateMessage, RendererStateUpdatedMessage,
RendererVolumeMutedMessage, SetActiveRendererMessage, SetAutoplayModeMessage, RendererVolumeChangedMessage, RendererVolumeMutedMessage, SetActiveRendererMessage,
SetLoopModeMessage, SetMaxAudioQualityMessage, SetPlayerStateMessage, SetAutoplayModeMessage, SetLoopModeMessage, SetMaxAudioQualityMessage,
SetPlayerStateQueueItemMessage, SetQueueStateMessage, SetQueueTrackWithContext, SetPlayerStateMessage, SetPlayerStateQueueItemMessage, SetQueueStateMessage,
SetShuffleModeMessage, SetVolumeMessage, SetQueueTrackWithContext, SetShuffleModeMessage, SetVolumeMessage,
}, },
OutboundEnvelope, ProtocolError, QueueCommand, QueueCommandType, RendererReport, OutboundEnvelope, ProtocolError, QueueCommand, QueueCommandType, RendererReport,
RendererReportType, RendererReportType,
@@ -86,6 +86,10 @@ fn map_queue_command(command: &QueueCommand) -> Result<QConnectMessage, Protocol
&["session_uuid", "sessionUuid"], &["session_uuid", "sessionUuid"],
)?, )?,
device_info: parse_device_info(&command.payload)?, device_info: parse_device_info(&command.payload)?,
// Controller JoinSession doesn't use renderer-specific fields
reason: None,
initial_state: None,
is_active: None,
}), }),
..Default::default() ..Default::default()
}), }),
@@ -390,14 +394,26 @@ fn map_queue_command(command: &QueueCommand) -> Result<QConnectMessage, Protocol
fn map_renderer_report(report: &RendererReport) -> Result<QConnectMessage, ProtocolError> { fn map_renderer_report(report: &RendererReport) -> Result<QConnectMessage, ProtocolError> {
match report.report_type { match report.report_type {
RendererReportType::RndrSrvrJoinSession => Ok(QConnectMessage { RendererReportType::RndrSrvrJoinSession => {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrJoinSession as i32), let initial_state = parse_renderer_initial_state(&report.payload)?;
rndr_srvr_join_session: Some(JoinSessionMessage { let is_active = Some(optional_bool(&report.payload, "is_active", false));
session_uuid: optional_uuid_bytes(&report.payload, &["session_uuid", "sessionUuid"])?, let reason = optional_i32(&report.payload, "reason")?;
device_info: parse_device_info(&report.payload)?,
}), Ok(QConnectMessage {
..Default::default() message_type: Some(QConnectMessageType::MessageTypeRndrSrvrJoinSession as i32),
}), rndr_srvr_join_session: Some(JoinSessionMessage {
session_uuid: optional_uuid_bytes(
&report.payload,
&["session_uuid", "sessionUuid"],
)?,
device_info: parse_device_info(&report.payload)?,
reason,
initial_state,
is_active,
}),
..Default::default()
})
}
RendererReportType::RndrSrvrDeviceInfoUpdated => Ok(QConnectMessage { RendererReportType::RndrSrvrDeviceInfoUpdated => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrDeviceInfoUpdated as i32), message_type: Some(QConnectMessageType::MessageTypeRndrSrvrDeviceInfoUpdated as i32),
rndr_srvr_device_info_updated: parse_device_info(&report.payload)?, rndr_srvr_device_info_updated: parse_device_info(&report.payload)?,
@@ -700,6 +716,7 @@ fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, Proto
let serial_number = optional_string_any(source, &["serial_number", "serialNumber"]); let serial_number = optional_string_any(source, &["serial_number", "serialNumber"]);
let device_type = optional_i32_any(source, &["device_type", "deviceType"])?; let device_type = optional_i32_any(source, &["device_type", "deviceType"])?;
let software_version = optional_string_any(source, &["software_version", "softwareVersion"]); let software_version = optional_string_any(source, &["software_version", "softwareVersion"]);
let capabilities = parse_device_capabilities(source)?;
if device_uuid.is_none() if device_uuid.is_none()
&& friendly_name.is_none() && friendly_name.is_none()
@@ -708,6 +725,7 @@ fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, Proto
&& serial_number.is_none() && serial_number.is_none()
&& device_type.is_none() && device_type.is_none()
&& software_version.is_none() && software_version.is_none()
&& capabilities.is_none()
{ {
return Ok(None); return Ok(None);
} }
@@ -719,10 +737,87 @@ fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, Proto
model, model,
serial_number, serial_number,
device_type, device_type,
capabilities,
software_version, software_version,
})) }))
} }
fn parse_device_capabilities(
source: &Value,
) -> Result<Option<DeviceCapabilitiesMessage>, ProtocolError> {
let nested = source
.get("capabilities")
.or_else(|| source.get("deviceCapabilities"));
if let Some(caps) = nested {
if caps.is_object() {
return Ok(Some(DeviceCapabilitiesMessage {
min_audio_quality: optional_i32_any(
caps,
&["min_audio_quality", "minAudioQuality"],
)?,
max_audio_quality: optional_i32_any(
caps,
&["max_audio_quality", "maxAudioQuality"],
)?,
volume_remote_control: optional_i32_any(
caps,
&["volume_remote_control", "volumeRemoteControl"],
)?,
}));
}
}
// Check flat fields (capabilities declared directly on device_info payload)
let min_aq = optional_i32_any(source, &["min_audio_quality", "minAudioQuality"])?;
let max_aq = optional_i32_any(source, &["max_audio_quality", "maxAudioQuality"])?;
let vol_rc = optional_i32_any(source, &["volume_remote_control", "volumeRemoteControl"])?;
if min_aq.is_some() || max_aq.is_some() || vol_rc.is_some() {
return Ok(Some(DeviceCapabilitiesMessage {
min_audio_quality: min_aq,
max_audio_quality: max_aq,
volume_remote_control: vol_rc,
}));
}
Ok(None)
}
fn parse_renderer_initial_state(
payload: &Value,
) -> Result<Option<RendererStateMessage>, ProtocolError> {
let nested = payload
.get("initial_state")
.or_else(|| payload.get("initialState"));
let Some(source) = nested else {
return Ok(None);
};
if !source.is_object() {
return Ok(None);
}
let current_position = optional_i32(source, "current_position")?;
let playback_position = current_position.map(|value| PlaybackPositionMessage {
timestamp: Some(now_ms()),
value: Some(value),
});
Ok(Some(RendererStateMessage {
playing_state: optional_i32(source, "playing_state")?,
buffer_state: optional_i32(source, "buffer_state")?,
current_position: playback_position,
duration: optional_i32(source, "duration")?,
queue_version: optional_queue_version(source, "queue_version")?
.map(|v| to_proto_queue_version(v))
.transpose()?,
current_queue_item_id: optional_i32(source, "current_queue_item_id")?,
next_queue_item_id: optional_i32(source, "next_queue_item_id")?,
}))
}
fn parse_set_player_state_queue_item( fn parse_set_player_state_queue_item(
payload: &Value, payload: &Value,
) -> Result<Option<SetPlayerStateQueueItemMessage>, ProtocolError> { ) -> Result<Option<SetPlayerStateQueueItemMessage>, ProtocolError> {
@@ -94,6 +94,16 @@ pub struct SetQueueTrackWithContext {
pub context_uuid: Option<Vec<u8>>, pub context_uuid: Option<Vec<u8>>,
} }
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DeviceCapabilitiesMessage {
#[prost(int32, optional, tag = "1")]
pub min_audio_quality: Option<i32>,
#[prost(int32, optional, tag = "2")]
pub max_audio_quality: Option<i32>,
#[prost(int32, optional, tag = "3")]
pub volume_remote_control: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)] #[derive(Clone, PartialEq, ::prost::Message)]
pub struct DeviceInfoMessage { pub struct DeviceInfoMessage {
#[prost(bytes = "vec", optional, tag = "1")] #[prost(bytes = "vec", optional, tag = "1")]
@@ -108,6 +118,8 @@ pub struct DeviceInfoMessage {
pub serial_number: Option<String>, pub serial_number: Option<String>,
#[prost(int32, optional, tag = "6")] #[prost(int32, optional, tag = "6")]
pub device_type: Option<i32>, pub device_type: Option<i32>,
#[prost(message, optional, tag = "7")]
pub capabilities: Option<DeviceCapabilitiesMessage>,
#[prost(string, optional, tag = "8")] #[prost(string, optional, tag = "8")]
pub software_version: Option<String>, pub software_version: Option<String>,
} }
@@ -118,6 +130,15 @@ pub struct JoinSessionMessage {
pub session_uuid: Option<Vec<u8>>, pub session_uuid: Option<Vec<u8>>,
#[prost(message, optional, tag = "2")] #[prost(message, optional, tag = "2")]
pub device_info: Option<DeviceInfoMessage>, pub device_info: Option<DeviceInfoMessage>,
/// Renderer-only: reason for joining (0=unknown, 1=controller_request, 2=reconnection)
#[prost(int32, optional, tag = "3")]
pub reason: Option<i32>,
/// Renderer-only: initial playback state sent on join
#[prost(message, optional, tag = "4")]
pub initial_state: Option<RendererStateMessage>,
/// Renderer-only: whether this renderer is active
#[prost(bool, optional, tag = "5")]
pub is_active: Option<bool>,
} }
#[derive(Clone, PartialEq, ::prost::Message)] #[derive(Clone, PartialEq, ::prost::Message)]
+98 -8
View File
@@ -28,14 +28,22 @@ const PLAYING_STATE_UNKNOWN: i32 = 0;
const PLAYING_STATE_STOPPED: i32 = 1; const PLAYING_STATE_STOPPED: i32 = 1;
const PLAYING_STATE_PLAYING: i32 = 2; const PLAYING_STATE_PLAYING: i32 = 2;
const PLAYING_STATE_PAUSED: i32 = 3; const PLAYING_STATE_PAUSED: i32 = 3;
const BUFFER_STATE_OK: i32 = 2;
const QCONNECT_QWS_TOKEN_KIND: &str = "jwt_qws"; const QCONNECT_QWS_TOKEN_KIND: &str = "jwt_qws";
const QCONNECT_QWS_CREATE_TOKEN_PATH: &str = "/qws/createToken"; const QCONNECT_QWS_CREATE_TOKEN_PATH: &str = "/qws/createToken";
const DEFAULT_QCONNECT_DEVICE_NAME: &str = "QBZ Desktop"; const DEFAULT_QCONNECT_DEVICE_NAME: &str = "QBZ Desktop";
const DEFAULT_QCONNECT_DEVICE_BRAND: &str = "QBZ"; const DEFAULT_QCONNECT_DEVICE_BRAND: &str = "QBZ";
const DEFAULT_QCONNECT_DEVICE_MODEL: &str = "QBZ"; const DEFAULT_QCONNECT_DEVICE_MODEL: &str = "QBZ";
const DEFAULT_QCONNECT_DEVICE_TYPE: i32 = 5; const DEFAULT_QCONNECT_DEVICE_TYPE: i32 = 5; // computer
const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz"; const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz";
const QCONNECT_REMOTE_QUEUE_SOURCE: &str = "qobuz_connect_remote"; const QCONNECT_REMOTE_QUEUE_SOURCE: &str = "qobuz_connect_remote";
// AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k)
const AUDIO_QUALITY_MP3: i32 = 1;
const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4;
// VolumeRemoteControl enum: 0=unknown, 1=not_allowed, 2=allowed
const VOLUME_REMOTE_CONTROL_ALLOWED: i32 = 2;
// JoinSessionReason: 0=unknown, 1=controller_request, 2=reconnection
const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1;
static QCONNECT_DEVICE_UUID: OnceLock<String> = OnceLock::new(); static QCONNECT_DEVICE_UUID: OnceLock<String> = OnceLock::new();
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -190,6 +198,13 @@ pub struct QconnectAdmissionBlockedEvent {
pub handoff_intent: QconnectHandoffIntent, pub handoff_intent: QconnectHandoffIntent,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QconnectDeviceCapabilitiesPayload {
pub min_audio_quality: Option<i32>,
pub max_audio_quality: Option<i32>,
pub volume_remote_control: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QconnectDeviceInfoPayload { pub struct QconnectDeviceInfoPayload {
pub device_uuid: Option<String>, pub device_uuid: Option<String>,
@@ -198,6 +213,7 @@ pub struct QconnectDeviceInfoPayload {
pub model: Option<String>, pub model: Option<String>,
pub serial_number: Option<String>, pub serial_number: Option<String>,
pub device_type: Option<i32>, pub device_type: Option<i32>,
pub capabilities: Option<QconnectDeviceCapabilitiesPayload>,
pub software_version: Option<String>, pub software_version: Option<String>,
} }
@@ -835,11 +851,23 @@ async fn bootstrap_remote_presence(
let device_info = default_qconnect_device_info(); let device_info = default_qconnect_device_info();
let queue_version_ref = app.queue_state_snapshot().await.version; let queue_version_ref = app.queue_state_snapshot().await.version;
let renderer_join_payload = serde_json::to_value(QconnectJoinSessionRequest { // 1. Renderer JoinSession with is_active=true, initial state, and capabilities
session_uuid: None, let renderer_join_payload = serde_json::json!({
device_info: Some(device_info.clone()), "device_info": serde_json::to_value(&device_info)
}) .map_err(|err| format!("serialize device_info: {err}"))?,
.map_err(|err| format!("serialize renderer join_session bootstrap payload: {err}"))?; "is_active": true,
"reason": JOIN_SESSION_REASON_CONTROLLER_REQUEST,
"initial_state": {
"playing_state": PLAYING_STATE_STOPPED,
"buffer_state": BUFFER_STATE_OK,
"current_position": 0,
"duration": 0,
"queue_version": {
"major": queue_version_ref.major,
"minor": queue_version_ref.minor
}
}
});
let renderer_join_report = RendererReport::new( let renderer_join_report = RendererReport::new(
RendererReportType::RndrSrvrJoinSession, RendererReportType::RndrSrvrJoinSession,
Uuid::new_v4().to_string(), Uuid::new_v4().to_string(),
@@ -850,6 +878,49 @@ async fn bootstrap_remote_presence(
.await .await
.map_err(|err| format!("send bootstrap rndr_srvr_join_session failed: {err}"))?; .map_err(|err| format!("send bootstrap rndr_srvr_join_session failed: {err}"))?;
// 2. Send initial StateUpdated report so other devices see our state
let state_report_payload = serde_json::json!({
"playing_state": PLAYING_STATE_STOPPED,
"buffer_state": BUFFER_STATE_OK,
"current_position": 0,
"duration": 0,
"queue_version": {
"major": queue_version_ref.major,
"minor": queue_version_ref.minor
}
});
let state_report = RendererReport::new(
RendererReportType::RndrSrvrStateUpdated,
Uuid::new_v4().to_string(),
queue_version_ref,
state_report_payload,
);
app.send_renderer_report_command(state_report)
.await
.map_err(|err| format!("send bootstrap rndr_srvr_state_updated failed: {err}"))?;
// 3. Report volume and max audio quality
let volume_report = RendererReport::new(
RendererReportType::RndrSrvrVolumeChanged,
Uuid::new_v4().to_string(),
queue_version_ref,
serde_json::json!({ "volume": 100 }),
);
app.send_renderer_report_command(volume_report)
.await
.map_err(|err| format!("send bootstrap rndr_srvr_volume_changed failed: {err}"))?;
let max_quality_report = RendererReport::new(
RendererReportType::RndrSrvrMaxAudioQualityChanged,
Uuid::new_v4().to_string(),
queue_version_ref,
serde_json::json!({ "max_audio_quality": AUDIO_QUALITY_HIRES_LEVEL2 }),
);
app.send_renderer_report_command(max_quality_report)
.await
.map_err(|err| format!("send bootstrap rndr_srvr_max_audio_quality_changed failed: {err}"))?;
// 4. Controller JoinSession to also participate as controller
let join_payload = serde_json::to_value(QconnectJoinSessionRequest { let join_payload = serde_json::to_value(QconnectJoinSessionRequest {
session_uuid: None, session_uuid: None,
device_info: Some(device_info), device_info: Some(device_info),
@@ -862,12 +933,25 @@ async fn bootstrap_remote_presence(
let join_action_uuid = app let join_action_uuid = app
.send_queue_command(join_command) .send_queue_command(join_command)
.await .await
.map_err(|err| format!("send bootstrap join_session failed: {err}"))?; .map_err(|err| format!("send bootstrap ctrl_srvr_join_session failed: {err}"))?;
// JoinSession typically responds with session/renderer controller events that are not part of // JoinSession typically responds with session/renderer controller events that are not part of
// queue reducer correlation. Drop pending slot so queue operations are not blocked for 10s. // queue reducer correlation. Drop pending slot so queue operations are not blocked for 10s.
clear_pending_if_matches(app, &join_action_uuid).await; clear_pending_if_matches(app, &join_action_uuid).await;
// 5. Ask for current queue state from server
let ask_queue_payload = serde_json::json!({});
let ask_queue_command = app
.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, ask_queue_payload)
.await;
let ask_action_uuid = app
.send_queue_command(ask_queue_command)
.await
.map_err(|err| format!("send bootstrap ask_for_queue_state failed: {err}"))?;
clear_pending_if_matches(app, &ask_action_uuid).await;
log::info!("[QConnect] Bootstrap complete: renderer joined with is_active=true, initial state reported, controller joined, queue state requested");
Ok(()) Ok(())
} }
@@ -921,6 +1005,11 @@ fn default_qconnect_device_info() -> QconnectDeviceInfoPayload {
model: Some(model), model: Some(model),
serial_number: None, serial_number: None,
device_type: Some(device_type), device_type: Some(device_type),
capabilities: Some(QconnectDeviceCapabilitiesPayload {
min_audio_quality: Some(AUDIO_QUALITY_MP3),
max_audio_quality: Some(AUDIO_QUALITY_HIRES_LEVEL2),
volume_remote_control: Some(VOLUME_REMOTE_CONTROL_ALLOWED),
}),
software_version: Some(software_version), software_version: Some(software_version),
} }
} }
@@ -1230,7 +1319,8 @@ async fn resolve_transport_config(
.collect(); .collect();
parse_subscribe_channels(channels)? parse_subscribe_channels(channels)?
} else { } else {
Vec::new() // Default QConnect channels: connectionId(0x01), backend(0x02), controllers(0x03)
vec![vec![0x01], vec![0x02], vec![0x03]]
}; };
let mut config = WsTransportConfig::default(); let mut config = WsTransportConfig::default();