diff --git a/drivers/SmartThings/sonos/src/api/rest.lua b/drivers/SmartThings/sonos/src/api/rest.lua index bc2dfe498f..93fbcf0f36 100644 --- a/drivers/SmartThings/sonos/src/api/rest.lua +++ b/drivers/SmartThings/sonos/src/api/rest.lua @@ -74,7 +74,7 @@ local SonosRestApi = {} --- Query a Sonos Group IP address for individual player info ---@param url table a URL table created by `net_url` ---@param headers table? ----@return SonosDiscoveryInfo|SonosErrorResponse|nil +---@return SonosDiscoveryInfoObject|SonosErrorResponse|nil ---@return string|nil error function SonosRestApi.get_player_info(url, headers) url.path = "/api/v1/players/local/info" diff --git a/drivers/SmartThings/sonos/src/api/sonos_connection.lua b/drivers/SmartThings/sonos/src/api/sonos_connection.lua index 35b1cf8885..48c8828fc8 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_connection.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_connection.lua @@ -169,7 +169,7 @@ local function _open_coordinator_socket(sonos_conn, household_id, self_player_id _, err = Router.open_socket_for_player( household_id, coordinator_id, - coordinator.player.websocketUrl, + coordinator.player.websocket_url, api_key ) if err ~= nil then @@ -406,7 +406,7 @@ function SonosConnection.new(driver, device) return end local group = household.groups[header.groupId] or { playerIds = {} } - for _, player_id in ipairs(group.playerIds) do + for _, player_id in ipairs(group.player_ids) do local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- 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) return end local group = household.groups[header.groupId] or { playerIds = {} } - for _, player_id in ipairs(group.playerIds) do + for _, player_id in ipairs(group.player_ids) do local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- 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) return end local group = household.groups[header.groupId] or { playerIds = {} } - for _, player_id in ipairs(group.playerIds) do + for _, player_id in ipairs(group.player_ids) do local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- 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) return end - local url_ip = lb_utils.force_url_table(coordinator_player.player.websocketUrl).host + local url_ip = lb_utils.force_url_table(coordinator_player.player.websocket_url).host local base_url = lb_utils.force_url_table( string.format("https://%s:%s", url_ip, SonosApi.DEFAULT_SONOS_PORT) ) @@ -510,7 +510,7 @@ function SonosConnection.new(driver, device) end self.driver.sonos:update_household_favorites(header.householdId, new_favorites) - for _, player_id in ipairs(group.playerIds) do + for _, player_id in ipairs(group.player_ids) do local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device diff --git a/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua index be3ae6092a..5baf30efe8 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua @@ -9,7 +9,140 @@ local utils = require "utils" local SonosApi = require "api" local SSDP_SCAN_INTERVAL_SECONDS = 600 +local SONOS_DEFAULT_PORT = 1443 +local SONOS_DEFAULT_WSS_PATH = "websocket/api" +local SONOS_DEFAULT_REST_PATH = "api/v1" + +--- Cached information gathered during discovery scanning, created from a subset of the +--- found [SonosSSDPInfo](lua://SonosSSDPInfo) and [SonosDiscoveryInfoObject](lua://SonosDiscoveryInfoObject) +--- +--- @class SpeakerDiscoveryInfo +--- @field public unique_key UniqueKey +--- @field public mac_addr string +--- @field public expires_at integer +--- @field public ipv4 string +--- @field public port integer +--- @field public household_id HouseholdId +--- @field public player_id PlayerId +--- @field public name string +--- @field public model string +--- @field public model_display_name string +--- @field public sw_gen integer +--- @field public wss_url table +--- @field public rest_url table +--- @field public is_group_coordinator boolean +--- @field public group_name string? nil if a speaker is the non-primary in a bonded set +--- @field public group_id GroupId? nil if a speaker is the non-primary in a bonded set +--- @field package wss_path string? nil if equivalent to the default value; does not include leading slash! +--- @field package rest_path string? nil if equivalent to the default value; does not include leading slash! +local SpeakerDiscoveryInfo = {} + +local proxy_index = function(self, k) + if k == "rest_url" and not rawget(self, "rest_url") then + rawset( + self, + "rest_url", + net_url.parse( + string.format( + "https://%s:%s/%s", + self.ipv4, + self.port, + self.rest_path or SONOS_DEFAULT_REST_PATH + ) + ) + ) + end + + if k == "wss_url" and not rawget(self, "wss_url") then + rawset( + self, + "wss_url", + net_url.parse( + string.format( + "https://%s:%s/%s", + self.ipv4, + self.port, + self.wss_path or SONOS_DEFAULT_WSS_PATH + ) + ) + ) + end + + return rawget(self, k) +end + +local proxy_newindex = function(_, _, _) + error("attempt to index a read-only table", 2) +end + +---@param ssdp_info SonosSSDPInfo +---@param discovery_info SonosDiscoveryInfoObject +---@return SpeakerDiscoveryInfo info +function SpeakerDiscoveryInfo.new(ssdp_info, discovery_info) + local mac_addr = utils.extract_mac_addr(discovery_info.device) + local port, rest_path = string.match(discovery_info.restUrl, "^.*:(%d*)/(.*)$") + local _, wss_path = string.match(discovery_info.websocketUrl, "^.*:(%d*)/(.*)$") + port = tonumber(port) or SONOS_DEFAULT_PORT + + local ret = { + unique_key = utils.sonos_unique_key_from_ssdp(ssdp_info), + expires_at = ssdp_info.expires_at, + ipv4 = ssdp_info.ip, + port = port, + mac_addr = mac_addr, + household_id = ssdp_info.household_id, + player_id = ssdp_info.player_id, + name = discovery_info.device.name, + model = discovery_info.device.model, + model_display_name = discovery_info.device.modelDisplayName, + sw_gen = discovery_info.device.swGen, + is_group_coordinator = ssdp_info.is_group_coordinator, + } + + if type(ssdp_info.group_name) == "string" and #ssdp_info.group_name > 0 then + ret.group_name = ssdp_info.group_name + end + + if type(ssdp_info.group_id) == "string" and #ssdp_info.group_id > 0 then + ret.group_id = ssdp_info.group_id + end + + if type(wss_path) == "string" and #wss_path > 0 and wss_path ~= SONOS_DEFAULT_WSS_PATH then + ret.wss_path = wss_path + end + + if type(rest_path) == "string" and #rest_path > 0 and rest_path ~= SONOS_DEFAULT_REST_PATH then + ret.rest_path = rest_path + end + + for k, v in pairs(SpeakerDiscoveryInfo) do + rawset(ret, k, v) + end + + return setmetatable(ret, { __index = proxy_index, __newindex = proxy_newindex }) +end + +function SpeakerDiscoveryInfo:is_bonded() + return (self.group_id == nil) +end + +---@return SonosSSDPInfo +function SpeakerDiscoveryInfo:as_ssdp_info() + ---@type SonosSSDPInfo + return { + ip = self.ipv4, + group_id = self.group_id or "", + group_name = self.group_name or "", + expires_at = self.expires_at, + player_id = self.player_id, + wss_url = self.wss_url:build(), + household_id = self.household_id, + is_group_coordinator = self.is_group_coordinator, + } +end + local sonos_ssdp = {} +sonos_ssdp.SpeakerDiscoveryInfo = SpeakerDiscoveryInfo ---@module 'luncheon.headers' @@ -147,6 +280,18 @@ function sonos_ssdp.ssdp_info_eq(a, b) and (a.wss_url == b.wss_url) end +---@param disco_info SpeakerDiscoveryInfo +---@param ssdp_info SonosSSDPInfo +function sonos_ssdp.known_speaker_matches_ssdp_info(disco_info, ssdp_info) + return (disco_info.group_id == ssdp_info.group_id) + and (disco_info.group_name == ssdp_info.group_name) + and (disco_info.household_id == ssdp_info.household_id) + and (disco_info.ipv4 == ssdp_info.ip) + and (disco_info.is_group_coordinator == ssdp_info.is_group_coordinator) + and (disco_info.player_id == ssdp_info.player_id) + and (disco_info.wss_url:build() == ssdp_info.wss_url) +end + ---@return SsdpSearchTerm the Sonos ssdp search term ---@return SsdpSearchKwargs the default set of keyword arguments for Sonos function sonos_ssdp.new_search_term_context() @@ -160,8 +305,8 @@ end ---@class SonosPersistentSsdpTask ---@field package ssdp_search_handle SsdpSearchHandle ----@field package player_info_by_sonos_ids table ----@field package player_info_by_mac_addrs table +---@field package player_info_by_sonos_ids table +---@field package player_info_by_mac_addrs table ---@field package waiting_for_unique_key table ---@field package waiting_for_mac_addr table ---@field package control_tx table @@ -217,7 +362,7 @@ function SonosPersistentSsdpTask:get_player_info(reply_tx, ...) end local maybe_existing = lookup_table[lookup_key] - if maybe_existing and maybe_existing.ssdp_info.expires_at > os.time() then + if maybe_existing and maybe_existing.expires_at > os.time() then reply_tx:send(maybe_existing) return end @@ -267,11 +412,12 @@ function sonos_ssdp.spawn_persistent_ssdp_task() local maybe_known = task_handle.player_info_by_sonos_ids[unique_key] local is_new_information = not ( maybe_known - and maybe_known.ssdp_info.expires_at > os.time() - and sonos_ssdp.ssdp_info_eq(maybe_known.ssdp_info, sonos_ssdp_info) + and maybe_known.expires_at > os.time() + and sonos_ssdp.known_speaker_matches_ssdp_info(maybe_known, sonos_ssdp_info) ) - local info_to_send + local speaker_info + local event_bus_msg if is_new_information then local headers = SonosApi.make_headers() @@ -283,30 +429,21 @@ function sonos_ssdp.spawn_persistent_ssdp_task() ) if not discovery_info then log.error(string.format("Error getting discovery info from SSDP response: %s", err)) + elseif discovery_info._objectType == "globalError" then + log.error(string.format("Error message in discovery info: %s", discovery_info.errorCode)) else - local unified_info = - { ssdp_info = sonos_ssdp_info, discovery_info = discovery_info, force_refresh = true } - task_handle.player_info_by_sonos_ids[unique_key] = unified_info - info_to_send = unified_info + speaker_info = SpeakerDiscoveryInfo.new(sonos_ssdp_info, discovery_info) + task_handle.player_info_by_sonos_ids[unique_key] = speaker_info + event_bus_msg = { speaker_info = speaker_info, force_refresh = true } end else - info_to_send = { - ssdp_info = maybe_known.ssdp_info, - discovery_info = maybe_known.discovery_info, - force_refresh = false, - } + speaker_info = maybe_known + event_bus_msg = { speaker_info = speaker_info, force_refresh = false } end - 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 = false }, - st_utils.stringify_table(info_to_send, "Sonos Discovery Info has unexpected structure") - ) - return - end - event_bus:send(info_to_send) - local mac_addr = utils.extract_mac_addr(info_to_send.discovery_info.device) + if speaker_info then + event_bus:send(event_bus_msg) + local mac_addr = speaker_info.mac_addr local waiting_handles = task_handle.waiting_for_unique_key[unique_key] or {} log.debug(st_utils.stringify_table(waiting_handles, "waiting for unique keys", true)) @@ -318,7 +455,7 @@ function sonos_ssdp.spawn_persistent_ssdp_task() st_utils.stringify_table(waiting_handles, "waiting for unique keys and mac addresses", true) ) for _, reply_tx in ipairs(waiting_handles) do - reply_tx:send(info_to_send) + reply_tx:send(speaker_info) end task_handle.waiting_for_unique_key[unique_key] = {} diff --git a/drivers/SmartThings/sonos/src/lifecycle_handlers.lua b/drivers/SmartThings/sonos/src/lifecycle_handlers.lua index 0db0efc2c9..97a79ac209 100644 --- a/drivers/SmartThings/sonos/src/lifecycle_handlers.lua +++ b/drivers/SmartThings/sonos/src/lifecycle_handlers.lua @@ -68,7 +68,7 @@ function SonosDriverLifecycleHandlers.initialize_device(driver, device) if not info then device.log.warn(string.format("error receiving device info: %s", recv_err)) else - ---@cast info { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo, force_refresh: boolean } + ---@cast info SpeakerDiscoveryInfo local auth_success, api_key_or_err = driver:check_auth(info) if not auth_success then device:offline() diff --git a/drivers/SmartThings/sonos/src/sonos_driver.lua b/drivers/SmartThings/sonos/src/sonos_driver.lua index 82d88752f2..3d8b22ecf3 100644 --- a/drivers/SmartThings/sonos/src/sonos_driver.lua +++ b/drivers/SmartThings/sonos/src/sonos_driver.lua @@ -232,7 +232,7 @@ end --- Check if the driver is able to authenticate against the given household_id --- with what credentials it currently possesses. ----@param info_or_device SonosDevice | { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo, force_refresh: boolean } +---@param info_or_device SonosDevice | SpeakerDiscoveryInfo ---@return boolean? auth_success true if the driver can authenticate against the provided arguments, false otherwise ---@return string? api_key_or_err if `auth_success` is true, this will be the API key that is known to auth. If `auth_success` is false, this will be nil. If `auth_success` is `nil`, this will be an error message. function SonosDriver:check_auth(info_or_device) @@ -247,13 +247,6 @@ function SonosDriver:check_auth(info_or_device) local rest_url, household_id, sw_gen if type(info_or_device) == "table" then if - type(info_or_device.ssdp_info) == "table" and type(info_or_device.discovery_info) == "table" - then - ---@cast info_or_device { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo } - rest_url = net_url.parse(info_or_device.discovery_info.restUrl) - household_id = info_or_device.ssdp_info.household_id - sw_gen = info_or_device.discovery_info.device.swGen - elseif type(info_or_device.get_field) == "function" and type(info_or_device.set_field) == "function" and info_or_device.id @@ -262,6 +255,11 @@ function SonosDriver:check_auth(info_or_device) rest_url = net_url.parse(info_or_device:get_field(PlayerFields.REST_URL)) household_id = self.sonos:get_sonos_ids_for_device(info_or_device) sw_gen = info_or_device:get_field(PlayerFields.SW_GEN) + else + ---@cast info_or_device SpeakerDiscoveryInfo + rest_url = info_or_device.rest_url + household_id = info_or_device.household_id + sw_gen = info_or_device.sw_gen end end @@ -272,11 +270,7 @@ function SonosDriver:check_auth(info_or_device) ( ( type(info_or_device) == "table" - and ( - info_or_device.label - or info_or_device.id - or info_or_device.discovery_info.device.name - ) + and (info_or_device.label or info_or_device.id or info_or_device.name) ) or "" ) ) @@ -443,33 +437,32 @@ local function make_ssdp_event_handler( end end if receiver == discovery_event_subscription then - ---@type { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo, force_refresh: boolean }? + ---@type { speaker_info: SpeakerDiscoveryInfo, force_refresh: boolean } 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) + local speaker_info = event.speaker_info if event.force_refresh or not ( - unauthorized[unique_key] - or discovered[unique_key] - or driver.bonded_devices[mac_addr] + unauthorized[speaker_info.unique_key] + or discovered[speaker_info.unique_key] + or driver.bonded_devices[speaker_info.mac_addr] ) then - local _, api_key = driver:check_auth(event) + local _, api_key = driver:check_auth(event.speaker_info) local success, handle_err, err_code = - driver:handle_player_discovery_info(api_key, event) + driver:handle_player_discovery_info(api_key, event.speaker_info) if not success then if err_code == "ERROR_NOT_AUTHORIZED" then - unauthorized[unique_key] = event + unauthorized[speaker_info.unique_key] = event end log.warn_with( { hub_logs = false }, string.format("Failed to handle discovered speaker: %s", handle_err) ) else - discovered[unique_key] = true + discovered[speaker_info.unique_key] = true end end else @@ -503,20 +496,14 @@ function SonosDriver:start_ssdp_event_task() end ---@param api_key string ----@param info { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo, force_refresh: boolean } +---@param info SpeakerDiscoveryInfo ---@param device SonosDevice? ---@return boolean|nil response nil or false on failure ---@return nil|string error the error reason on failure, nil on success ---@return nil|string error_code the Sonos error code, if available function SonosDriver:handle_player_discovery_info(api_key, info, device) - -- If the SSDP Group Info is an empty string, then that means it's the non-primary - -- 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 they are not - -- already onboarded. - - local discovery_info_mac_addr = utils.extract_mac_addr(info.discovery_info.device) - local bonded = (#info.ssdp_info.group_id == 0) + local discovery_info_mac_addr = info.mac_addr + local bonded = info:is_bonded() self.bonded_devices[discovery_info_mac_addr] = bonded local maybe_device = self:get_device_by_dni(discovery_info_mac_addr) @@ -527,11 +514,11 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) api_key = api_key or self:get_fallback_api_key() - local rest_url = net_url.parse(info.discovery_info.restUrl) + local rest_url = info.rest_url local maybe_token, no_token_reason = self:get_oauth_token() local headers = SonosApi.make_headers(api_key, maybe_token and maybe_token.accessToken) local response, response_err = - SonosApi.RestApi.get_groups_info(rest_url, info.ssdp_info.household_id, headers) + SonosApi.RestApi.get_groups_info(rest_url, info.household_id, headers) if response_err then return nil, string.format("Error while making REST API call: %s", response_err) @@ -541,7 +528,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) local additional_info = response.reason or response.wwwAuthenticate local error_string = string.format( '`getGroups` response error for player "%s":\n\tError Code: %s', - info.discovery_info.device.name, + info.name, response.errorCode ) @@ -558,7 +545,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) return nil, error_string, response.errorCode end - local sw_gen = info.discovery_info.device.swGen + local sw_gen = info.sw_gen local is_s1 = sw_gen == 1 local response_valid if is_s1 then @@ -577,12 +564,11 @@ 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) + self.sonos:update_household_info(info.household_id, response, self) local device_to_update, device_mac_addr - local maybe_device_id = - self.sonos:get_device_id_for_player(info.ssdp_info.household_id, info.discovery_info.playerId) + local maybe_device_id = self.sonos:get_device_id_for_player(info.household_id, info.player_id) if device then device_to_update = device @@ -596,7 +582,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) end if not device_mac_addr then - if not (info and info.discovery_info and info.discovery_info.device) then + if not (info and info.mac_addr) then return nil, st_utils.stringify_table(info, "Sonos Discovery Info has unexpected structure") end device_mac_addr = discovery_info_mac_addr @@ -613,10 +599,8 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) self.dni_to_device_id[device_mac_addr] = device_to_update.id self.sonos:associate_device_record(device_to_update, info) elseif not bonded then - local name = info.discovery_info.device.name - or info.discovery_info.device.modelDisplayName - or "Unknown Sonos Player" - local model = info.discovery_info.device.modelDisplayName or "Unknown Sonos Model" + local name = info.name or info.model_display_name or "Unknown Sonos Player" + local model = info.model_display_name or "Unknown Sonos Model" local try_create_message = { type = "LAN", device_network_id = device_mac_addr, @@ -624,7 +608,7 @@ function SonosDriver:handle_player_discovery_info(api_key, info, device) label = name, model = model, profile = "sonos-player", - vendor_provided_label = info.discovery_info.device.model, + vendor_provided_label = info.model, } self:try_create_device(try_create_message) diff --git a/drivers/SmartThings/sonos/src/sonos_state.lua b/drivers/SmartThings/sonos/src/sonos_state.lua index 12d10f6211..4fe27ccdc6 100644 --- a/drivers/SmartThings/sonos/src/sonos_state.lua +++ b/drivers/SmartThings/sonos/src/sonos_state.lua @@ -10,8 +10,8 @@ local SonosConnection = require "api.sonos_connection" --- @class SonosHousehold --- 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 groups table All of the current groups 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 @@ -77,7 +77,7 @@ end local _STATE = { ---@type Households households = make_households_table(), - ---@type table + ---@type table device_record_map = {}, } @@ -87,12 +87,12 @@ local SonosState = {} SonosState.__index = SonosState ---@param device SonosDevice ----@param info { ssdp_info: SonosSSDPInfo, discovery_info: SonosDiscoveryInfo } +---@param info SpeakerDiscoveryInfo function SonosState:associate_device_record(device, info) - local household_id = info.ssdp_info.household_id - local group_id = info.ssdp_info.group_id - -- This is the device id even if the device is a secondary in a bonded set - local player_id = info.discovery_info.playerId + local household_id = info.household_id + local group_id = info.group_id + -- This is the device id even if the device is a seondary in a bonded set + local player_id = info.player_id local household = _STATE.households[household_id] if not household then @@ -120,7 +120,9 @@ function SonosState:associate_device_record(device, info) return end - local player = (household.players[player_id] or {}).player + local player_tbl = household.players[player_id] + local player = (player_tbl or {}).player + local sonos_device = (player_tbl or {}).device if not player then log.error( @@ -134,30 +136,31 @@ function SonosState:associate_device_record(device, info) household.st_devices[player_id] = device.id - _STATE.device_record_map[device.id] = - { sonos_device_id = player_id, group = group, player = player, household = household } + _STATE.device_record_map[device.id] = { + sonos_device_id = player_id, + group = group, + player = player, + household = household, + sonos_device = sonos_device, + } local bonded = household.bonded_players[player_id] and true or false - local sw_gen_changed = utils.update_field_if_changed( - device, - PlayerFields.SW_GEN, - info.discovery_info.device.swGen, - { persist = true } - ) + local sw_gen_changed = + utils.update_field_if_changed(device, PlayerFields.SW_GEN, info.sw_gen, { persist = true }) if sw_gen_changed then - CapEventHandlers.handle_sw_gen(device, info.discovery_info.device.swGen) + CapEventHandlers.handle_sw_gen(device, info.sw_gen) end - device:set_field(PlayerFields.REST_URL, info.discovery_info.restUrl, { persist = true }) + device:set_field(PlayerFields.REST_URL, info.rest_url:build(), { persist = true }) local sonos_conn = device:get_field(PlayerFields.CONNECTION) local connected = sonos_conn ~= nil local websocket_url_changed = utils.update_field_if_changed( device, PlayerFields.WSS_URL, - info.ssdp_info.wss_url, + info.wss_url:build(), { persist = true } ) @@ -176,12 +179,8 @@ 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, player_id, { persist = true }) local need_refresh = connected and (websocket_url_changed or household_id_changed or player_id_changed) @@ -206,7 +205,7 @@ function SonosState:associate_device_record(device, info) end ---@param household SonosHousehold ----@param group SonosGroupObject +---@param group SonosGroupInfo ---@param device SonosDevice function SonosState:update_device_record_group_info(household, group, device) local player_id = device:get_field(PlayerFields.PLAYER_ID) @@ -221,10 +220,10 @@ function SonosState:update_device_record_group_info(household, group, device) and player_id and group and group.id - and group.coordinatorId - ) and player_id == group.coordinatorId + and group.coordinator_id + ) and player_id == group.coordinator_id then - local player_ids_list = (household.groups[group.id] or {}).playerIds or {} + local player_ids_list = (household.groups[group.id] or {}).player_ids or {} if #player_ids_list > 1 then group_role = "primary" else @@ -249,11 +248,11 @@ function SonosState:update_device_record_group_info(household, group, device) field_changed = utils.update_field_if_changed( device, PlayerFields.COORDINATOR_ID, - group.coordinatorId, + group.coordinator_id, { persist = true } ) if not bonded and field_changed then - CapEventHandlers.handle_group_coordinator_update(device, group.coordinatorId) + CapEventHandlers.handle_group_coordinator_update(device, group.coordinator_id) end if bonded then @@ -306,9 +305,21 @@ function SonosState:update_device_record_from_state(household_id, device) self:update_device_record_group_info(household, current_mapping.group, device) end --- Helper function for when updating household info +--- Helper function for when updating household info +---@param driver SonosDriver +---@param player SonosPlayerObject +---@param household SonosHousehold +---@param known_bonded_players table +---@param sonos_device_id PlayerId local function update_device_info(driver, player, household, known_bonded_players, sonos_device_id) - household.players[sonos_device_id] = { player = player } + ---@type SonosDeviceInfo + local device_info = { id = sonos_device_id, primary_device_id = player.id } + ---@type SonosPlayerInfo + local player_info = { id = player.id, websocket_url = player.websocketUrl } + household.players[sonos_device_id] = { + player = player_info, + device = device_info, + } local previously_bonded = known_bonded_players[sonos_device_id] and true or false local currently_bonded local group_id @@ -328,8 +339,8 @@ local function update_device_info(driver, player, household, known_bonded_player _STATE.device_record_map[maybe_device_id] = _STATE.device_record_map[maybe_device_id] or {} _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_id = sonos_device_id + _STATE.device_record_map[maybe_device_id].player = player_info + _STATE.device_record_map[maybe_device_id].sonos_device = device_info if previously_bonded ~= currently_bonded then local target_device = driver:get_device_info(maybe_device_id) if target_device then @@ -351,7 +362,8 @@ function SonosState:update_household_info(id, groups_event, driver) local groups, players = groups_event.groups, groups_event.players for _, group in ipairs(groups) do - household.groups[group.id] = group + household.groups[group.id] = + { id = group.id, coordinator_id = group.coordinatorId, player_ids = group.playerIds } for _, playerId in ipairs(group.playerIds) do household.player_to_group[playerId] = group.id end @@ -363,11 +375,11 @@ function SonosState:update_household_info(id, groups_event, driver) for _, player in ipairs(players) do -- Prefer devices because deviceIds is deprecated but all we care about is -- the ID so either way is fine. - if player.devices then + if type(player.devices) == "table" then for _, device in ipairs(player.devices) do update_device_info(driver, player, household, known_bonded_players, device.id) end - elseif player.deviceIds then + elseif type(player.deviceIds) == "table" then for _, device_id in ipairs(player.deviceIds) do update_device_info(driver, player, household, known_bonded_players, device_id) end @@ -378,7 +390,10 @@ function SonosState:update_household_info(id, groups_event, driver) end end if log_devices_error then - log.warn_with( { hub_logs = true}, "Group event contained neither devices nor deviceIds in player") + log.warn_with( + { hub_logs = true }, + "Group event contained neither devices nor deviceIds in player" + ) end household.id = id @@ -474,7 +489,7 @@ function SonosState:get_coordinator_for_group(household_id, group_id) return end - return group.coordinatorId + return group.coordinator_id end --- @param device SonosDevice @@ -583,7 +598,7 @@ function SonosState:get_coordinator_for_device(device) ) end - return household_id, group.coordinatorId, nil + return household_id, group.coordinator_id, nil end ---@return SonosState diff --git a/drivers/SmartThings/sonos/src/types.lua b/drivers/SmartThings/sonos/src/types.lua index a1be63fe3f..c404f92a36 100644 --- a/drivers/SmartThings/sonos/src/types.lua +++ b/drivers/SmartThings/sonos/src/types.lua @@ -5,6 +5,9 @@ --- @alias HouseholdId string --- @alias GroupId string +--------- #region Sonos API Types; the following defintions are from the Sonos API +--------- In particular, anything ending in `Object` is an API object. + --- @alias SonosCapabilities ---| "PLAYBACK" # The player can produce audio. You can target it for playback. ---| "CLOUD" # The player can send commands and receive events over the internet. @@ -17,11 +20,11 @@ ---| "SPEAKER_DETECTION" # The component device is capable of detecting connected speaker drivers. ---| "FIXED_VOLUME" # The device supports fixed volume. ---- @class SonosFeatureInfo +--- @class SonosFeatureInfoObject --- @field public _objectType "feature" --- @field public name string ----@class SonosVersionsInfo +---@class SonosVersionsInfoObject ---@field public _objectType "sdkVersions" ---@field public audioTxProtocol { [1]: integer } ---@field public trueplaySdk { [1]: string } @@ -43,11 +46,11 @@ --- @field public softwareVersion string Stores the software version the player is running. --- @field public hwVersion string Stores the hardware version the player is running. The format is: `{vendor}.{model}.{submodel}.{revision}-{region}.` --- @field public swGen integer Stores the software generation that the player is running. ---- @field public versions SonosVersionsInfo ---- @field public features SonosFeatureInfo[] +--- @field public versions SonosVersionsInfoObject +--- @field public features SonosFeatureInfoObject[] --- Lua representation of the Sonos `discoveryInfo` JSON object: https://developer.sonos.com/build/control-sonos-players-lan/discover-lan/#discoveryInfo-object ---- @class SonosDiscoveryInfo +--- @class SonosDiscoveryInfoObject --- @field public _objectType "discoveryInfo" --- @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. @@ -143,11 +146,7 @@ --- @field public capabilities SonosCapabilities[] --- @field public devices SonosDeviceInfoObject[] ---- Sonos player local state ---- @class PlayerDiscoveryState ---- @field public info_cache SonosDiscoveryInfo Table representation of the JSON returned by the player REST API info endpoint ---- @field public ipv4 string the ipv4 address of the player on the local network ---- @field public is_coordinator boolean whether or not the player was a coordinator (at time of discovery) +--------- #endregion Sonos API Types --- @class SonosSSDPInfo --- Information parsed from Sonos SSDP reply. Contains most of what is needed to uniquely @@ -165,13 +164,7 @@ --- @field public expires_at integer --- @alias SonosFavorites { id: string, name: string }[] ---- @alias DiscoCallback fun(dni: string, ssdp_group_info: SonosSSDPInfo, player_info: SonosDiscoveryInfo, group_info: SonosGroupsResponseBody): boolean? - ----@class SonosFieldCacheTable ----@field public swGen number ----@field public household_id string ----@field public player_id string ----@field public wss_url string +--- @alias DiscoCallback fun(dni: string, ssdp_group_info: SonosSSDPInfo, player_info: SonosDiscoveryInfoObject, group_info: SonosGroupsResponseBody): boolean? --- Sonos Player device --- @class SonosDevice : st.Device @@ -189,5 +182,18 @@ --- @field public emit_event fun(self: SonosDevice, event: any) --- @field public driver SonosDriver +--- @class SonosGroupInfo +--- @field public id GroupId +--- @field public coordinator_id PlayerId +--- @field public player_ids PlayerId[] + +--- @class SonosDeviceInfo +--- @field public id PlayerId +--- @field public primary_device_id PlayerId? + +--- @class SonosPlayerInfo +--- @field public id PlayerId +--- @field public websocket_url string + --- Sonos JSON commands --- @class SonosCommand