def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: """Find domains matching the passed CaseInsensitiveDict.""" assert self._match_by_key is not None domains = set() for key, matchers_by_key in self._match_by_key.items(): if not (match_value := info_with_desc.get(key)): continue for domain, matcher in matchers_by_key.get(match_value, []): if domain in domains: continue if all(info_with_desc.get(k) == v for (k, v) in matcher.items()): domains.add(domain)
def decode_ssdp_packet( data: bytes, addr: AddressTupleVXType) -> Tuple[str, CaseInsensitiveDict]: """Decode a message.""" lines = data.split(b"\n") # request_line request_line = lines[0].strip().decode() # parse headers header_lines = b"\n".join(lines[1:]) email_headers = email.message_from_bytes(header_lines) headers = CaseInsensitiveDict(**dict(email_headers.items())) # adjust some headers if "location" in headers: headers["_location_original"] = headers["location"] headers["location"] = get_adjusted_url(headers["location"], addr) # own data headers["_timestamp"] = datetime.now() headers["_host"] = get_host_string(addr) headers["_port"] = addr[1] if "usn" in headers and "uuid:" in headers["usn"]: parts = str(headers["usn"]).split("::") headers["_udn"] = parts[0] return request_line, headers
def decode_ssdp_packet( data: bytes, addr: AddressTupleVXType) -> Tuple[str, CaseInsensitiveDict]: """Decode a message.""" lines = data.replace(b"\r\n", b"\n").split(b"\n") # request_line request_line = lines[0].strip().decode() if lines and lines[-1] != b"": lines.append(b"") parsed_headers, _ = HeadersParser().parse_headers(lines) headers = CaseInsensitiveDict(**parsed_headers) # adjust some headers if "location" in headers: headers["_location_original"] = headers["location"] headers["location"] = get_adjusted_url(headers["location"], addr) # own data headers["_timestamp"] = datetime.now() headers["_host"] = get_host_string(addr) headers["_port"] = addr[1] if "usn" in headers and "uuid:" in headers["usn"]: parts = str(headers["usn"]).split("::") headers["_udn"] = parts[0] return request_line, headers
def test_case_insensitive_dict(): """Test CaseInsensitiveDict.""" ci_dict = CaseInsensitiveDict() ci_dict["Key"] = "value" assert ci_dict["Key"] == "value" assert ci_dict["key"] == "value" assert ci_dict["KEY"] == "value"
async def _ssdp_listener_callback(self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, source: SsdpSource) -> None: """Handle a device/service change.""" _LOGGER.debug("Change, ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source) location = ssdp_device.location info_desc = await self._async_get_description_dict(location) or {} combined_headers = ssdp_device.combined_headers(dst) info_with_desc = CaseInsensitiveDict(combined_headers, **info_desc) discovery_info = discovery_info_from_headers_and_description( info_with_desc) callbacks = self._async_get_matching_callbacks(combined_headers) ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) for domain in self._async_matching_domains(info_with_desc): _LOGGER.debug("Discovered %s at %s", domain, location) flow: SSDPFlow = { "domain": domain, "context": { "source": config_entries.SOURCE_SSDP }, "data": discovery_info, } assert self._flow_dispatcher is not None self._flow_dispatcher.create(flow)
def _async_matching_domains(self, info_with_req: CaseInsensitiveDict) -> set[str]: domains = set() for domain, matchers in self._integration_matchers.items(): for matcher in matchers: if all(info_with_req.get(k) == v for (k, v) in matcher.items()): domains.add(domain) return domains
def test_case_insensitive_dict_dict_equality(): """Test CaseInsensitiveDict against dict equality.""" ci_dict = CaseInsensitiveDict() ci_dict["Key"] = "value" assert ci_dict == {"Key": "value"} assert ci_dict == {"key": "value"} assert ci_dict == {"KEY": "value"}
def discovery_info_from_headers_and_request( info_with_req: CaseInsensitiveDict, ) -> dict[str, str]: """Convert headers and description to discovery_info.""" info = {DISCOVERY_MAPPING.get(k.lower(), k): v for k, v in info_with_req.items()} if ATTR_UPNP_UDN not in info and ATTR_SSDP_USN in info: if udn := _udn_from_usn(info[ATTR_SSDP_USN]): info[ATTR_UPNP_UDN] = udn
def _async_headers_match(headers: CaseInsensitiveDict, lower_match_dict: dict[str, str]) -> bool: for header, val in lower_match_dict.items(): if val == MATCH_ALL: if header not in headers: return False elif headers.get_lower(header) != val: return False return True
def test_case_insensitive_dict(): ci = CaseInsensitiveDict() ci['Key'] = 'value' assert ci['Key'] == 'value' assert ci['key'] == 'value' assert ci['KEY'] == 'value' assert ci == {'Key': 'value'} assert ci == {'key': 'value'} assert ci == {'KEY': 'value'} source_dict = {'key': 'value'} ci = CaseInsensitiveDict(**source_dict) assert ci == {'key': 'value'} cis = set() cis.add(CaseInsensitiveDict(key='value')) cis.add(CaseInsensitiveDict(KEY='value')) assert len(cis) == 1
def _async_headers_to_discovery_info( self, headers: Mapping[str, str]) -> dict[str, str]: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. """ assert self.description_manager is not None location = headers["location"] info_req = self.description_manager.async_cached_description( location) or {} return discovery_info_from_headers_and_request( CaseInsensitiveDict(**headers, **info_req))
async def _async_headers_to_discovery_info( self, headers: Mapping[str, Any]) -> dict[str, Any]: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. """ assert self._description_cache is not None location = headers["location"] info_desc = ( await self._description_cache.async_get_description_dict(location) or {}) return discovery_info_from_headers_and_description( CaseInsensitiveDict(headers, **info_desc))
async def _ssdp_listener_callback( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, source: SsdpSource, ) -> None: """Handle a device/service change.""" _LOGGER.debug( "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source ) location = ssdp_device.location info_desc = None combined_headers = ssdp_device.combined_headers(dst) callbacks = self._async_get_matching_callbacks(combined_headers) matching_domains: set[str] = set() # If there are no changes from a search, do not trigger a config flow if source != SsdpSource.SEARCH_ALIVE: info_desc = await self._async_get_description_dict(location) or {} assert isinstance(combined_headers, CaseInsensitiveDict) matching_domains = self.integration_matchers.async_matching_domains( CaseInsensitiveDict({**combined_headers.as_dict(), **info_desc}) ) if not callbacks and not matching_domains: return if info_desc is None: info_desc = await self._async_get_description_dict(location) or {} discovery_info = discovery_info_from_headers_and_description( combined_headers, info_desc ) discovery_info.x_homeassistant_matching_domains = matching_domains ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) # Config flows should only be created for alive/update messages from alive devices if ssdp_change == SsdpChange.BYEBYE: return _LOGGER.debug("Discovery info: %s", discovery_info) for domain in matching_domains: _LOGGER.debug("Discovered %s at %s", domain, location) discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_SSDP}, discovery_info, )
async def _ssdp_listener_callback( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, source: SsdpSource, ) -> None: """Handle a device/service change.""" _LOGGER.debug("SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source) location = ssdp_device.location info_desc = await self._async_get_description_dict(location) or {} combined_headers = ssdp_device.combined_headers(dst) info_with_desc = CaseInsensitiveDict(combined_headers, **info_desc) callbacks = self._async_get_matching_callbacks(combined_headers) matching_domains: set[str] = set() # If there are no changes from a search, do not trigger a config flow if source != SsdpSource.SEARCH_ALIVE: matching_domains = self.integration_matchers.async_matching_domains( info_with_desc) if not callbacks and not matching_domains: return discovery_info = discovery_info_from_headers_and_description( info_with_desc) ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) # Config flows should only be created for alive/update messages from alive devices if ssdp_change == SsdpChange.BYEBYE: return for domain in matching_domains: _LOGGER.debug("Discovered %s at %s", domain, location) flow: SSDPFlow = { "domain": domain, "context": { "source": config_entries.SOURCE_SSDP }, "data": discovery_info, } assert self._flow_dispatcher is not None self._flow_dispatcher.create(flow)
def decode_ssdp_packet(data: bytes, addr: str) -> Tuple[str, CaseInsensitiveDict]: """Decode a message.""" lines = data.split(b'\n') # request_line request_line = lines[0].strip().decode() # parse headers header_lines = b'\n'.join(lines[1:]) email_headers = email.message_from_bytes(header_lines) headers = CaseInsensitiveDict(**dict(email_headers.items())) # own data headers['_timestamp'] = datetime.now() headers['_address'] = addr if 'usn' in headers and 'uuid:' in headers['usn']: parts = str(headers['usn']).split('::') headers['_udn'] = parts[0] return request_line, headers
def discovery_info_from_headers_and_description( combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], ) -> SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] ssdp_st = combined_headers.get("st") if isinstance(info_desc, CaseInsensitiveDict): upnp_info = {**info_desc.as_dict()} else: upnp_info = {**info_desc} # Increase compatibility: depending on the message type, # either the ST (Search Target, from M-SEARCH messages) # or NT (Notification Type, from NOTIFY messages) header is mandatory if not ssdp_st: ssdp_st = combined_headers["nt"] # Ensure UPnP "udn" is set if ATTR_UPNP_UDN not in upnp_info: if udn := _udn_from_usn(ssdp_usn): upnp_info[ATTR_UPNP_UDN] = udn
def test_case_insensitive_dict_equality(): """Test CaseInsensitiveDict equality.""" assert CaseInsensitiveDict(key="value") == CaseInsensitiveDict(KEY="value")
async def test_location_change_with_overlapping_udn_st_combinations( hass, aioclient_mock): """Test handling when a UDN and ST broadcast multiple locations.""" mock_get_ssdp = { "test_integration": [{ "manufacturer": "test_manufacturer", "modelName": "test_model" }] } hue_response = """ <root xmlns="urn:schemas-upnp-org:device-1-0"> <device> <manufacturer>test_manufacturer</manufacturer> <modelName>test_model</modelName> </device> </root> """ aioclient_mock.get( "http://192.168.72.1:49154/wps_device.xml", text=hue_response.format(ip_address="192.168.72.1"), ) aioclient_mock.get( "http://192.168.72.1:49152/wps_device.xml", text=hue_response.format(ip_address="192.168.72.1"), ) ssdp_response_without_location = { "ST": "upnp:rootdevice", "_udn": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6", "USN": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6::upnp:rootdevice", "EXT": "", } port_49154_response = CaseInsensitiveDict( **ssdp_response_without_location, **{"LOCATION": "http://192.168.72.1:49154/wps_device.xml"}, ) port_49152_response = CaseInsensitiveDict( **ssdp_response_without_location, **{"LOCATION": "http://192.168.72.1:49152/wps_device.xml"}, ) mock_ssdp_response = port_49154_response def _generate_fake_ssdp_listener(*args, **kwargs): listener = SSDPListener(*args, **kwargs) async def _async_callback(*_): pass @callback def _callback(*_): hass.async_create_task(listener.async_callback(mock_ssdp_response)) listener.async_start = _async_callback listener.async_search = _callback return listener with patch( "homeassistant.components.ssdp.async_get_ssdp", return_value=mock_get_ssdp, ), patch( "homeassistant.components.ssdp.SSDPListener", new=_generate_fake_ssdp_listener, ), patch.object(hass.config_entries.flow, "async_init") as mock_init: assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "test_integration" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert (mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == port_49154_response["location"]) mock_init.reset_mock() mock_ssdp_response = port_49152_response async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) await hass.async_block_till_done() assert mock_init.mock_calls[0][1][0] == "test_integration" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert (mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == port_49152_response["location"]) mock_init.reset_mock() mock_ssdp_response = port_49154_response async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) await hass.async_block_till_done() assert mock_init.mock_calls[0][1][0] == "test_integration" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert (mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == port_49154_response["location"])
def _ssdp_headers(headers): return CaseInsensitiveDict(headers, _timestamp=datetime(2021, 1, 1, 12, 00), _udn=udn_from_headers(headers))
async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock): """Test that a location change for a UDN will evict the prior location from the cache.""" mock_get_ssdp = { "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] } hue_response = """ <root xmlns="urn:schemas-upnp-org:device-1-0"> <specVersion> <major>1</major> <minor>0</minor> </specVersion> <URLBase>http://{ip_address}:80/</URLBase> <device> <deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType> <friendlyName>Philips hue ({ip_address})</friendlyName> <manufacturer>Signify</manufacturer> <manufacturerURL>http://www.philips-hue.com</manufacturerURL> <modelDescription>Philips hue Personal Wireless Lighting</modelDescription> <modelName>Philips hue bridge 2015</modelName> <modelNumber>BSB002</modelNumber> <modelURL>http://www.philips-hue.com</modelURL> <serialNumber>001788a36abf</serialNumber> <UDN>uuid:2f402f80-da50-11e1-9b23-001788a36abf</UDN> </device> </root> """ aioclient_mock.get( "http://192.168.212.23/description.xml", text=hue_response.format(ip_address="192.168.212.23"), ) aioclient_mock.get( "http://169.254.8.155/description.xml", text=hue_response.format(ip_address="169.254.8.155"), ) ssdp_response_without_location = { "ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", "_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", "USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", "SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0", "hue-bridgeid": "001788FFFEA36ABF", "EXT": "", } mock_good_ip_ssdp_response = CaseInsensitiveDict( **ssdp_response_without_location, **{"LOCATION": "http://192.168.212.23/description.xml"}, ) mock_link_local_ip_ssdp_response = CaseInsensitiveDict( **ssdp_response_without_location, **{"LOCATION": "http://169.254.8.155/description.xml"}, ) mock_ssdp_response = mock_good_ip_ssdp_response def _generate_fake_ssdp_listener(*args, **kwargs): listener = SSDPListener(*args, **kwargs) async def _async_callback(*_): pass @callback def _callback(*_): import pprint pprint.pprint(mock_ssdp_response) hass.async_create_task(listener.async_callback(mock_ssdp_response)) listener.async_start = _async_callback listener.async_search = _callback return listener with patch( "homeassistant.components.ssdp.async_get_ssdp", return_value=mock_get_ssdp, ), patch( "homeassistant.components.ssdp.SSDPListener", new=_generate_fake_ssdp_listener, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_init: assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "hue" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert ( mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == mock_good_ip_ssdp_response["location"] ) mock_init.reset_mock() mock_ssdp_response = mock_link_local_ip_ssdp_response async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) await hass.async_block_till_done() assert mock_init.mock_calls[0][1][0] == "hue" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert ( mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == mock_link_local_ip_ssdp_response["location"] ) mock_init.reset_mock() mock_ssdp_response = mock_good_ip_ssdp_response async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) await hass.async_block_till_done() assert mock_init.mock_calls[0][1][0] == "hue" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert ( mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == mock_good_ip_ssdp_response["location"] )
async def test_scan_second_hit(hass, aioclient_mock, caplog): """Test matching on second scan.""" aioclient_mock.get( "http://1.1.1.1", text=""" <root> <device> <deviceType>Paulus</deviceType> </device> </root> """, ) mock_ssdp_response = CaseInsensitiveDict( **{ "ST": "mock-st", "LOCATION": "http://1.1.1.1", "USN": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", "SERVER": "mock-server", "EXT": "", } ) mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} integration_callbacks = [] @callback def _async_integration_callbacks(info): integration_callbacks.append(info) def _generate_fake_ssdp_listener(*args, **kwargs): listener = SSDPListener(*args, **kwargs) async def _async_callback(*_): pass @callback def _callback(*_): hass.async_create_task(listener.async_callback(mock_ssdp_response)) listener.async_start = _async_callback listener.async_search = _callback return listener with patch( "homeassistant.components.ssdp.async_get_ssdp", return_value=mock_get_ssdp, ), patch( "homeassistant.components.ssdp.SSDPListener", new=_generate_fake_ssdp_listener, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) await hass.async_block_till_done() remove = ssdp.async_register_callback( hass, _async_integration_callbacks, {"st": "mock-st"}, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() remove() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() assert len(integration_callbacks) == 4 assert integration_callbacks[0] == { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", ssdp.ATTR_SSDP_EXT: "", ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", ssdp.ATTR_SSDP_SERVER: "mock-server", ssdp.ATTR_SSDP_ST: "mock-st", ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } assert mock_init.mock_calls[0][2]["data"] == { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", ssdp.ATTR_SSDP_ST: "mock-st", ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", ssdp.ATTR_SSDP_SERVER: "mock-server", ssdp.ATTR_SSDP_EXT: "", ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert "Failed to fetch ssdp data" not in caplog.text udn_discovery_info = ssdp.async_get_discovery_info_by_st(hass, "mock-st") discovery_info = udn_discovery_info[0] assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" assert ( discovery_info[ssdp.ATTR_UPNP_UDN] == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) assert ( discovery_info[ssdp.ATTR_SSDP_USN] == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" ) st_discovery_info = ssdp.async_get_discovery_info_by_udn( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) discovery_info = st_discovery_info[0] assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" assert ( discovery_info[ssdp.ATTR_UPNP_UDN] == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) assert ( discovery_info[ssdp.ATTR_SSDP_USN] == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" ) discovery_info = ssdp.async_get_discovery_info_by_udn_st( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", "mock-st" ) assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" assert ( discovery_info[ssdp.ATTR_UPNP_UDN] == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) assert ( discovery_info[ssdp.ATTR_SSDP_USN] == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" ) assert ssdp.async_get_discovery_info_by_udn_st(hass, "wrong", "mock-st") is None
class Scanner: """Class to manage SSDP scanning.""" def __init__( self, hass: HomeAssistant, integration_matchers: dict[str, list[dict[str, str]]]) -> None: """Initialize class.""" self.hass = hass self.seen: set[tuple[str, str]] = set() self.cache: dict[tuple[str, str], Mapping[str, str]] = {} self._integration_matchers = integration_matchers self._cancel_scan: Callable[[], None] | None = None self._ssdp_listeners: list[SSDPListener] = [] self._callbacks: list[tuple[Callable[[dict], None], dict[str, str]]] = [] self.flow_dispatcher: FlowDispatcher | None = None self.description_manager: DescriptionManager | None = None @core_callback def async_register_callback( self, callback: Callable[[dict], None], match_dict: None | dict[str, str] = None) -> Callable[[], None]: """Register a callback.""" if match_dict is None: match_dict = {} # Make sure any entries that happened # before the callback was registered are fired if self.hass.state != CoreState.running: for headers in self.cache.values(): self._async_callback_if_match(callback, headers, match_dict) callback_entry = (callback, match_dict) self._callbacks.append(callback_entry) @core_callback def _async_remove_callback() -> None: self._callbacks.remove(callback_entry) return _async_remove_callback @core_callback def _async_callback_if_match( self, callback: Callable[[dict], None], headers: Mapping[str, str], match_dict: dict[str, str], ) -> None: """Fire a callback if info matches the match dict.""" if not all(headers.get(k) == v for (k, v) in match_dict.items()): return _async_process_callbacks( [callback], self._async_headers_to_discovery_info(headers)) @core_callback def async_stop(self, *_: Any) -> None: """Stop the scanner.""" assert self._cancel_scan is not None self._cancel_scan() for listener in self._ssdp_listeners: listener.async_stop() self._ssdp_listeners = [] async def _async_build_source_set(self) -> set[IPv4Address | IPv6Address]: """Build the list of ssdp sources.""" adapters = await network.async_get_adapters(self.hass) sources: set[IPv4Address | IPv6Address] = set() if _async_use_default_interface(adapters): sources.add(IPv4Address("0.0.0.0")) return sources for adapter in adapters: if not adapter["enabled"]: continue if adapter["ipv4"]: ipv4 = adapter["ipv4"][0] sources.add(IPv4Address(ipv4["address"])) if adapter["ipv6"]: ipv6 = adapter["ipv6"][0] # With python 3.9 add scope_ids can be # added by enumerating adapter["ipv6"]s # IPv6Address(f"::%{ipv6['scope_id']}") sources.add(IPv6Address(ipv6["address"])) return sources @core_callback def async_scan(self, *_: Any) -> None: """Scan for new entries.""" for listener in self._ssdp_listeners: listener.async_search() async def async_start(self) -> None: """Start the scanner.""" self.description_manager = DescriptionManager(self.hass) self.flow_dispatcher = FlowDispatcher(self.hass) for source_ip in await self._async_build_source_set(): self._ssdp_listeners.append( SSDPListener(async_callback=self._async_process_entry, source_ip=source_ip)) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start) await asyncio.gather( *[listener.async_start() for listener in self._ssdp_listeners]) self._cancel_scan = async_track_time_interval(self.hass, self.async_scan, SCAN_INTERVAL) @core_callback def _async_get_matching_callbacks( self, headers: Mapping[str, str]) -> list[Callable[[dict], None]]: """Return a list of callbacks that match.""" return [ callback for callback, match_dict in self._callbacks if all( headers.get(k) == v for (k, v) in match_dict.items()) ] @core_callback def _async_matching_domains( self, info_with_req: CaseInsensitiveDict) -> set[str]: domains = set() for domain, matchers in self._integration_matchers.items(): for matcher in matchers: if all( info_with_req.get(k) == v for (k, v) in matcher.items()): domains.add(domain) return domains async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) if "st" not in headers or "location" not in headers: return h_st = headers["st"] h_location = headers["location"] key = (h_st, h_location) if udn := _udn_from_usn(headers.get("usn")): self.cache[(udn, h_st)] = headers callbacks = self._async_get_matching_callbacks(headers) if key in self.seen and not callbacks: return assert self.description_manager is not None info_req = await self.description_manager.fetch_description(h_location ) or {} info_with_req = CaseInsensitiveDict(**headers, **info_req) discovery_info = discovery_info_from_headers_and_request(info_with_req) _async_process_callbacks(callbacks, discovery_info) if key in self.seen: return self.seen.add(key) for domain in self._async_matching_domains(info_with_req): _LOGGER.debug("Discovered %s at %s", domain, h_location) flow: SSDPFlow = { "domain": domain, "context": { "source": config_entries.SOURCE_SSDP }, "data": discovery_info, } assert self.flow_dispatcher is not None self.flow_dispatcher.create(flow)
def _ssdp_headers(headers): ssdp_headers = CaseInsensitiveDict(headers, _timestamp=datetime.now()) ssdp_headers["_udn"] = udn_from_headers(ssdp_headers) return ssdp_headers
class Scanner: """Class to manage SSDP scanning.""" def __init__( self, hass: HomeAssistant, integration_matchers: dict[str, list[dict[str, str]]]) -> None: """Initialize class.""" self.hass = hass self.seen: set[tuple[str, str | None]] = set() self.cache: dict[tuple[str, str], Mapping[str, str]] = {} self._integration_matchers = integration_matchers self._cancel_scan: Callable[[], None] | None = None self._ssdp_listeners: list[SSDPListener] = [] self._callbacks: list[tuple[Callable[[dict], None], dict[str, str]]] = [] self.flow_dispatcher: FlowDispatcher | None = None self.description_manager: DescriptionManager | None = None @core_callback def async_register_callback( self, callback: Callable[[dict], None], match_dict: None | dict[str, str] = None) -> Callable[[], None]: """Register a callback.""" if match_dict is None: match_dict = {} # Make sure any entries that happened # before the callback was registered are fired if self.hass.state != CoreState.running: for headers in self.cache.values(): if _async_headers_match(headers, match_dict): _async_process_callbacks( [callback], self._async_headers_to_discovery_info(headers)) callback_entry = (callback, match_dict) self._callbacks.append(callback_entry) @core_callback def _async_remove_callback() -> None: self._callbacks.remove(callback_entry) return _async_remove_callback @core_callback def async_stop(self, *_: Any) -> None: """Stop the scanner.""" assert self._cancel_scan is not None self._cancel_scan() for listener in self._ssdp_listeners: listener.async_stop() self._ssdp_listeners = [] async def _async_build_source_set(self) -> set[IPv4Address | IPv6Address]: """Build the list of ssdp sources.""" adapters = await network.async_get_adapters(self.hass) sources: set[IPv4Address | IPv6Address] = set() if _async_use_default_interface(adapters): sources.add(IPv4Address("0.0.0.0")) return sources for adapter in adapters: if not adapter["enabled"]: continue if adapter["ipv4"]: ipv4 = adapter["ipv4"][0] sources.add(IPv4Address(ipv4["address"])) if adapter["ipv6"]: ipv6 = adapter["ipv6"][0] # With python 3.9 add scope_ids can be # added by enumerating adapter["ipv6"]s # IPv6Address(f"::%{ipv6['scope_id']}") sources.add(IPv6Address(ipv6["address"])) return sources @core_callback def async_scan(self, *_: Any) -> None: """Scan for new entries.""" for listener in self._ssdp_listeners: listener.async_search() async def async_start(self) -> None: """Start the scanner.""" self.description_manager = DescriptionManager(self.hass) self.flow_dispatcher = FlowDispatcher(self.hass) for source_ip in await self._async_build_source_set(): self._ssdp_listeners.append( SSDPListener(async_callback=self._async_process_entry, source_ip=source_ip)) try: IPv4Address(source_ip) except ValueError: continue # Some sonos devices only seem to respond if we send to the broadcast # address. This matches pysonos' behavior # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 self._ssdp_listeners.append( SSDPListener( async_callback=self._async_process_entry, source_ip=source_ip, target_ip=IPV4_BROADCAST, )) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start) await asyncio.gather( *[listener.async_start() for listener in self._ssdp_listeners]) self._cancel_scan = async_track_time_interval(self.hass, self.async_scan, SCAN_INTERVAL) @core_callback def _async_get_matching_callbacks( self, headers: Mapping[str, str]) -> list[Callable[[dict], None]]: """Return a list of callbacks that match.""" return [ callback for callback, match_dict in self._callbacks if _async_headers_match(headers, match_dict) ] @core_callback def _async_matching_domains( self, info_with_req: CaseInsensitiveDict) -> set[str]: domains = set() for domain, matchers in self._integration_matchers.items(): for matcher in matchers: if all( info_with_req.get(k) == v for (k, v) in matcher.items()): domains.add(domain) return domains def _async_seen(self, header_st: str | None, header_location: str | None) -> bool: """Check if we have seen a specific st and optional location.""" if header_st is None: return True return (header_st, header_location) in self.seen def _async_see(self, header_st: str | None, header_location: str | None) -> None: """Mark a specific st and optional location as seen.""" if header_st is not None: self.seen.add((header_st, header_location)) async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) h_st = headers.get("st") h_location = headers.get("location") if h_st and (udn := _udn_from_usn(headers.get("usn"))): self.cache[(udn, h_st)] = headers callbacks = self._async_get_matching_callbacks(headers) if self._async_seen(h_st, h_location) and not callbacks: return assert self.description_manager is not None info_req = await self.description_manager.fetch_description(h_location ) or {} info_with_req = CaseInsensitiveDict(**headers, **info_req) discovery_info = discovery_info_from_headers_and_request(info_with_req) _async_process_callbacks(callbacks, discovery_info) if self._async_seen(h_st, h_location): return self._async_see(h_st, h_location) for domain in self._async_matching_domains(info_with_req): _LOGGER.debug("Discovered %s at %s", domain, h_location) flow: SSDPFlow = { "domain": domain, "context": { "source": config_entries.SOURCE_SSDP }, "data": discovery_info, } assert self.flow_dispatcher is not None self.flow_dispatcher.create(flow)
if h_st and (udn := _udn_from_usn(headers.get("usn"))): cache_key = (udn, h_st) if old_headers := self.cache.get(cache_key): old_h_location = old_headers.get("location") if h_location != old_h_location: self._async_unsee(old_headers.get("st"), old_h_location) self.cache[cache_key] = headers callbacks = self._async_get_matching_callbacks(headers) if self._async_seen(h_st, h_location) and not callbacks: return assert self.description_manager is not None info_req = await self.description_manager.fetch_description(h_location) or {} info_with_req = CaseInsensitiveDict(**headers, **info_req) discovery_info = discovery_info_from_headers_and_request(info_with_req) _async_process_callbacks(callbacks, discovery_info) if self._async_seen(h_st, h_location): return self._async_see(h_st, h_location) for domain in self._async_matching_domains(info_with_req): _LOGGER.debug("Discovered %s at %s", domain, h_location) flow: SSDPFlow = { "domain": domain, "context": {"source": config_entries.SOURCE_SSDP}, "data": discovery_info, }