"""Envoy Firmware detection""" import asyncio import logging import time import aiohttp from awesomeversion import AwesomeVersion from lxml import etree # nosec from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, stop_after_delay, wait_random_exponential, ) from .const import LOCAL_TIMEOUT, MAX_PROBE_REQUEST_ATTEMPTS, MAX_PROBE_REQUEST_DELAY from .exceptions import EnvoyFirmwareCheckError, EnvoyFirmwareFatalCheckError _LOGGER = logging.getLogger(__name__) class EnvoyFirmware: """Class for querying and determining the Envoy firmware version.""" __slots__ = ( "_client", "_firmware_version", "_host", "_metered", "_part_number", "_serial_number", "_url", ) def __init__( self, _client: aiohttp.ClientSession, host: str, ) -> None: """ Class for querying and determining the Envoy firmware version. :param client: aiohttp ClientSession not verifying SSL certificates, see :class:`pyenphase.ssl`. :param host: Envoy DNS name or IP address """ self._client = _client self._host = host self._firmware_version: str | None = None self._serial_number: str | None = None self._part_number: str | None = None self._url: str = "" self._metered: bool = False @retry( retry=retry_if_exception_type(aiohttp.ClientError), wait=wait_random_exponential(multiplier=2, max=5), stop=stop_after_delay(MAX_PROBE_REQUEST_DELAY) | stop_after_attempt(MAX_PROBE_REQUEST_ATTEMPTS), reraise=True, ) async def _get_info(self) -> tuple[int, bytes]: """ Perform GET request to /info endpoint on envoy. Try GET request to https:///info to read info endpoint. If connection error or timeout, retry on http:///info. Will retry up to :any:`MAX_PROBE_REQUEST_ATTEMPTS` times or :any:`MAX_PROBE_REQUEST_DELAY` elapsed at next try, which ever comes first on network or remote protocol errors. HTTP status is not verified. :return: tuple of (status_code, content) """ self._url = f"https://{self._host}/info" _LOGGER.debug("Requesting %s with timeout %s", self._url, LOCAL_TIMEOUT) try: resp = await self._client.get(self._url, timeout=LOCAL_TIMEOUT) return resp.status, await resp.read() except (aiohttp.ClientConnectorError, asyncio.TimeoutError): # Firmware < 7.0.0 does not support HTTPS so we need to try HTTP # as a fallback, worse sometimes http will redirect to https://localhost # which is not helpful self._url = f"http://{self._host}/info" _LOGGER.debug("Retrying to %s with timeout %s", self._url, LOCAL_TIMEOUT) resp = await self._client.get(self._url, timeout=LOCAL_TIMEOUT) return resp.status, await resp.read() async def setup(self) -> None: """ Obtain the firmware version, serial-number and part-number from Envoy. Read /info on Envoy, accessible without authentication. Store firmware version, serial-number and part-number properties from xml response. Reads first on HTTPS, if that fails on HTTP for firmware < 7. Will retry up to :any:`MAX_PROBE_REQUEST_ATTEMPTS` times or :any:`MAX_PROBE_REQUEST_DELAY` elapsed at next try, which ever comes first on network or remote protocol errors. .. code-block:: python connector = aiohttp.TCPConnector(ssl=create_no_verify_ssl_context()) client = aiohttp.ClientSession(connector=connector) firmware = EnvoyFirmware(client,host) await firmware.setup() print(firmware.version) :raises EnvoyFirmwareFatalCheckError: if connection or timeout failure occurs :raises EnvoyFirmwareCheckError: on http errors or any HTTP status other then 200 """ # /info will return XML with the firmware version debugon = _LOGGER.isEnabledFor(logging.DEBUG) if debugon: request_start = time.monotonic() try: status_code, content = await self._get_info() except asyncio.TimeoutError as ex: raise EnvoyFirmwareFatalCheckError( 500, "Timeout connecting to Envoy" ) from ex except aiohttp.ClientConnectorError as ex: raise EnvoyFirmwareFatalCheckError( 500, "Unable to connect to Envoy" ) from ex except aiohttp.ClientError as ex: raise EnvoyFirmwareCheckError( 500, "Unable to query firmware version" ) from ex if status_code == 200: if debugon: request_end = time.monotonic() _LOGGER.debug( "Request reply in %s sec from %s status %s: %s", round(request_end - request_start, 1), self._url, status_code, content, ) xml = etree.fromstring(content) # nosec if (device_tag := xml.find("device")) is not None: if (software_tag := device_tag.find("software")) is not None: self._firmware_version = AwesomeVersion( software_tag.text[1:] ) # need to strip off the leading 'R' or 'D' if (sn_tag := device_tag.find("sn")) is not None: self._serial_number = sn_tag.text if (pn_tag := device_tag.find("pn")) is not None: self._part_number = pn_tag.text if (imeter_tag := device_tag.find("imeter")) is not None: self._metered = imeter_tag.text == "true" return else: # If we get a different status code, raise an exception raise EnvoyFirmwareCheckError(status_code, content.decode()) @property def version(self) -> AwesomeVersion: """ Return firmware version as read from Envoy. :return: Envoy firmware version or None if :class:`pyenphase.firmware.EnvoyFirmware.setup` was not used """ return self._firmware_version @property def serial(self) -> str | None: """ Return serial number as read from Envoy. :return: Envoy serial number or None if :class:`pyenphase.firmware.EnvoyFirmware.setup` was not used """ return self._serial_number @property def part_number(self) -> str | None: """ Return part number as read from Envoy. :return: Envoy part number or None if :class:`pyenphase.firmware.EnvoyFirmware.setup` was not used """ return self._part_number @property def is_metered(self) -> bool: """ Return imetered setting as read from Envoy. :return: Envoy info imetered setting. Only True if read and set in info """ return self._metered