From 99b6f2bd23dd1bab9f6848783138845992e6d6d1 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Mon, 28 Jul 2025 13:27:36 -0500 Subject: [PATCH] fix(sonos): Improved handling of bonded set membership changes. We have historically ignored non-primary devices in bonded sets during discovery/onboarding, going back to the pre-Edge days. Bonded sets are things like stereo pairs or home theater setups. These devices cannot be controlled via the WSS LAN API at all, and they aren't treated as being part of a Group in the Sonos group model; the intent is for API consumers to treat the entire bonded set as a single "player". Only one of the devices in the set exposes an API endpoint, which differs from a Group where all the players expose an endpoint. While groups service most commands via calling the API endpoint on the Group Coordinator, certain commands like volume can be sent to non-coordinator players. This is not the case with bonded sets; the entire set always acts atomically, and only the primary device in the set can service commands. Hence, we treat non-primary devices as not controllable in a bonded set, and we don't onboard them. However, we didn't really have very robust handling of devices that become part of a bonded set at runtime after onboarding. This led to unnecessary CPU and RAM usage by creating a few tight loops due to unexpected failure/edge cases when making certain calls. Because we would fail to discover this device under normal circumstances, the preferred way to handle this transition is to mark the device as being offline. We discussed emitting a delete for these devices, but it seems that it wouldn't be too uncommon for some users to create and destroy bonded sets ephemerally the way one might do with a Group. For this reason we decided we would avoid deleting the records. If a non-primary in a bonded set is removed from the bonded set, it will be marked as online again. --- .../sonos/src/api/event_handlers.lua | 49 ++++--- .../sonos/src/api/sonos_connection.lua | 46 ++++-- .../sonos/src/api/sonos_ssdp_discovery.lua | 2 +- drivers/SmartThings/sonos/src/fields.lua | 1 + drivers/SmartThings/sonos/src/init.lua | 4 +- .../sonos/src/lifecycle_handlers.lua | 10 +- .../SmartThings/sonos/src/sonos_driver.lua | 61 ++++++-- drivers/SmartThings/sonos/src/sonos_state.lua | 136 ++++++++++++------ drivers/SmartThings/sonos/src/types.lua | 5 +- drivers/SmartThings/sonos/src/utils.lua | 8 +- 10 files changed, 223 insertions(+), 99 deletions(-) diff --git a/drivers/SmartThings/sonos/src/api/event_handlers.lua b/drivers/SmartThings/sonos/src/api/event_handlers.lua index a4fe85766e..ad9b697740 100644 --- a/drivers/SmartThings/sonos/src/api/event_handlers.lua +++ b/drivers/SmartThings/sonos/src/api/event_handlers.lua @@ -1,8 +1,12 @@ local capabilities = require "st.capabilities" +local swGenCapability = capabilities["stus.softwareGeneration"] + local log = require "log" local st_utils = require "st.utils" +local PlayerFields = require "fields".SonosPlayerFields + local CapEventHandlers = {} CapEventHandlers.PlaybackStatus = { @@ -12,41 +16,52 @@ CapEventHandlers.PlaybackStatus = { Playing = "PLAYBACK_STATE_PLAYING", } +local function _do_emit(device, attribute_event) + local bonded = device:get_field(PlayerFields.BONDED) + if not bonded then + device:emit_event(attribute_event) + end +end + +function CapEventHandlers.handle_sw_gen(device, sw_gen) + _do_emit(device, swGenCapability.generation(string.format("%s", sw_gen))) +end + function CapEventHandlers.handle_player_volume(device, new_volume, is_muted) - device:emit_event(capabilities.audioVolume.volume(new_volume)) + _do_emit(device, capabilities.audioVolume.volume(new_volume)) if is_muted then - device:emit_event(capabilities.audioMute.mute.muted()) + _do_emit(device, capabilities.audioMute.mute.muted()) else - device:emit_event(capabilities.audioMute.mute.unmuted()) + _do_emit(device, capabilities.audioMute.mute.unmuted()) end end function CapEventHandlers.handle_group_volume(device, new_volume, is_muted) - device:emit_event(capabilities.mediaGroup.groupVolume(new_volume)) + _do_emit(device, capabilities.mediaGroup.groupVolume(new_volume)) if is_muted then - device:emit_event(capabilities.mediaGroup.groupMute.muted()) + _do_emit(device, capabilities.mediaGroup.groupMute.muted()) else - device:emit_event(capabilities.mediaGroup.groupMute.unmuted()) + _do_emit(device, capabilities.mediaGroup.groupMute.unmuted()) end end function CapEventHandlers.handle_group_role_update(device, group_role) - device:emit_event(capabilities.mediaGroup.groupRole(group_role)) + _do_emit(device, capabilities.mediaGroup.groupRole(group_role)) end function CapEventHandlers.handle_group_coordinator_update(device, coordinator_id) - device:emit_event(capabilities.mediaGroup.groupPrimaryDeviceId(coordinator_id)) + _do_emit(device, capabilities.mediaGroup.groupPrimaryDeviceId(coordinator_id)) end function CapEventHandlers.handle_group_id_update(device, group_id) - device:emit_event(capabilities.mediaGroup.groupId(group_id)) + _do_emit(device, capabilities.mediaGroup.groupId(group_id)) end function CapEventHandlers.handle_group_update(device, group_info) local groupRole, groupPrimaryDeviceId, groupId = table.unpack(group_info) - device:emit_event(capabilities.mediaGroup.groupRole(groupRole)) - device:emit_event(capabilities.mediaGroup.groupPrimaryDeviceId(groupPrimaryDeviceId)) - device:emit_event(capabilities.mediaGroup.groupId(groupId)) + _do_emit(device, capabilities.mediaGroup.groupRole(groupRole)) + _do_emit(device, capabilities.mediaGroup.groupPrimaryDeviceId(groupPrimaryDeviceId)) + _do_emit(device, capabilities.mediaGroup.groupId(groupId)) end function CapEventHandlers.handle_audio_clip_status(device, clips) @@ -61,11 +76,11 @@ end function CapEventHandlers.handle_playback_status(device, playback_state) if playback_state == CapEventHandlers.PlaybackStatus.Playing then - device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) + _do_emit(device, capabilities.mediaPlayback.playbackStatus.playing()) elseif playback_state == CapEventHandlers.PlaybackStatus.Idle then - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) + _do_emit(device, capabilities.mediaPlayback.playbackStatus.stopped()) elseif playback_state == CapEventHandlers.PlaybackStatus.Paused then - device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) + _do_emit(device, capabilities.mediaPlayback.playbackStatus.paused()) elseif playback_state == CapEventHandlers.PlaybackStatus.Buffering then -- TODO the DTH doesn't currently do anything w/ buffering; -- might be worth figuring out what to do with this in the future. @@ -74,7 +89,7 @@ function CapEventHandlers.handle_playback_status(device, playback_state) end function CapEventHandlers.update_favorites(device, new_favorites) - device:emit_event(capabilities.mediaPresets.presets(new_favorites)) + _do_emit(device, capabilities.mediaPresets.presets(new_favorites)) end function CapEventHandlers.handle_playback_metadata_update(device, metadata_status_body) @@ -128,7 +143,7 @@ function CapEventHandlers.handle_playback_metadata_update(device, metadata_statu end if type(audio_track_data.title) == "string" then - device:emit_event(capabilities.audioTrackData.audioTrackData(audio_track_data)) + _do_emit(device, capabilities.audioTrackData.audioTrackData(audio_track_data)) end end diff --git a/drivers/SmartThings/sonos/src/api/sonos_connection.lua b/drivers/SmartThings/sonos/src/api/sonos_connection.lua index f1773e24aa..d854401313 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_connection.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_connection.lua @@ -166,8 +166,12 @@ local function _open_coordinator_socket(sonos_conn, household_id, self_player_id return end - _, err = - Router.open_socket_for_player(household_id, coordinator_id, coordinator.websocketUrl, api_key) + _, err = Router.open_socket_for_player( + household_id, + coordinator_id, + coordinator.player.websocketUrl, + api_key + ) if err ~= nil then log.error( string.format( @@ -302,10 +306,13 @@ end --- @return SonosConnection function SonosConnection.new(driver, device) log.debug(string.format("Creating new SonosConnection for %s", device.label)) - local self = setmetatable( - { driver = driver, device = device, _listener_uuids = {}, _initialized = false, _reconnecting = false }, - SonosConnection - ) + local self = setmetatable({ + driver = driver, + device = device, + _listener_uuids = {}, + _initialized = false, + _reconnecting = false, + }, SonosConnection) -- capture the label here in case something goes wonky like a callback being fired after a -- device is removed @@ -358,19 +365,28 @@ function SonosConnection.new(driver, device) local household_id, current_coordinator = self.driver.sonos:get_coordinator_for_device(self.device) local _, player_id = self.driver.sonos:get_player_for_device(self.device) - self.driver.sonos:update_household_info(header.householdId, body, self.device) + self.driver.sonos:update_household_info(header.householdId, body, self.driver) self.driver.sonos:update_device_record_from_state(header.householdId, self.device) local _, updated_coordinator = self.driver.sonos:get_coordinator_for_device(self.device) + local bonded = self.device:get_field(PlayerFields.BONDED) + if bonded then + self:stop() + end + Router.cleanup_unused_sockets(self.driver) - if not self:coordinator_running() then - --TODO this is not infallible - _open_coordinator_socket(self, household_id, player_id) - end + if not bonded then + if not self:coordinator_running() then + --TODO this is not infallible + _open_coordinator_socket(self, household_id, player_id) + end - if current_coordinator ~= updated_coordinator then - self:refresh_subscriptions() + if current_coordinator ~= updated_coordinator then + self:refresh_subscriptions() + end + else + self.device:offline() end elseif header.type == "playerVolume" then log.trace(string.format("PlayerVolume type message for %s", device_name)) @@ -477,7 +493,7 @@ function SonosConnection.new(driver, device) return end - local url_ip = lb_utils.force_url_table(coordinator_player.websocketUrl).host + local url_ip = lb_utils.force_url_table(coordinator_player.player.websocketUrl).host local base_url = lb_utils.force_url_table( string.format("https://%s:%s", url_ip, SonosApi.DEFAULT_SONOS_PORT) ) @@ -590,7 +606,7 @@ function SonosConnection:coordinator_running() ) ) end - return type(unique_key) == "string" and Router.is_connected(unique_key) and self._initialized + return type(unique_key) == "string" and Router.is_connected(unique_key) end function SonosConnection:refresh_subscriptions(maybe_reply_tx) diff --git a/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua index 88e0cddbd9..be3ae6092a 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua @@ -300,7 +300,7 @@ function sonos_ssdp.spawn_persistent_ssdp_task() if info_to_send then if not (info_to_send.discovery_info and info_to_send.discovery_info.device) then log.error_with( - { hub_logs = true }, + { hub_logs = false }, st_utils.stringify_table(info_to_send, "Sonos Discovery Info has unexpected structure") ) return diff --git a/drivers/SmartThings/sonos/src/fields.lua b/drivers/SmartThings/sonos/src/fields.lua index 6a219ef452..11b658a835 100644 --- a/drivers/SmartThings/sonos/src/fields.lua +++ b/drivers/SmartThings/sonos/src/fields.lua @@ -5,6 +5,7 @@ local Fields = {} Fields.SonosPlayerFields = { _IS_INIT = "init", _IS_SCANNING = "scanning", + BONDED = "bonded", CONNECTION = "conn", UNIQUE_KEY = "unique_key", HOUSEHOLD_ID = "householdId", diff --git a/drivers/SmartThings/sonos/src/init.lua b/drivers/SmartThings/sonos/src/init.lua index bfe1c0eb9e..db4e5f6013 100644 --- a/drivers/SmartThings/sonos/src/init.lua +++ b/drivers/SmartThings/sonos/src/init.lua @@ -39,6 +39,6 @@ if api_version < 14 then driver:start_ssdp_event_task() end -log.info "Starting Sonos run loop" +log.info("Starting Sonos run loop") driver:run() -log.info "Exiting Sonos run loop" +log.info("Exiting Sonos run loop") diff --git a/drivers/SmartThings/sonos/src/lifecycle_handlers.lua b/drivers/SmartThings/sonos/src/lifecycle_handlers.lua index 747381f798..c62b90abcd 100644 --- a/drivers/SmartThings/sonos/src/lifecycle_handlers.lua +++ b/drivers/SmartThings/sonos/src/lifecycle_handlers.lua @@ -164,10 +164,12 @@ function SonosDriverLifecycleHandlers.initialize_device(driver, device) return end log.error_with( - { hub_logs = true }, - "Error handling Sonos player initialization: %s, error code: %s", - error, - (error_code or "N/A") + { hub_logs = false }, + string.format( + "Error handling Sonos player initialization: %s, error code: %s", + error, + (error_code or "N/A") + ) ) end end diff --git a/drivers/SmartThings/sonos/src/sonos_driver.lua b/drivers/SmartThings/sonos/src/sonos_driver.lua index c603a7f399..774ca713e6 100644 --- a/drivers/SmartThings/sonos/src/sonos_driver.lua +++ b/drivers/SmartThings/sonos/src/sonos_driver.lua @@ -42,11 +42,27 @@ local ONE_HOUR_IN_SECONDS = 3600 ---@field private waiting_for_oauth_token boolean ---@field private startup_state_received boolean ---@field private devices_waiting_for_startup_state SonosDevice[] +---@field package bonded_devices table map of Device device_network_id to a boolean indicating if the device is currently known as a bonded device. --- ---@field public ssdp_task SonosPersistentSsdpTask? ---@field private ssdp_event_thread_handle table? local SonosDriver = {} +---@param device SonosDevice +function SonosDriver:update_bonded_device_tracking(device) + local already_bonded = self.bonded_devices[device.device_network_id] + local currently_bonded = device:get_field(PlayerFields.BONDED) + self.bonded_devices[device.device_network_id] = currently_bonded + + if currently_bonded and not already_bonded then + device:offline() + end + + if already_bonded and not currently_bonded then + SonosDriverLifecycleHandlers.initialize_device(self, device) + end +end + function SonosDriver:has_received_startup_state() return self.startup_state_received end @@ -127,13 +143,13 @@ end function SonosDriver:handle_augmented_store_delete(update_key) if update_key == "endpointAppInfo" then if update_key == "endpointAppInfo" then - log.trace "deleting endpoint app info" + log.trace("deleting endpoint app info") self.oauth.endpoint_app_info = nil elseif update_key == "sonosOAuthToken" then - log.trace "deleting OAuth Token" + log.trace("deleting OAuth Token") self.oauth.token = nil elseif update_key == "force_oauth" then - log.trace "deleting Force OAuth" + log.trace("deleting Force OAuth") self.oauth.force_oauth = nil else log.debug(string.format("received delete of unexpected key: %s", update_key)) @@ -423,9 +439,15 @@ local function make_ssdp_event_handler( local event, recv_err = discovery_event_subscription:receive() if event then + local mac_addr = utils.extract_mac_addr(event.discovery_info.device) local unique_key = utils.sonos_unique_key_from_ssdp(event.ssdp_info) if - event.force_refresh or not (unauthorized[unique_key] or discovered[unique_key]) + event.force_refresh + or not ( + unauthorized[unique_key] + or discovered[unique_key] + or driver.bonded_devices[mac_addr] + ) then local _, api_key = driver:check_auth(event) local success, handle_err, err_code = @@ -435,7 +457,7 @@ local function make_ssdp_event_handler( unauthorized[unique_key] = event end log.warn_with( - { hub_logs = true }, + { hub_logs = false }, string.format("Failed to handle discovered speaker: %s", handle_err) ) else @@ -483,12 +505,22 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) -- speaker in a bonded set (e.g. a home theater system, a stereo pair, etc). -- These aren't the same as speaker groups, and bonded speakers can't be controlled -- via websocket at all. So we ignore all bonded non-primary speakers - if #info.ssdp_info.group_id == 0 then - return nil, - string.format( - "Player %s is a non-primary bonded Sonos device, ignoring", - info.discovery_info.device.name - ) + -- if then + -- return nil, + -- string.format( + -- "Player %s is a non-primary bonded Sonos device, ignoring", + -- info.discovery_info.device.name + -- ) + -- end + + local discovery_info_mac_addr = utils.extract_mac_addr(info.discovery_info.device) + local bonded = (#info.ssdp_info.group_id == 0) + self.bonded_devices[discovery_info_mac_addr] = bonded + + local maybe_device = self:get_device_by_dni(discovery_info_mac_addr) + if maybe_device then + maybe_device:set_field(PlayerFields.BONDED, bonded, { persist = false }) + self:update_bonded_device_tracking(maybe_device) end api_key = api_key or self:get_fallback_api_key() @@ -543,7 +575,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) end --- @cast response SonosGroupsResponseBody - self.sonos:update_household_info(info.ssdp_info.household_id, response) + self.sonos:update_household_info(info.ssdp_info.household_id, response, self) local device_to_update, device_mac_addr @@ -565,7 +597,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) if not (info and info.discovery_info and info.discovery_info.device) then return nil, st_utils.stringify_table(info, "Sonos Discovery Info has unexpected structure") end - device_mac_addr = utils.extract_mac_addr(info.discovery_info.device) + device_mac_addr = discovery_info_mac_addr end if not device_to_update then @@ -578,7 +610,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) if device_to_update then self.dni_to_device_id[device_mac_addr] = device_to_update.id self.sonos:associate_device_record(device_to_update, info) - else + elseif not bonded then local name = info.discovery_info.device.name or info.discovery_info.device.modelDisplayName or "Unknown Sonos Player" @@ -631,6 +663,7 @@ function SonosDriver.new_driver_template() waiting_for_oauth_token = false, startup_state_received = false, devices_waiting_for_startup_state = {}, + bonded_devices = utils.new_mac_address_keyed_table(), dni_to_device_id = utils.new_mac_address_keyed_table(), lifecycle_handlers = SonosDriverLifecycleHandlers, capability_handlers = { diff --git a/drivers/SmartThings/sonos/src/sonos_state.lua b/drivers/SmartThings/sonos/src/sonos_state.lua index 25c9e47b2b..ad7b8e8c41 100644 --- a/drivers/SmartThings/sonos/src/sonos_state.lua +++ b/drivers/SmartThings/sonos/src/sonos_state.lua @@ -1,7 +1,5 @@ -local capabilities = require "st.capabilities" local log = require "log" local st_utils = require "st.utils" -local swGenCapability = capabilities["stus.softwareGeneration"] local utils = require "utils" @@ -13,7 +11,8 @@ local SonosConnection = require "api.sonos_connection" --- Information on an entire Sonos system ("household"), such as its current groups, list of players, etc. --- @field public id HouseholdId --- @field public groups table All of the current groups in the system ---- @field public players table All of the current players in the system +--- @field public players table All of the current players in the system +--- @field public bonded_players table PlayerID's in this map that map to true are non-primary bonded players, and not controllable. --- @field public player_to_group table quick lookup from Player ID -> Group ID --- @field public st_devices table Player ID -> ST Device Record UUID information for the household --- @field public favorites SonosFavorites all of the favorites/presets in the system @@ -23,6 +22,9 @@ function _household_mt:reset() self.groups = utils.new_case_insensitive_table() self.players = utils.new_case_insensitive_table() self.player_to_group = utils.new_case_insensitive_table() + if not self.bonded_players then + self.bonded_players = utils.new_case_insensitive_table() + end end _household_mt.__index = _household_mt @@ -46,10 +48,10 @@ local function make_households_table() local households_table_inner = utils.new_case_insensitive_table() local households_table = setmetatable({}, { - __index = function(tbl, key) + __index = function(_, key) return households_table_inner[key] end, - __newindex = function(tbl, key, value) + __newindex = function(_, key, value) households_table_inner[key] = value end, __metatable = "SonosHouseholds", @@ -73,7 +75,7 @@ end local _STATE = { ---@type Households households = make_households_table(), - ---@type table + ---@type table device_record_map = {}, } @@ -100,8 +102,11 @@ function SonosState:associate_device_record(device, info) return end - local group = household.groups[group_id] + if not group_id or #group_id == 0 then + group_id = household.player_to_group[player_id or ""] or "" + end + local group = household.groups[group_id] if not group then log.error( string.format( @@ -112,9 +117,11 @@ function SonosState:associate_device_record(device, info) return end - local player = household.players[player_id] + local player_tbl = household.players[player_id] + local player = (player_tbl or {}).player + local sonos_device = (player_tbl or {}).device - if not player then + if not (player and sonos_device) then log.error( string.format( "No record of Sonos player for device %s", @@ -124,15 +131,24 @@ function SonosState:associate_device_record(device, info) return end - household.st_devices[player.id] = device.id + household.st_devices[sonos_device.id] = device.id - _STATE.device_record_map[device.id] = { group = group, player = player, household = household } + _STATE.device_record_map[device.id] = + { sonos_device = sonos_device, group = group, player = player, household = household } - device:set_field(PlayerFields.SW_GEN, info.discovery_info.device.swGen, { persist = true }) - device:emit_event( - swGenCapability.generation(string.format("%s", info.discovery_info.device.swGen)) + local bonded = household.bonded_players[sonos_device.id or {}] and true or false + + local sw_gen_changed = utils.update_field_if_changed( + device, + PlayerFields.SW_GEN, + info.discovery_info.device.swGen, + { persist = true } ) + if sw_gen_changed then + CapEventHandlers.handle_sw_gen(device, info.discovery_info.device.swGen) + end + device:set_field(PlayerFields.REST_URL, info.discovery_info.restUrl, { persist = true }) local sonos_conn = device:get_field(PlayerFields.CONNECTION) @@ -144,7 +160,9 @@ function SonosState:associate_device_record(device, info) { persist = true } ) - if websocket_url_changed and connected then + local should_stop_conn = connected and (bonded or websocket_url_changed) + + if should_stop_conn then sonos_conn:stop() sonos_conn = nil device:set_field(PlayerFields.CONNECTION, nil) @@ -157,13 +175,17 @@ function SonosState:associate_device_record(device, info) { persist = true } ) - local player_id_changed = - utils.update_field_if_changed(device, PlayerFields.PLAYER_ID, player.id, { persist = true }) + local player_id_changed = utils.update_field_if_changed( + device, + PlayerFields.PLAYER_ID, + sonos_device.id, + { persist = true } + ) local need_refresh = connected and (websocket_url_changed or household_id_changed or player_id_changed) - if sonos_conn == nil then + if not bonded and sonos_conn == nil then sonos_conn = SonosConnection.new(device.driver, device) device:set_field(PlayerFields.CONNECTION, sonos_conn) sonos_conn:start() @@ -175,6 +197,11 @@ function SonosState:associate_device_record(device, info) end self:update_device_record_group_info(household, group, device) + + -- device can't be controlled, mark the device as being offline. + if bonded then + device:offline() + end end ---@param household SonosHousehold @@ -182,8 +209,11 @@ end ---@param device SonosDevice function SonosState:update_device_record_group_info(household, group, device) local player_id = device:get_field(PlayerFields.PLAYER_ID) + local bonded = ((household or {}).bonded_players or {})[player_id] and true or false local group_role - if + if bonded then + group_role = "auxilary" + elseif ( type(household) == "table" and type(household.groups) == "table" @@ -205,13 +235,13 @@ function SonosState:update_device_record_group_info(household, group, device) local field_changed = utils.update_field_if_changed(device, PlayerFields.GROUP_ID, group.id, { persist = true }) - if field_changed then + if not bonded and field_changed then CapEventHandlers.handle_group_id_update(device, group.id) end field_changed = utils.update_field_if_changed(device, PlayerFields.GROUP_ROLE, group_role, { persist = true }) - if field_changed then + if not bonded and field_changed then CapEventHandlers.handle_group_role_update(device, group_role) end @@ -221,9 +251,13 @@ function SonosState:update_device_record_group_info(household, group, device) group.coordinatorId, { persist = true } ) - if field_changed then + if not bonded and field_changed then CapEventHandlers.handle_group_coordinator_update(device, group.coordinatorId) end + + if bonded then + device:offline() + end end function SonosState:remove_device_record_association(device) @@ -273,8 +307,10 @@ end --- @param id HouseholdId --- @param groups_event SonosGroupsResponseBody -function SonosState:update_household_info(id, groups_event) +--- @param driver SonosDriver +function SonosState:update_household_info(id, groups_event, driver) local household = _STATE.households:get_or_init(id) + local known_bonded_players = household.bonded_players or {} household:reset() local groups, players = groups_event.groups, groups_event.players @@ -283,25 +319,45 @@ function SonosState:update_household_info(id, groups_event) household.groups[group.id] = group for _, playerId in ipairs(group.playerIds) do household.player_to_group[playerId] = group.id + end + end + + for _, player in ipairs(players) do + for _, device in ipairs(player.devices) do + household.players[device.id] = { player = player, device = device } + local previously_bonded = known_bonded_players[device.id] and true or false + local currently_bonded + local group_id + -- non-primary bonded players are excluded from a group's list of PlayerID's so we use the group membership + -- of the primary device + if type(device.primaryDeviceId) == "string" and #device.primaryDeviceId > 0 then + currently_bonded = true + group_id = household.player_to_group[device.primaryDeviceId] + else + currently_bonded = false + group_id = household.player_to_group[device.id] + end + household.player_to_group[device.id] = group_id + household.bonded_players[device.id] = currently_bonded - local maybe_device_id = household.st_devices[playerId] + local maybe_device_id = household.st_devices[device.id] if maybe_device_id then _STATE.device_record_map[maybe_device_id] = _STATE.device_record_map[maybe_device_id] or {} - _STATE.device_record_map[maybe_device_id].group = group _STATE.device_record_map[maybe_device_id].household = household + _STATE.device_record_map[maybe_device_id].group = household.groups[group_id] + _STATE.device_record_map[maybe_device_id].player = player + _STATE.device_record_map[maybe_device_id].sonos_device = device + if previously_bonded ~= currently_bonded then + local target_device = driver:get_device_info(maybe_device_id) + if target_device then + target_device:set_field(PlayerFields.BONDED, currently_bonded, { persist = false }) + driver:update_bonded_device_tracking(target_device) + end + end end end end - for _, player in ipairs(players) do - household.players[player.id] = player - local maybe_device_id = household.st_devices[player.id] - if maybe_device_id then - _STATE.device_record_map[maybe_device_id] = _STATE.device_record_map[maybe_device_id] or {} - _STATE.device_record_map[maybe_device_id].player = player - end - end - household.id = id _STATE.households[id] = household end @@ -312,7 +368,7 @@ end --- @return string? error nil on success function SonosState:get_group_for_player(household_id, player_id) log.debug_with( - { hub_logs = true }, + { hub_logs = false }, st_utils.stringify_table( { household_id = household_id, player_id = player_id }, "Get Group For Player", @@ -339,7 +395,7 @@ end --- @return PlayerId?,string? function SonosState:get_coordinator_for_player(household_id, player_id) log.debug_with( - { hub_logs = true }, + { hub_logs = false }, st_utils.stringify_table( { household_id = household_id, player_id = player_id }, "Get Coordinator For Player", @@ -361,7 +417,7 @@ end --- @return PlayerId?,string? function SonosState:get_coordinator_for_group(household_id, group_id) log.debug_with( - { hub_logs = true }, + { hub_logs = false }, st_utils.stringify_table( { household_id = household_id, group_id = group_id }, "Get Coordinator For Group", @@ -432,7 +488,7 @@ function SonosState:get_sonos_ids_for_device(device) -- player id *should* be stable if not player_id then - player_id = sonos_objects.player.id + player_id = sonos_objects.sonos_device.id device:set_field(PlayerFields.PLAYER_ID, player_id, { persist = true }) end @@ -464,7 +520,7 @@ end --- @return nil|string error nil on success function SonosState:get_group_for_device(device) if type(device) ~= "table" then - return nil, string.format("Invalid device argument for get_player_for_device: %s", device) + return nil, string.format("Invalid device argument for get_group_for_device: %s", device) end local household_id, group_id, _, err = self:get_sonos_ids_for_device(device) if err then @@ -479,7 +535,7 @@ end --- @return nil|string error nil on success function SonosState:get_coordinator_for_device(device) if type(device) ~= "table" then - return nil, string.format("Invalid device argument for get_player_for_device: %s", device) + return nil, string.format("Invalid device argument for get_coordinator_for_device: %s", device) end local household_id, group_id, _, err = self:get_sonos_ids_for_device(device) if err then diff --git a/drivers/SmartThings/sonos/src/types.lua b/drivers/SmartThings/sonos/src/types.lua index 65bcb3041f..a1be63fe3f 100644 --- a/drivers/SmartThings/sonos/src/types.lua +++ b/drivers/SmartThings/sonos/src/types.lua @@ -28,7 +28,7 @@ ---@field public controlApi { [1]: string } --- Lua representation of the Sonos `deviceInfo` JSON Object: https://developer.sonos.com/build/control-sonos-players-lan/discover-lan/#deviceInfo-object ---- @class SonosDeviceInfo +--- @class SonosDeviceInfoObject --- @field public _objectType "deviceInfo" --- @field public id PlayerId The playerId. Also known as the deviceId. Used to address Sonos devices in the control API. --- @field public primaryDeviceId string Identifies the primary device in bonded sets. Primary devices leave the value blank, which omits the key from the message. The field is expected for secondary devices in stereo pairs and satellites in home theater configurations. @@ -49,7 +49,7 @@ --- Lua representation of the Sonos `discoveryInfo` JSON object: https://developer.sonos.com/build/control-sonos-players-lan/discover-lan/#discoveryInfo-object --- @class SonosDiscoveryInfo --- @field public _objectType "discoveryInfo" ---- @field public device SonosDeviceInfo The device object. This object presents immutable data that describes a Sonos device. Use this object to uniquely identify any Sonos device. See below for details. +--- @field public device SonosDeviceInfoObject The device object. This object presents immutable data that describes a Sonos device. Use this object to uniquely identify any Sonos device. See below for details. --- @field public householdId HouseholdId An opaque identifier assigned to the device during registration. This field may be missing prior to registration. --- @field public playerId PlayerId The identifier used to address this particular device in the control API. --- @field public groupId GroupId The currently assigned groupId, an ephemeral opaque identifier. This value is always correct, including for group members. @@ -141,6 +141,7 @@ --- @field public softwareVersion string --- @field public websocketUrl string --- @field public capabilities SonosCapabilities[] +--- @field public devices SonosDeviceInfoObject[] --- Sonos player local state --- @class PlayerDiscoveryState diff --git a/drivers/SmartThings/sonos/src/utils.lua b/drivers/SmartThings/sonos/src/utils.lua index 5ffb3a8a9d..1aa299b629 100644 --- a/drivers/SmartThings/sonos/src/utils.lua +++ b/drivers/SmartThings/sonos/src/utils.lua @@ -134,7 +134,7 @@ local function __case_insensitive_key_index(tbl, key) fmt_val = key or "" end log.warn_with( - { hub_logs = true }, + { hub_logs = false }, string.format( "Expected `string` key for CaseInsensitiveKeyTable, received (%s: %s)", fmt_val, @@ -157,7 +157,7 @@ local function __case_insensitive_key_newindex(tbl, key, value) fmt_val = key or "" end log.warn_with( - { hub_logs = true }, + { hub_logs = false }, string.format( "Expected `string` key for CaseInsensitiveKeyTable, received (%s: %s)", fmt_val, @@ -179,11 +179,11 @@ function utils.new_case_insensitive_table() return setmetatable({}, _case_insensitive_key_mt) end ----@param sonos_device_info SonosDeviceInfo +---@param sonos_device_info SonosDeviceInfoObject function utils.extract_mac_addr(sonos_device_info) if type(sonos_device_info) ~= "table" or type(sonos_device_info.serialNumber) ~= "string" then log.error_with( - { hub_logs = true }, + { hub_logs = false }, string.format("Bad sonos device info passed to `extract_mac_addr`: %s", sonos_device_info) ) end