diff --git a/drivers/SmartThings/sonos/src/api/event_handlers.lua b/drivers/SmartThings/sonos/src/api/event_handlers.lua index a4fe85766e..3bdcc0ec36 100644 --- a/drivers/SmartThings/sonos/src/api/event_handlers.lua +++ b/drivers/SmartThings/sonos/src/api/event_handlers.lua @@ -3,6 +3,8 @@ local log = require "log" local st_utils = require "st.utils" +local PlayerFields = require "fields".SonosPlayerFields + local CapEventHandlers = {} CapEventHandlers.PlaybackStatus = { @@ -12,41 +14,48 @@ 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_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 +70,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 +83,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 +137,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/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 f1773e24aa..1102953f34 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.websocket_url, + 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 @@ -339,9 +346,11 @@ function SonosConnection.new(driver, device) device.log.warn( string.format("WebSocket connection no longer authorized, disconnecting") ) - local _, security_err = driver:request_oauth_token() - if security_err then - log.warn(string.format("Error during request for oauth token: %s", security_err)) + if not driver:is_waiting_for_oauth_token() then + local _, security_err = driver:request_oauth_token() + if security_err then + log.warn(string.format("Error during request for oauth token: %s", security_err)) + end end -- closing the socket directly without calling `:stop()` triggers the reconnect loop, -- which is where we wait for the token to come in. @@ -358,19 +367,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)) @@ -399,7 +417,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 @@ -423,7 +441,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 @@ -446,7 +464,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 @@ -477,7 +495,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.websocket_url).host local base_url = lb_utils.force_url_table( string.format("https://%s:%s", url_ip, SonosApi.DEFAULT_SONOS_PORT) ) @@ -503,7 +521,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 @@ -590,7 +608,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..debd87da64 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 = {} + +---@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 + + local proxy_index = function(_, k) + if k == "rest_url" and not rawget(ret, "rest_url") then + rawset( + ret, + "rest_url", + net_url.parse( + string.format( + "https://%s:%s/%s", + ret.ipv4, + ret.port, + ret.rest_path or SONOS_DEFAULT_REST_PATH + ) + ) + ) + end + + if k == "wss_url" and not rawget(ret, "wss_url") then + rawset( + ret, + "wss_url", + net_url.parse( + string.format( + "https://%s:%s/%s", + ret.ipv4, + ret.port, + ret.wss_path or SONOS_DEFAULT_WSS_PATH + ) + ) + ) + end + + return rawget(ret, k) + end + + local proxy_newindex = function(_, _, _) + error("attempt to index a read-only table", 2) + 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' @@ -160,8 +293,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 +350,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 +400,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.ssdp_info_eq(maybe_known:as_ssdp_info(), 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 +417,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 = true }, - 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 +443,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/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..ef7b63a4f5 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() @@ -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..d64bd02e7a 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)) @@ -184,11 +200,15 @@ function SonosDriver:notify_augmented_data_changed(update_kind, update_key, upda ) end - if - self.oauth.endpoint_app_info + local maybe_token, _ = self:get_oauth_token() + + local should_request_token = self.oauth.endpoint_app_info and self.oauth.endpoint_app_info.state == "connected" and not already_connected - then + and type(maybe_token) ~= "table" + and not self:is_waiting_for_oauth_token() + + if should_request_token then local _, err = self:request_oauth_token() if err then log.error(string.format("Request OAuth token error: %s", err)) @@ -220,7 +240,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) @@ -240,13 +260,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 @@ -255,6 +268,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 = net_url.parse(info_or_device.rest_url) + household_id = info_or_device.household_id + sw_gen = info_or_device.sw_gen end end @@ -265,11 +283,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 "" ) ) @@ -322,6 +336,17 @@ function SonosDriver:check_auth(info_or_device) ) end +---@return boolean is_connected +function SonosDriver:is_account_linked() + return ( + self.oauth + and self.oauth.endpoint_app_info + and self.oauth.endpoint_app_info.state == "connected" + ) + and true + or false +end + ---@return any? ret nil on permissions violation ---@return string? error nil on success function SonosDriver:request_oauth_token() @@ -333,13 +358,17 @@ function SonosDriver:request_oauth_token() log.warn(string.format("get oauth token error: %s", maybe_err)) end if type(maybe_token) == "table" and type(maybe_token.accessToken) == "string" then + self.waiting_for_oauth_token = false self.oauth_token_bus:send(maybe_token) + return true end local result, err = security.get_sonos_oauth() if not result then return nil, string.format("Error requesting OAuth token via Security API: %s", err) end - self.waiting_for_oauth_token = true + -- if the account isn't linked, then we're not actually "waiting" for the token yet, + -- because we need to wait for the account link to succeed and the endpoint app upsert + self.waiting_for_oauth_token = self:is_account_linked() return result, err end @@ -419,27 +448,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 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]) + event.force_refresh + or not ( + 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 = true }, + { 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 @@ -473,31 +507,29 @@ 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 #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 - ) + 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) + 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() - local rest_url = net_url.parse(info.discovery_info.restUrl) + local rest_url = net_url.parse(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) @@ -507,7 +539,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 ) @@ -524,7 +556,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 @@ -543,12 +575,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.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 @@ -562,10 +593,10 @@ 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 = utils.extract_mac_addr(info.discovery_info.device) + device_mac_addr = discovery_info_mac_addr end if not device_to_update then @@ -578,11 +609,9 @@ 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 - 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" + elseif not bonded then + 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, @@ -590,7 +619,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) @@ -631,6 +660,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..d7278c53a8 100644 --- a/drivers/SmartThings/sonos/src/sonos_state.lua +++ b/drivers/SmartThings/sonos/src/sonos_state.lua @@ -12,8 +12,9 @@ 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 --- @field public favorites SonosFavorites all of the favorites/presets in the system @@ -23,6 +24,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 +50,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 +77,7 @@ end local _STATE = { ---@type Households households = make_households_table(), - ---@type table + ---@type table device_record_map = {}, } @@ -83,11 +87,11 @@ 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 - local player_id = info.discovery_info.playerId + local household_id = info.household_id + local group_id = info.group_id + local player_id = info.player_id local household = _STATE.households[household_id] if not household then @@ -100,8 +104,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 +119,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,27 +133,33 @@ 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 + + device:set_field(PlayerFields.SW_GEN, info.sw_gen, { persist = true }) + -- don't emit if we're bonded since that can undo being offline in the case that we're already offline. + if not bonded then + device:emit_event(swGenCapability.generation(string.format("%s", 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 } ) - 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 +172,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,25 +194,33 @@ 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 ----@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) + 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" 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 @@ -205,24 +232,28 @@ 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 field_changed = utils.update_field_if_changed( device, PlayerFields.COORDINATOR_ID, - group.coordinatorId, + group.coordinator_id, { persist = true } ) - if field_changed then - CapEventHandlers.handle_group_coordinator_update(device, group.coordinatorId) + if not bonded and field_changed then + CapEventHandlers.handle_group_coordinator_update(device, group.coordinator_id) + end + + if bonded then + device:offline() end end @@ -273,35 +304,65 @@ 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 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 + end + + for _, player in ipairs(players) do + for _, device in ipairs(player.devices) do + ---@type SonosDeviceInfo + local device_info = { id = device.id, primary_device_id = device.primaryDeviceId } + ---@type SonosPlayerInfo + local player_info = { id = player.id, websocket_url = player.websocketUrl } + household.players[device.id] = { + player = player_info, + device = device_info, + } + 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_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 + 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 +373,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 +400,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 +422,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", @@ -395,7 +456,7 @@ function SonosState:get_coordinator_for_group(household_id, group_id) return end - return group.coordinatorId + return group.coordinator_id end --- @param device SonosDevice @@ -432,7 +493,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 +525,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 +540,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 @@ -504,7 +565,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 65bcb3041f..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,18 +20,18 @@ ---| "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 } ---@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. @@ -43,13 +46,13 @@ --- @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 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,12 +144,9 @@ --- @field public softwareVersion string --- @field public websocketUrl string --- @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 @@ -164,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 @@ -188,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 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