Skip to content

Commit fd45493

Browse files
committed
chore: Improve Sonos Memory Usage
@NoahCornell did some analysis of driver heap usage, and Sonos was disproportionately above the mean for the heap-space-per-device line fit on the dataset. This PR improves memory usage by limiting the information we keep resident from API responses to the fields that we actually use in regular operation. Additional background: One source of heap usage was unbounded memory growth due to task spawning on a certain error pathway, which he already fixed in a different PR. Another source was the fact that we were cacheing the entire API response for the Player and Group information for the LAN's Sonos topology. These API payloads are pretty large, and have actually gotten larger over time. This has been the case for this driver for a very long time; the decision to store the whole response object was made so that we would have the information available if we needed it in the future for bug fixes or enhancements. It turns out that the information we're utilizing hasn't really changed much over the last few years, so I'm feeling quite comfortable about excising the majority of the payload information at this point. We see pretty signifcant memory savings with these changes, and the savings should scale appreciably with device count, which is a big win.
1 parent 4d3ae9a commit fd45493

File tree

7 files changed

+279
-183
lines changed

7 files changed

+279
-183
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ local SonosRestApi = {}
7474
--- Query a Sonos Group IP address for individual player info
7575
---@param url table a URL table created by `net_url`
7676
---@param headers table<string,string>?
77-
---@return SonosDiscoveryInfo|SonosErrorResponse|nil
77+
---@return SonosDiscoveryInfoObject|SonosErrorResponse|nil
7878
---@return string|nil error
7979
function SonosRestApi.get_player_info(url, headers)
8080
url.path = "/api/v1/players/local/info"

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ local function _open_coordinator_socket(sonos_conn, household_id, self_player_id
169169
_, err = Router.open_socket_for_player(
170170
household_id,
171171
coordinator_id,
172-
coordinator.player.websocketUrl,
172+
coordinator.player.websocket_url,
173173
api_key
174174
)
175175
if err ~= nil then
@@ -406,7 +406,7 @@ function SonosConnection.new(driver, device)
406406
return
407407
end
408408
local group = household.groups[header.groupId] or { playerIds = {} }
409-
for _, player_id in ipairs(group.playerIds) do
409+
for _, player_id in ipairs(group.player_ids) do
410410
local device_for_player = self.driver:device_for_player(header.householdId, player_id)
411411
--- we've seen situations where these messages can be processed while a device
412412
--- is being deleted so we check for the presence of emit event as a proxy for
@@ -430,7 +430,7 @@ function SonosConnection.new(driver, device)
430430
return
431431
end
432432
local group = household.groups[header.groupId] or { playerIds = {} }
433-
for _, player_id in ipairs(group.playerIds) do
433+
for _, player_id in ipairs(group.player_ids) do
434434
local device_for_player = self.driver:device_for_player(header.householdId, player_id)
435435
--- we've seen situations where these messages can be processed while a device
436436
--- is being deleted so we check for the presence of emit event as a proxy for
@@ -453,7 +453,7 @@ function SonosConnection.new(driver, device)
453453
return
454454
end
455455
local group = household.groups[header.groupId] or { playerIds = {} }
456-
for _, player_id in ipairs(group.playerIds) do
456+
for _, player_id in ipairs(group.player_ids) do
457457
local device_for_player = self.driver:device_for_player(header.householdId, player_id)
458458
--- we've seen situations where these messages can be processed while a device
459459
--- is being deleted so we check for the presence of emit event as a proxy for
@@ -484,7 +484,7 @@ function SonosConnection.new(driver, device)
484484
return
485485
end
486486

487-
local url_ip = lb_utils.force_url_table(coordinator_player.player.websocketUrl).host
487+
local url_ip = lb_utils.force_url_table(coordinator_player.player.websocket_url).host
488488
local base_url = lb_utils.force_url_table(
489489
string.format("https://%s:%s", url_ip, SonosApi.DEFAULT_SONOS_PORT)
490490
)
@@ -510,7 +510,7 @@ function SonosConnection.new(driver, device)
510510
end
511511
self.driver.sonos:update_household_favorites(header.householdId, new_favorites)
512512

513-
for _, player_id in ipairs(group.playerIds) do
513+
for _, player_id in ipairs(group.player_ids) do
514514
local device_for_player =
515515
self.driver:device_for_player(header.householdId, player_id)
516516
--- we've seen situations where these messages can be processed while a device

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

Lines changed: 151 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,140 @@ local utils = require "utils"
99
local SonosApi = require "api"
1010
local SSDP_SCAN_INTERVAL_SECONDS = 600
1111

12+
local SONOS_DEFAULT_PORT = 1443
13+
local SONOS_DEFAULT_WSS_PATH = "websocket/api"
14+
local SONOS_DEFAULT_REST_PATH = "api/v1"
15+
16+
--- Cached information gathered during discovery scanning, created from a subset of the
17+
--- found [SonosSSDPInfo](lua://SonosSSDPInfo) and [SonosDiscoveryInfoObject](lua://SonosDiscoveryInfoObject)
18+
---
19+
--- @class SpeakerDiscoveryInfo
20+
--- @field public unique_key UniqueKey
21+
--- @field public mac_addr string
22+
--- @field public expires_at integer
23+
--- @field public ipv4 string
24+
--- @field public port integer
25+
--- @field public household_id HouseholdId
26+
--- @field public player_id PlayerId
27+
--- @field public name string
28+
--- @field public model string
29+
--- @field public model_display_name string
30+
--- @field public sw_gen integer
31+
--- @field public wss_url table
32+
--- @field public rest_url table
33+
--- @field public is_group_coordinator boolean
34+
--- @field public group_name string? nil if a speaker is the non-primary in a bonded set
35+
--- @field public group_id GroupId? nil if a speaker is the non-primary in a bonded set
36+
--- @field package wss_path string? nil if equivalent to the default value; does not include leading slash!
37+
--- @field package rest_path string? nil if equivalent to the default value; does not include leading slash!
38+
local SpeakerDiscoveryInfo = {}
39+
40+
---@param ssdp_info SonosSSDPInfo
41+
---@param discovery_info SonosDiscoveryInfoObject
42+
---@return SpeakerDiscoveryInfo info
43+
function SpeakerDiscoveryInfo.new(ssdp_info, discovery_info)
44+
local mac_addr = utils.extract_mac_addr(discovery_info.device)
45+
local port, rest_path = string.match(discovery_info.restUrl, "^.*:(%d*)/(.*)$")
46+
local _, wss_path = string.match(discovery_info.websocketUrl, "^.*:(%d*)/(.*)$")
47+
port = tonumber(port) or SONOS_DEFAULT_PORT
48+
49+
local ret = {
50+
unique_key = utils.sonos_unique_key_from_ssdp(ssdp_info),
51+
expires_at = ssdp_info.expires_at,
52+
ipv4 = ssdp_info.ip,
53+
port = port,
54+
mac_addr = mac_addr,
55+
household_id = ssdp_info.household_id,
56+
player_id = ssdp_info.player_id,
57+
name = discovery_info.device.name,
58+
model = discovery_info.device.model,
59+
model_display_name = discovery_info.device.modelDisplayName,
60+
sw_gen = discovery_info.device.swGen,
61+
is_group_coordinator = ssdp_info.is_group_coordinator,
62+
}
63+
64+
if type(ssdp_info.group_name) == "string" and #ssdp_info.group_name > 0 then
65+
ret.group_name = ssdp_info.group_name
66+
end
67+
68+
if type(ssdp_info.group_id) == "string" and #ssdp_info.group_id > 0 then
69+
ret.group_id = ssdp_info.group_id
70+
end
71+
72+
if type(wss_path) == "string" and #wss_path > 0 and wss_path ~= SONOS_DEFAULT_WSS_PATH then
73+
ret.wss_path = wss_path
74+
end
75+
76+
if type(rest_path) == "string" and #rest_path > 0 and rest_path ~= SONOS_DEFAULT_REST_PATH then
77+
ret.rest_path = rest_path
78+
end
79+
80+
local proxy_index = function(_, k)
81+
if k == "rest_url" and not rawget(ret, "rest_url") then
82+
rawset(
83+
ret,
84+
"rest_url",
85+
net_url.parse(
86+
string.format(
87+
"https://%s:%s/%s",
88+
ret.ipv4,
89+
ret.port,
90+
ret.rest_path or SONOS_DEFAULT_REST_PATH
91+
)
92+
)
93+
)
94+
end
95+
96+
if k == "wss_url" and not rawget(ret, "wss_url") then
97+
rawset(
98+
ret,
99+
"wss_url",
100+
net_url.parse(
101+
string.format(
102+
"https://%s:%s/%s",
103+
ret.ipv4,
104+
ret.port,
105+
ret.wss_path or SONOS_DEFAULT_WSS_PATH
106+
)
107+
)
108+
)
109+
end
110+
111+
return rawget(ret, k)
112+
end
113+
114+
local proxy_newindex = function(_, _, _)
115+
error("attempt to index a read-only table", 2)
116+
end
117+
118+
for k, v in pairs(SpeakerDiscoveryInfo) do
119+
rawset(ret, k, v)
120+
end
121+
122+
return setmetatable(ret, { __index = proxy_index, __newindex = proxy_newindex })
123+
end
124+
125+
function SpeakerDiscoveryInfo:is_bonded()
126+
return (self.group_id == nil)
127+
end
128+
129+
---@return SonosSSDPInfo
130+
function SpeakerDiscoveryInfo:as_ssdp_info()
131+
---@type SonosSSDPInfo
132+
return {
133+
ip = self.ipv4,
134+
group_id = self.group_id or "",
135+
group_name = self.group_name or "",
136+
expires_at = self.expires_at,
137+
player_id = self.player_id,
138+
wss_url = self.wss_url:build(),
139+
household_id = self.household_id,
140+
is_group_coordinator = self.is_group_coordinator,
141+
}
142+
end
143+
12144
local sonos_ssdp = {}
145+
sonos_ssdp.SpeakerDiscoveryInfo = SpeakerDiscoveryInfo
13146

14147
---@module 'luncheon.headers'
15148

@@ -160,8 +293,8 @@ end
160293

161294
---@class SonosPersistentSsdpTask
162295
---@field package ssdp_search_handle SsdpSearchHandle
163-
---@field package player_info_by_sonos_ids table<UniqueKey, { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo }>
164-
---@field package player_info_by_mac_addrs table<string, { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo }>
296+
---@field package player_info_by_sonos_ids table<UniqueKey, SpeakerDiscoveryInfo>
297+
---@field package player_info_by_mac_addrs table<string, SpeakerDiscoveryInfo>
165298
---@field package waiting_for_unique_key table<UniqueKey, table[]>
166299
---@field package waiting_for_mac_addr table<string, table[]>
167300
---@field package control_tx table
@@ -217,7 +350,7 @@ function SonosPersistentSsdpTask:get_player_info(reply_tx, ...)
217350
end
218351

219352
local maybe_existing = lookup_table[lookup_key]
220-
if maybe_existing and maybe_existing.ssdp_info.expires_at > os.time() then
353+
if maybe_existing and maybe_existing.expires_at > os.time() then
221354
reply_tx:send(maybe_existing)
222355
return
223356
end
@@ -267,11 +400,12 @@ function sonos_ssdp.spawn_persistent_ssdp_task()
267400
local maybe_known = task_handle.player_info_by_sonos_ids[unique_key]
268401
local is_new_information = not (
269402
maybe_known
270-
and maybe_known.ssdp_info.expires_at > os.time()
271-
and sonos_ssdp.ssdp_info_eq(maybe_known.ssdp_info, sonos_ssdp_info)
403+
and maybe_known.expires_at > os.time()
404+
and sonos_ssdp.ssdp_info_eq(maybe_known:as_ssdp_info(), sonos_ssdp_info)
272405
)
273406

274-
local info_to_send
407+
local speaker_info
408+
local event_bus_msg
275409

276410
if is_new_information then
277411
local headers = SonosApi.make_headers()
@@ -283,30 +417,21 @@ function sonos_ssdp.spawn_persistent_ssdp_task()
283417
)
284418
if not discovery_info then
285419
log.error(string.format("Error getting discovery info from SSDP response: %s", err))
420+
elseif discovery_info._objectType == "globalError" then
421+
log.error(string.format("Error message in discovery info: %s", discovery_info.errorCode))
286422
else
287-
local unified_info =
288-
{ ssdp_info = sonos_ssdp_info, discovery_info = discovery_info, force_refresh = true }
289-
task_handle.player_info_by_sonos_ids[unique_key] = unified_info
290-
info_to_send = unified_info
423+
speaker_info = SpeakerDiscoveryInfo.new(sonos_ssdp_info, discovery_info)
424+
task_handle.player_info_by_sonos_ids[unique_key] = speaker_info
425+
event_bus_msg = { speaker_info = speaker_info, force_refresh = true }
291426
end
292427
else
293-
info_to_send = {
294-
ssdp_info = maybe_known.ssdp_info,
295-
discovery_info = maybe_known.discovery_info,
296-
force_refresh = false,
297-
}
428+
speaker_info = maybe_known
429+
event_bus_msg = { speaker_info = speaker_info, force_refresh = false }
298430
end
299431

300-
if info_to_send then
301-
if not (info_to_send.discovery_info and info_to_send.discovery_info.device) then
302-
log.error_with(
303-
{ hub_logs = false },
304-
st_utils.stringify_table(info_to_send, "Sonos Discovery Info has unexpected structure")
305-
)
306-
return
307-
end
308-
event_bus:send(info_to_send)
309-
local mac_addr = utils.extract_mac_addr(info_to_send.discovery_info.device)
432+
if speaker_info then
433+
event_bus:send(event_bus_msg)
434+
local mac_addr = speaker_info.mac_addr
310435
local waiting_handles = task_handle.waiting_for_unique_key[unique_key] or {}
311436

312437
log.debug(st_utils.stringify_table(waiting_handles, "waiting for unique keys", true))
@@ -318,7 +443,7 @@ function sonos_ssdp.spawn_persistent_ssdp_task()
318443
st_utils.stringify_table(waiting_handles, "waiting for unique keys and mac addresses", true)
319444
)
320445
for _, reply_tx in ipairs(waiting_handles) do
321-
reply_tx:send(info_to_send)
446+
reply_tx:send(speaker_info)
322447
end
323448

324449
task_handle.waiting_for_unique_key[unique_key] = {}

drivers/SmartThings/sonos/src/lifecycle_handlers.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function SonosDriverLifecycleHandlers.initialize_device(driver, device)
6868
if not info then
6969
device.log.warn(string.format("error receiving device info: %s", recv_err))
7070
else
71-
---@cast info { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo, force_refresh: boolean }
71+
---@cast info SpeakerDiscoveryInfo
7272
local auth_success, api_key_or_err = driver:check_auth(info)
7373
if not auth_success then
7474
device:offline()

0 commit comments

Comments
 (0)