import asyncio import aiohttp import functools import logging import re from datetime import datetime, timedelta from defusedxml import ElementTree from typing import Optional, Any _LOGGER = logging.getLogger(__name__) def async_retry(attempts=2, allowed_exceptions=()): def decorator(f): @functools.wraps(f) async def wrapper(*args, **kwargs): attempt = attempts while True: attempt -= 1 try: return await f(*args, **kwargs) except allowed_exceptions: if attempt == 0: raise _LOGGER.warning("Retrying: %s %s", f, args) return wrapper return decorator class Throttle: def __init__(self, delay: float) -> None: self._timestamp = datetime.now() self._lock = asyncio.Lock() self._delay = timedelta(seconds=delay) async def get(self) -> None: async with self._lock: timestamp = datetime.now() delay = (self._timestamp - timestamp).total_seconds() if delay > 0: await asyncio.sleep(delay) self._timestamp = datetime.now() + self._delay def _log_exception(msg, *args): """Log an error and turn on traceback if debug is on.""" _LOGGER.error(msg, *args, exc_info=_LOGGER.getEffectiveLevel() == logging.DEBUG) def get_uniqueid_from_udn(data) -> Optional[str]: """Extract a unique id from udn.""" try: return data[5:].split("-")[4] except IndexError: _log_exception("Unable to get unique id from %s", data) return None def get_possibly_invalid_xml(data) -> Any: try: return ElementTree.fromstring(data) except ElementTree.ParseError: _LOGGER.info("Device provide corrupt xml, trying with ampersand replacement") data = re.sub(r"&(?![A-Za-z]+[0-9]*;|#[0-9]+;|#x[0-9a-fA-F]+;)", r"&", data) return ElementTree.fromstring(data) def get_udn_from_xml(xml: Any) -> Optional[str]: return xml.findtext( "d:device/d:UDN", None, {"d": "urn:schemas-upnp-org:device-1-0"} ) async def get_uniqueid_from_device_description( session: aiohttp.ClientSession, url: str ): """Retrieve and extract unique id from url.""" try: async with session.get(url) as req: req.raise_for_status() data = await req.text() xml = get_possibly_invalid_xml(data) udn = get_udn_from_xml(xml) return get_uniqueid_from_udn(udn) except (aiohttp.ClientError, asyncio.TimeoutError, ElementTree.ParseError): _log_exception("Unable to get device description from %s", url) return None async def get_uniqueid_from_host(session: aiohttp.ClientSession, host: str): """Try to deduce a unique id from a host based on ssdp/upnp.""" return await get_uniqueid_from_device_description( session, f"http://{host}:8080/dd.xml" )