Skip to content

Commit 99b6f2b

Browse files
committed
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.
1 parent 86c1146 commit 99b6f2b

File tree

10 files changed

+223
-99
lines changed

10 files changed

+223
-99
lines changed

drivers/SmartThings/sonos/src/api/event_handlers.lua

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
local capabilities = require "st.capabilities"
2+
local swGenCapability = capabilities["stus.softwareGeneration"]
3+
24
local log = require "log"
35

46
local st_utils = require "st.utils"
57

8+
local PlayerFields = require "fields".SonosPlayerFields
9+
610
local CapEventHandlers = {}
711

812
CapEventHandlers.PlaybackStatus = {
@@ -12,41 +16,52 @@ CapEventHandlers.PlaybackStatus = {
1216
Playing = "PLAYBACK_STATE_PLAYING",
1317
}
1418

19+
local function _do_emit(device, attribute_event)
20+
local bonded = device:get_field(PlayerFields.BONDED)
21+
if not bonded then
22+
device:emit_event(attribute_event)
23+
end
24+
end
25+
26+
function CapEventHandlers.handle_sw_gen(device, sw_gen)
27+
_do_emit(device, swGenCapability.generation(string.format("%s", sw_gen)))
28+
end
29+
1530
function CapEventHandlers.handle_player_volume(device, new_volume, is_muted)
16-
device:emit_event(capabilities.audioVolume.volume(new_volume))
31+
_do_emit(device, capabilities.audioVolume.volume(new_volume))
1732
if is_muted then
18-
device:emit_event(capabilities.audioMute.mute.muted())
33+
_do_emit(device, capabilities.audioMute.mute.muted())
1934
else
20-
device:emit_event(capabilities.audioMute.mute.unmuted())
35+
_do_emit(device, capabilities.audioMute.mute.unmuted())
2136
end
2237
end
2338

2439
function CapEventHandlers.handle_group_volume(device, new_volume, is_muted)
25-
device:emit_event(capabilities.mediaGroup.groupVolume(new_volume))
40+
_do_emit(device, capabilities.mediaGroup.groupVolume(new_volume))
2641
if is_muted then
27-
device:emit_event(capabilities.mediaGroup.groupMute.muted())
42+
_do_emit(device, capabilities.mediaGroup.groupMute.muted())
2843
else
29-
device:emit_event(capabilities.mediaGroup.groupMute.unmuted())
44+
_do_emit(device, capabilities.mediaGroup.groupMute.unmuted())
3045
end
3146
end
3247

3348
function CapEventHandlers.handle_group_role_update(device, group_role)
34-
device:emit_event(capabilities.mediaGroup.groupRole(group_role))
49+
_do_emit(device, capabilities.mediaGroup.groupRole(group_role))
3550
end
3651

3752
function CapEventHandlers.handle_group_coordinator_update(device, coordinator_id)
38-
device:emit_event(capabilities.mediaGroup.groupPrimaryDeviceId(coordinator_id))
53+
_do_emit(device, capabilities.mediaGroup.groupPrimaryDeviceId(coordinator_id))
3954
end
4055

4156
function CapEventHandlers.handle_group_id_update(device, group_id)
42-
device:emit_event(capabilities.mediaGroup.groupId(group_id))
57+
_do_emit(device, capabilities.mediaGroup.groupId(group_id))
4358
end
4459

4560
function CapEventHandlers.handle_group_update(device, group_info)
4661
local groupRole, groupPrimaryDeviceId, groupId = table.unpack(group_info)
47-
device:emit_event(capabilities.mediaGroup.groupRole(groupRole))
48-
device:emit_event(capabilities.mediaGroup.groupPrimaryDeviceId(groupPrimaryDeviceId))
49-
device:emit_event(capabilities.mediaGroup.groupId(groupId))
62+
_do_emit(device, capabilities.mediaGroup.groupRole(groupRole))
63+
_do_emit(device, capabilities.mediaGroup.groupPrimaryDeviceId(groupPrimaryDeviceId))
64+
_do_emit(device, capabilities.mediaGroup.groupId(groupId))
5065
end
5166

5267
function CapEventHandlers.handle_audio_clip_status(device, clips)
@@ -61,11 +76,11 @@ end
6176

6277
function CapEventHandlers.handle_playback_status(device, playback_state)
6378
if playback_state == CapEventHandlers.PlaybackStatus.Playing then
64-
device:emit_event(capabilities.mediaPlayback.playbackStatus.playing())
79+
_do_emit(device, capabilities.mediaPlayback.playbackStatus.playing())
6580
elseif playback_state == CapEventHandlers.PlaybackStatus.Idle then
66-
device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped())
81+
_do_emit(device, capabilities.mediaPlayback.playbackStatus.stopped())
6782
elseif playback_state == CapEventHandlers.PlaybackStatus.Paused then
68-
device:emit_event(capabilities.mediaPlayback.playbackStatus.paused())
83+
_do_emit(device, capabilities.mediaPlayback.playbackStatus.paused())
6984
elseif playback_state == CapEventHandlers.PlaybackStatus.Buffering then
7085
-- TODO the DTH doesn't currently do anything w/ buffering;
7186
-- 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)
7489
end
7590

7691
function CapEventHandlers.update_favorites(device, new_favorites)
77-
device:emit_event(capabilities.mediaPresets.presets(new_favorites))
92+
_do_emit(device, capabilities.mediaPresets.presets(new_favorites))
7893
end
7994

8095
function CapEventHandlers.handle_playback_metadata_update(device, metadata_status_body)
@@ -128,7 +143,7 @@ function CapEventHandlers.handle_playback_metadata_update(device, metadata_statu
128143
end
129144

130145
if type(audio_track_data.title) == "string" then
131-
device:emit_event(capabilities.audioTrackData.audioTrackData(audio_track_data))
146+
_do_emit(device, capabilities.audioTrackData.audioTrackData(audio_track_data))
132147
end
133148
end
134149

drivers/SmartThings/sonos/src/api/sonos_connection.lua

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,12 @@ local function _open_coordinator_socket(sonos_conn, household_id, self_player_id
166166
return
167167
end
168168

169-
_, err =
170-
Router.open_socket_for_player(household_id, coordinator_id, coordinator.websocketUrl, api_key)
169+
_, err = Router.open_socket_for_player(
170+
household_id,
171+
coordinator_id,
172+
coordinator.player.websocketUrl,
173+
api_key
174+
)
171175
if err ~= nil then
172176
log.error(
173177
string.format(
@@ -302,10 +306,13 @@ end
302306
--- @return SonosConnection
303307
function SonosConnection.new(driver, device)
304308
log.debug(string.format("Creating new SonosConnection for %s", device.label))
305-
local self = setmetatable(
306-
{ driver = driver, device = device, _listener_uuids = {}, _initialized = false, _reconnecting = false },
307-
SonosConnection
308-
)
309+
local self = setmetatable({
310+
driver = driver,
311+
device = device,
312+
_listener_uuids = {},
313+
_initialized = false,
314+
_reconnecting = false,
315+
}, SonosConnection)
309316

310317
-- capture the label here in case something goes wonky like a callback being fired after a
311318
-- device is removed
@@ -358,19 +365,28 @@ function SonosConnection.new(driver, device)
358365
local household_id, current_coordinator =
359366
self.driver.sonos:get_coordinator_for_device(self.device)
360367
local _, player_id = self.driver.sonos:get_player_for_device(self.device)
361-
self.driver.sonos:update_household_info(header.householdId, body, self.device)
368+
self.driver.sonos:update_household_info(header.householdId, body, self.driver)
362369
self.driver.sonos:update_device_record_from_state(header.householdId, self.device)
363370
local _, updated_coordinator = self.driver.sonos:get_coordinator_for_device(self.device)
364371

372+
local bonded = self.device:get_field(PlayerFields.BONDED)
373+
if bonded then
374+
self:stop()
375+
end
376+
365377
Router.cleanup_unused_sockets(self.driver)
366378

367-
if not self:coordinator_running() then
368-
--TODO this is not infallible
369-
_open_coordinator_socket(self, household_id, player_id)
370-
end
379+
if not bonded then
380+
if not self:coordinator_running() then
381+
--TODO this is not infallible
382+
_open_coordinator_socket(self, household_id, player_id)
383+
end
371384

372-
if current_coordinator ~= updated_coordinator then
373-
self:refresh_subscriptions()
385+
if current_coordinator ~= updated_coordinator then
386+
self:refresh_subscriptions()
387+
end
388+
else
389+
self.device:offline()
374390
end
375391
elseif header.type == "playerVolume" then
376392
log.trace(string.format("PlayerVolume type message for %s", device_name))
@@ -477,7 +493,7 @@ function SonosConnection.new(driver, device)
477493
return
478494
end
479495

480-
local url_ip = lb_utils.force_url_table(coordinator_player.websocketUrl).host
496+
local url_ip = lb_utils.force_url_table(coordinator_player.player.websocketUrl).host
481497
local base_url = lb_utils.force_url_table(
482498
string.format("https://%s:%s", url_ip, SonosApi.DEFAULT_SONOS_PORT)
483499
)
@@ -590,7 +606,7 @@ function SonosConnection:coordinator_running()
590606
)
591607
)
592608
end
593-
return type(unique_key) == "string" and Router.is_connected(unique_key) and self._initialized
609+
return type(unique_key) == "string" and Router.is_connected(unique_key)
594610
end
595611

596612
function SonosConnection:refresh_subscriptions(maybe_reply_tx)

drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ function sonos_ssdp.spawn_persistent_ssdp_task()
300300
if info_to_send then
301301
if not (info_to_send.discovery_info and info_to_send.discovery_info.device) then
302302
log.error_with(
303-
{ hub_logs = true },
303+
{ hub_logs = false },
304304
st_utils.stringify_table(info_to_send, "Sonos Discovery Info has unexpected structure")
305305
)
306306
return

drivers/SmartThings/sonos/src/fields.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ local Fields = {}
55
Fields.SonosPlayerFields = {
66
_IS_INIT = "init",
77
_IS_SCANNING = "scanning",
8+
BONDED = "bonded",
89
CONNECTION = "conn",
910
UNIQUE_KEY = "unique_key",
1011
HOUSEHOLD_ID = "householdId",

drivers/SmartThings/sonos/src/init.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ if api_version < 14 then
3939
driver:start_ssdp_event_task()
4040
end
4141

42-
log.info "Starting Sonos run loop"
42+
log.info("Starting Sonos run loop")
4343
driver:run()
44-
log.info "Exiting Sonos run loop"
44+
log.info("Exiting Sonos run loop")

drivers/SmartThings/sonos/src/lifecycle_handlers.lua

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,12 @@ function SonosDriverLifecycleHandlers.initialize_device(driver, device)
164164
return
165165
end
166166
log.error_with(
167-
{ hub_logs = true },
168-
"Error handling Sonos player initialization: %s, error code: %s",
169-
error,
170-
(error_code or "N/A")
167+
{ hub_logs = false },
168+
string.format(
169+
"Error handling Sonos player initialization: %s, error code: %s",
170+
error,
171+
(error_code or "N/A")
172+
)
171173
)
172174
end
173175
end

drivers/SmartThings/sonos/src/sonos_driver.lua

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,27 @@ local ONE_HOUR_IN_SECONDS = 3600
4242
---@field private waiting_for_oauth_token boolean
4343
---@field private startup_state_received boolean
4444
---@field private devices_waiting_for_startup_state SonosDevice[]
45+
---@field package bonded_devices table<string, boolean> map of Device device_network_id to a boolean indicating if the device is currently known as a bonded device.
4546
---
4647
---@field public ssdp_task SonosPersistentSsdpTask?
4748
---@field private ssdp_event_thread_handle table?
4849
local SonosDriver = {}
4950

51+
---@param device SonosDevice
52+
function SonosDriver:update_bonded_device_tracking(device)
53+
local already_bonded = self.bonded_devices[device.device_network_id]
54+
local currently_bonded = device:get_field(PlayerFields.BONDED)
55+
self.bonded_devices[device.device_network_id] = currently_bonded
56+
57+
if currently_bonded and not already_bonded then
58+
device:offline()
59+
end
60+
61+
if already_bonded and not currently_bonded then
62+
SonosDriverLifecycleHandlers.initialize_device(self, device)
63+
end
64+
end
65+
5066
function SonosDriver:has_received_startup_state()
5167
return self.startup_state_received
5268
end
@@ -127,13 +143,13 @@ end
127143
function SonosDriver:handle_augmented_store_delete(update_key)
128144
if update_key == "endpointAppInfo" then
129145
if update_key == "endpointAppInfo" then
130-
log.trace "deleting endpoint app info"
146+
log.trace("deleting endpoint app info")
131147
self.oauth.endpoint_app_info = nil
132148
elseif update_key == "sonosOAuthToken" then
133-
log.trace "deleting OAuth Token"
149+
log.trace("deleting OAuth Token")
134150
self.oauth.token = nil
135151
elseif update_key == "force_oauth" then
136-
log.trace "deleting Force OAuth"
152+
log.trace("deleting Force OAuth")
137153
self.oauth.force_oauth = nil
138154
else
139155
log.debug(string.format("received delete of unexpected key: %s", update_key))
@@ -423,9 +439,15 @@ local function make_ssdp_event_handler(
423439
local event, recv_err = discovery_event_subscription:receive()
424440

425441
if event then
442+
local mac_addr = utils.extract_mac_addr(event.discovery_info.device)
426443
local unique_key = utils.sonos_unique_key_from_ssdp(event.ssdp_info)
427444
if
428-
event.force_refresh or not (unauthorized[unique_key] or discovered[unique_key])
445+
event.force_refresh
446+
or not (
447+
unauthorized[unique_key]
448+
or discovered[unique_key]
449+
or driver.bonded_devices[mac_addr]
450+
)
429451
then
430452
local _, api_key = driver:check_auth(event)
431453
local success, handle_err, err_code =
@@ -435,7 +457,7 @@ local function make_ssdp_event_handler(
435457
unauthorized[unique_key] = event
436458
end
437459
log.warn_with(
438-
{ hub_logs = true },
460+
{ hub_logs = false },
439461
string.format("Failed to handle discovered speaker: %s", handle_err)
440462
)
441463
else
@@ -483,12 +505,22 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device)
483505
-- speaker in a bonded set (e.g. a home theater system, a stereo pair, etc).
484506
-- These aren't the same as speaker groups, and bonded speakers can't be controlled
485507
-- via websocket at all. So we ignore all bonded non-primary speakers
486-
if #info.ssdp_info.group_id == 0 then
487-
return nil,
488-
string.format(
489-
"Player %s is a non-primary bonded Sonos device, ignoring",
490-
info.discovery_info.device.name
491-
)
508+
-- if then
509+
-- return nil,
510+
-- string.format(
511+
-- "Player %s is a non-primary bonded Sonos device, ignoring",
512+
-- info.discovery_info.device.name
513+
-- )
514+
-- end
515+
516+
local discovery_info_mac_addr = utils.extract_mac_addr(info.discovery_info.device)
517+
local bonded = (#info.ssdp_info.group_id == 0)
518+
self.bonded_devices[discovery_info_mac_addr] = bonded
519+
520+
local maybe_device = self:get_device_by_dni(discovery_info_mac_addr)
521+
if maybe_device then
522+
maybe_device:set_field(PlayerFields.BONDED, bonded, { persist = false })
523+
self:update_bonded_device_tracking(maybe_device)
492524
end
493525

494526
api_key = api_key or self:get_fallback_api_key()
@@ -543,7 +575,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device)
543575
end
544576

545577
--- @cast response SonosGroupsResponseBody
546-
self.sonos:update_household_info(info.ssdp_info.household_id, response)
578+
self.sonos:update_household_info(info.ssdp_info.household_id, response, self)
547579

548580
local device_to_update, device_mac_addr
549581

@@ -565,7 +597,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device)
565597
if not (info and info.discovery_info and info.discovery_info.device) then
566598
return nil, st_utils.stringify_table(info, "Sonos Discovery Info has unexpected structure")
567599
end
568-
device_mac_addr = utils.extract_mac_addr(info.discovery_info.device)
600+
device_mac_addr = discovery_info_mac_addr
569601
end
570602

571603
if not device_to_update then
@@ -578,7 +610,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device)
578610
if device_to_update then
579611
self.dni_to_device_id[device_mac_addr] = device_to_update.id
580612
self.sonos:associate_device_record(device_to_update, info)
581-
else
613+
elseif not bonded then
582614
local name = info.discovery_info.device.name
583615
or info.discovery_info.device.modelDisplayName
584616
or "Unknown Sonos Player"
@@ -631,6 +663,7 @@ function SonosDriver.new_driver_template()
631663
waiting_for_oauth_token = false,
632664
startup_state_received = false,
633665
devices_waiting_for_startup_state = {},
666+
bonded_devices = utils.new_mac_address_keyed_table(),
634667
dni_to_device_id = utils.new_mac_address_keyed_table(),
635668
lifecycle_handlers = SonosDriverLifecycleHandlers,
636669
capability_handlers = {

0 commit comments

Comments
 (0)