Skip to content

Commit 90f2310

Browse files
committed
feat: Preliminary Support for Viper-backed OAuth in Sonos
1 parent dcded0b commit 90f2310

File tree

6 files changed

+238
-46
lines changed

6 files changed

+238
-46
lines changed

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

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ local function _do_send_to_group(driver, device, payload)
2424
local household_id, group_id = driver.sonos:get_group_for_device(device)
2525
payload[1].householdId = household_id
2626
payload[1].groupId = group_id
27+
local maybe_token, err = driver:get_oauth_token()
28+
if err then
29+
log.warn(string.format("notice: get_oauth_token -> %s", err))
30+
end
31+
32+
if maybe_token then
33+
payload[1].authorization = string.format("Bearer %s", maybe_token.access_token)
34+
end
2735

2836
_do_send(device, payload)
2937
end
@@ -32,7 +40,14 @@ local function _do_send_to_self(driver, device, payload)
3240
local household_id, player_id = driver.sonos:get_player_for_device(device)
3341
payload[1].householdId = household_id
3442
payload[1].playerId = player_id
43+
local maybe_token, err = driver:get_oauth_token()
44+
if err then
45+
log.warn(string.format("notice: get_oauth_token -> %s", err))
46+
end
3547

48+
if maybe_token then
49+
payload[1].authorization = string.format("Bearer %s", maybe_token.access_token)
50+
end
3651
_do_send(device, payload)
3752
end
3853

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

+28-6
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,43 @@ local SonosRestApi = {}
7272
--- Query a Sonos Group IP address for individual player info
7373
--- @param ip string the IP address of the player
7474
--- @param port integer the port number of the player
75+
--- @param access_token string? the OAuth Access Token
7576
--- @return SonosDiscoveryInfo|nil
7677
--- @return string|nil error
77-
function SonosRestApi.get_player_info(ip, port)
78+
function SonosRestApi.get_player_info(ip, port, access_token)
7879
local url = "https://" .. ip .. ":" .. port .. "/api/v1/players/local/info"
79-
return process_rest_response(RestClient.one_shot_get(url, HEADERS))
80+
local headers = {}
81+
for k,v in pairs(HEADERS) do
82+
headers[k] = v
83+
end
84+
if type(access_token) == "string" then
85+
headers['Authorization'] = string.format("Bearer %s", access_token)
86+
end
87+
return process_rest_response(RestClient.one_shot_get(url, headers))
8088
end
8189

82-
function SonosRestApi.get_groups_info(ip, port, household)
90+
function SonosRestApi.get_groups_info(ip, port, household, access_token)
8391
local url = string.format("https://%s:%s/api/v1/households/%s/groups", ip, port, household)
84-
return process_rest_response(RestClient.one_shot_get(url, HEADERS))
92+
local headers = {}
93+
for k,v in pairs(HEADERS) do
94+
headers[k] = v
95+
end
96+
if type(access_token) == "string" then
97+
headers['Authorization'] = string.format("Bearer %s", access_token)
98+
end
99+
return process_rest_response(RestClient.one_shot_get(url, headers))
85100
end
86101

87-
function SonosRestApi.get_favorites(ip, port, household)
102+
function SonosRestApi.get_favorites(ip, port, household, access_token)
88103
local url = string.format("https://%s:%s/api/v1/households/%s/favorites", ip, port, household)
89-
return process_rest_response(RestClient.one_shot_get(url, HEADERS))
104+
local headers = {}
105+
for k,v in pairs(HEADERS) do
106+
headers[k] = v
107+
end
108+
if type(access_token) == "string" then
109+
headers['Authorization'] = string.format("Bearer %s", access_token)
110+
end
111+
return process_rest_response(RestClient.one_shot_get(url, headers))
90112
end
91113

92114
return SonosRestApi

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

+93-30
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,26 @@ local favorites_version = ""
4040
---@param sonos_conn SonosConnection
4141
---@param namespaces string[]
4242
---@param command "subscribe"|"unsubscribe"
43-
local _update_subscriptions_helper = function(sonos_conn, householdId, playerId, groupId, namespaces, command)
43+
local _update_subscriptions_helper = function(sonos_conn, householdId, playerId, groupId, namespaces,
44+
command)
4445
for _, namespace in ipairs(namespaces) do
46+
local wss_msg_header = {
47+
namespace = namespace,
48+
command = command,
49+
householdId = householdId,
50+
groupId = groupId,
51+
playerId = playerId
52+
}
53+
local maybe_token, err = sonos_conn.driver:get_oauth_token()
54+
if err then
55+
log.warn(string.format("notice: get_oauth_token -> %s", err))
56+
end
57+
58+
if maybe_token then
59+
wss_msg_header.authorization = string.format("Bearer %s", maybe_token.access_token)
60+
end
4561
local payload_table = {
46-
{
47-
namespace = namespace,
48-
command = command,
49-
householdId = householdId,
50-
groupId = groupId,
51-
playerId = playerId
52-
},
62+
wss_msg_header,
5363
{}
5464
}
5565
local payload = json.encode(payload_table)
@@ -82,7 +92,8 @@ end
8292
---@param self_player_id PlayerId
8393
local function _open_coordinator_socket(sonos_conn, household_id, self_player_id)
8494
log.debug("Open coordinator socket for: " .. sonos_conn.device.label)
85-
local _, coordinator_id, err = sonos_conn.driver.sonos:get_coordinator_for_device(sonos_conn.device)
95+
local _, coordinator_id, err = sonos_conn.driver.sonos:get_coordinator_for_device(sonos_conn
96+
.device)
8697
if err ~= nil then
8798
log.error(
8899
string.format(
@@ -95,14 +106,16 @@ local function _open_coordinator_socket(sonos_conn, household_id, self_player_id
95106
if coordinator_id ~= self_player_id then
96107
local household = sonos_conn.driver.sonos:get_household(household_id)
97108
if household == nil then
98-
log.error(string.format("Cannot open coordinator socket, houshold doesn't exist: %s", household_id))
109+
log.error(string.format("Cannot open coordinator socket, houshold doesn't exist: %s",
110+
household_id))
99111
return
100112
end
101113

102114
local coordinator = household.players[coordinator_id]
103115
if coordinator == nil then
104116
log.error(st_utils.stringify_table(
105-
{household = sonos_conn.driver.sonos:get_household(household_id)}, string.format("Coordinator doesn't exist for player: %s", sonos_conn.device.label), false
117+
{ household = sonos_conn.driver.sonos:get_household(household_id) },
118+
string.format("Coordinator doesn't exist for player: %s", sonos_conn.device.label), false
106119
))
107120
return
108121
end
@@ -155,11 +168,27 @@ end
155168
---@param sonos_conn SonosConnection
156169
local function _spawn_reconnect_task(sonos_conn)
157170
log.debug("Spawning reconnect task for ", sonos_conn.device.label)
171+
local token_receive_handle, err = sonos_conn.driver:get_oauth_token_receive_handle()
172+
if not token_receive_handle then
173+
log.warn(string.format("error creating oauth token receive handle for respawn task: %s", err))
174+
end
158175
cosock.spawn(function()
176+
local token, channel_error = nil, nil
159177
local backoff = backoff_builder(60, 1, 0.1)
160178
while not sonos_conn:is_running() do
161-
local start_success = sonos_conn:start()
162-
if start_success then return end
179+
if sonos_conn.driver.waiting_for_token and token_receive_handle then
180+
token, channel_error = token_receive_handle:receive()
181+
if not token then
182+
log.warn(string.format("Error requesting token: %s", channel_error))
183+
local _, get_token_err = sonos_conn.driver:get_oauth_token()
184+
if get_token_err then
185+
log.warn(string.format("notice: get_oauth_token -> %s", get_token_err))
186+
end
187+
end
188+
else
189+
local start_success = sonos_conn:start()
190+
if start_success then return end
191+
end
163192
cosock.socket.sleep(backoff())
164193
end
165194
end, string.format("%s Reconnect Task", sonos_conn.device.label))
@@ -171,7 +200,8 @@ end
171200
--- @return SonosConnection
172201
function SonosConnection.new(driver, device)
173202
log.debug(string.format("Creating new SonosConnection for %s", device.label))
174-
local self = setmetatable({ driver = driver, device = device, _listener_uuids = {}, _initialized = false },
203+
local self = setmetatable(
204+
{ driver = driver, device = device, _listener_uuids = {}, _initialized = false },
175205
SonosConnection)
176206

177207
-- capture the label here in case something goes wonky like a callback being fired after a
@@ -185,14 +215,16 @@ function SonosConnection.new(driver, device)
185215
local success = table.remove(json_result, 1)
186216
if not success then
187217
log.error(st_utils.stringify_table(
188-
{response_body = msg.data, json = json_result}, "Couldn't decode JSON in WebSocket callback:", false
218+
{ response_body = msg.data, json = json_result },
219+
"Couldn't decode JSON in WebSocket callback:", false
189220
))
190221
return
191222
end
192223
local header, body = table.unpack(table.unpack(json_result))
193224
if header.type == "groups" then
194225
log.trace(string.format("Groups type message for %s", device_name))
195-
local household_id, current_coordinator = self.driver.sonos:get_coordinator_for_device(self.device)
226+
local household_id, current_coordinator = self.driver.sonos:get_coordinator_for_device(self
227+
.device)
196228
local _, player_id = self.driver.sonos:get_player_for_device(self.device)
197229
self.driver.sonos:update_household_info(header.householdId, body, self.device)
198230
local _, updated_coordinator = self.driver.sonos:get_coordinator_for_device(self.device)
@@ -225,8 +257,9 @@ function SonosConnection.new(driver, device)
225257
local household = self.driver.sonos:get_household(header.householdId)
226258
if household == nil or household.groups == nil then
227259
log.error(st_utils.stringify_table(
228-
{response_body = msg.data, household = household or header.householdId},
229-
"Received groupVolume message for non-existent household or household groups dont exist", false
260+
{ response_body = msg.data, household = household or header.householdId },
261+
"Received groupVolume message for non-existent household or household groups dont exist",
262+
false
230263
))
231264
return
232265
end
@@ -246,8 +279,9 @@ function SonosConnection.new(driver, device)
246279
local household = self.driver.sonos:get_household(header.householdId)
247280
if household == nil or household.groups == nil then
248281
log.error(st_utils.stringify_table(
249-
{response_body = msg.data, household = household or header.householdId},
250-
"Received playbackStatus message for non-existent household or household groups dont exist", false
282+
{ response_body = msg.data, household = household or header.householdId },
283+
"Received playbackStatus message for non-existent household or household groups dont exist",
284+
false
251285
))
252286
return
253287
end
@@ -266,8 +300,9 @@ function SonosConnection.new(driver, device)
266300
local household = self.driver.sonos:get_household(header.householdId)
267301
if household == nil or household.groups == nil then
268302
log.error(st_utils.stringify_table(
269-
{response_body = msg.data, household = household or header.householdId},
270-
"Received metadataStatus message for non-existent household or household groups dont exist", false
303+
{ response_body = msg.data, household = household or header.householdId },
304+
"Received metadataStatus message for non-existent household or household groups dont exist",
305+
false
271306
))
272307
return
273308
end
@@ -289,19 +324,21 @@ function SonosConnection.new(driver, device)
289324
local household = self.driver.sonos:get_household(header.householdId) or { groups = {} }
290325

291326
for group_id, group in pairs(household.groups) do
292-
local coordinator_id = self.driver.sonos:get_coordinator_for_group(header.householdId, group_id)
327+
local coordinator_id = self.driver.sonos:get_coordinator_for_group(header.householdId,
328+
group_id)
293329
local coordinator_player = household.players[coordinator_id]
294330
if coordinator_player == nil then
295331
log.error(st_utils.stringify_table(
296-
{household = household, coordinator_id = coordinator_id}, "Received message for non-existent coordinator player", false
332+
{ household = household, coordinator_id = coordinator_id },
333+
"Received message for non-existent coordinator player", false
297334
))
298335
return
299336
end
300337

301338
local url_ip = lb_utils.force_url_table(coordinator_player.websocketUrl).host
302339

303340
local favorites_response, err, _ =
304-
SonosRestApi.get_favorites(url_ip, SonosApi.DEFAULT_SONOS_PORT, header.householdId)
341+
SonosRestApi.get_favorites(url_ip, SonosApi.DEFAULT_SONOS_PORT, header.householdId)
305342

306343
if err or not favorites_response then
307344
log.error("Error querying for favorites: " .. err)
@@ -310,7 +347,10 @@ function SonosConnection.new(driver, device)
310347
for _, favorite in ipairs(favorites_response.items or {}) do
311348
local new_item = { id = favorite.id, name = favorite.name }
312349
if favorite.imageUrl then new_item.imageUrl = favorite.imageUrl end
313-
if favorite.service and favorite.service.name then new_item.mediaSource = favorite.service.name end
350+
if favorite.service and favorite.service.name then
351+
new_item.mediaSource = favorite
352+
.service.name
353+
end
314354
table.insert(new_favorites, new_item)
315355
end
316356
self.driver.sonos:update_household_favorites(header.householdId, new_favorites)
@@ -329,11 +369,14 @@ function SonosConnection.new(driver, device)
329369
end
330370
end
331371
else
332-
log.warn(string.format("WebSocket Message for %s did not have a data payload: %s", device_name, st_utils.stringify_table(msg)))
372+
log.warn(string.format("WebSocket Message for %s did not have a data payload: %s", device_name,
373+
st_utils.stringify_table(msg)))
333374
end
334375
end
335376

336377
self.on_error = function(uuid, err)
378+
-- TODO: Implement a call to `getToken` here on certain failures, once I actually
379+
-- know what an auth failure over websocket is going to look like.
337380
log.error(err or ("unknown websocket error for " .. (self.device.label or "unknown device")))
338381
end
339382

@@ -351,8 +394,9 @@ end
351394
function SonosConnection:is_running()
352395
local self_running = self:self_running()
353396
local coord_running = self:coordinator_running()
354-
log.debug(string.format("%s all connections running? %s", self.device.label, st_utils.stringify_table({coordinator = self_running, mine = self_running})))
355-
return self_running and coord_running
397+
log.debug(string.format("%s all connections running? %s", self.device.label,
398+
st_utils.stringify_table({ coordinator = self_running, mine = self_running })))
399+
return self_running and coord_running
356400
end
357401

358402
--- Whether or not the connection has a live websocket connection
@@ -425,7 +469,26 @@ function SonosConnection:start()
425469
_open_coordinator_socket(self, household_id, player_id)
426470
end
427471

428-
self:refresh_subscriptions()
472+
473+
local reply_tx, reply_rx = cosock.channel.new()
474+
475+
self:refresh_subscriptions(reply_tx)
476+
477+
local reply = reply_rx:receive()
478+
-- TODO handle logic to abort connection if the refresh is refused
479+
-- once we know what a forbidden/unauthorized error will look like.
480+
local connection_successful = true
481+
if not connection_successful then
482+
if not self.driver.waiting_for_token then
483+
local err = self.driver:get_oauth_token()
484+
if err then
485+
log.warn(string.format("notice: get_oauth_token -> %s", err))
486+
end
487+
self.driver.waiting_for_token = true
488+
self.on_close()
489+
end
490+
return false
491+
end
429492
local coordinator_id = self.driver.sonos:get_coordinator_for_player(household_id, player_id)
430493
if Router.is_connected(player_id) and Router.is_connected(coordinator_id) then
431494
self.device:online()

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

+6-3
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ cosock.spawn(function()
107107
end
108108

109109
log.trace(string.format("Sending message over websocket for target %s", target))
110-
wss:send(Message.new(Message.TEXT, msg.body))
110+
local response = table.pack(wss:send(Message.new(Message.TEXT, msg.body)))
111+
if msg.header.reply_tx then
112+
msg.header.reply_tx:send(response)
113+
end
111114
end
112115
elseif msg.type and msg.data and recv.id then -- websocket message received
113116
log.trace(string.format("Received WebSocket message, fanning out to listeners"))
@@ -229,9 +232,9 @@ function SonosWebSocketRouter.open_socket_for_player(player_id, wss_url)
229232
end
230233
end
231234

232-
function SonosWebSocketRouter.send_message_to_player(target, json_payload)
235+
function SonosWebSocketRouter.send_message_to_player(target, json_payload, reply_tx)
233236
local websocket_message = {
234-
header = { type = "WebSocket", target = target },
237+
header = { type = "WebSocket", target = target, reply_tx = reply_tx},
235238
body = json_payload
236239
}
237240

0 commit comments

Comments
 (0)