"""SMA WebConnect library for Python. See: http://www.sma.de/en/products/monitoring-control/webconnect.html Source: http://www.github.com/kellerza/pysma """ import asyncio import copy import json import logging import pkgutil from typing import Any import jmespath from aiohttp import ClientSession, ClientTimeout, client_exceptions, hdrs from .const import ( DEFAULT_LANG, DEFAULT_TIMEOUT, DEVICE_INFO, ENERGY_METER_VIA_INVERTER, GENERIC_SENSORS, OPTIMIZERS_VIA_INVERTER, URL_ALL_PARAMS, URL_ALL_VALUES, URL_DASH_LOGGER, URL_DASH_VALUES, URL_LOGGER, URL_LOGIN, URL_LOGOUT, URL_VALUES, USERS, ) from .definitions import webconnect from .exceptions import ( SmaAuthenticationException, SmaConnectionException, SmaReadException, ) from .helpers import DeviceInfo from .sensor import Sensors _LOG = logging.getLogger(__name__) class SMAWebConnect: """Class to connect to the SMA webconnect module and read parameters.""" _aio_session: ClientSession _new_session_data: dict | None _url: str _sid: str | None _uid: str | None _lang: str _l10n: dict | None _devclass: str | None _device_info_sensors: Sensors def __init__( self, session: ClientSession, url: str, password: str | None = None, group: str = "user", uid: str | None = None, lang: str = DEFAULT_LANG, ): """Init SMA connection. Args: session (ClientSession): aiohttp client session url (str): Url or IP address of device password (str, optional): Password to use during login. Defaults to None. group (str, optional): Username to use during login. Defaults to "user". uid (str, optional): uid used for data extraction. Defaults to None. lang (str, optional): Language code of file to retrieve. Defaults to "en-US". Raises: KeyError: User was not in USERS """ if group not in USERS: raise KeyError(f"Invalid user type: {group}") if password is not None and len(password) > 12: _LOG.warning("Password should not exceed 12 characters") if password is None: self._new_session_data = None else: self._new_session_data = {"right": USERS[group], "pass": password} self._url = url.rstrip("/") if not url.startswith("http"): self._url = "http://" + self._url self._aio_session = session self._sid = None self._uid = uid self._lang = lang self._l10n = None self._devclass = None self._device_info_sensors = Sensors(webconnect.sensor_map[DEVICE_INFO]) async def _request_json( self, method: str, url: str, **kwargs: dict[str, Any] ) -> dict: """Request json data for requests. Args: method (str): HTTP method to use url (str): URL to do request to **kwargs (dict): Additional request parameters Raises: SmaConnectionException: Connection to device failed Returns: dict: json returned by device """ if self._sid: kwargs.setdefault("params", {}) kwargs["params"]["sid"] = self._sid _LOG.debug("Sending %s request to %s: %s", method, url, kwargs) max_retries = 2 for retry in range(max_retries): try: async with self._aio_session.request( method, self._url + url, timeout=ClientTimeout(total=DEFAULT_TIMEOUT), **kwargs, # type:ignore[arg-type] ) as res: res_json = await res.json() _LOG.debug("Received reply %s", res_json) return res_json or {} except (client_exceptions.ContentTypeError, json.decoder.JSONDecodeError): _LOG.warning("Request to %s did not return a valid json.", url) break except client_exceptions.ServerDisconnectedError as exc: if (retry + 1) < max_retries: # For some reason the SMA device sometimes raises a server disconnected error # If this happens we will retry up to `max_retries` times # This prevents errors in Home Assistant _LOG.debug("ServerDisconnectedError, will retry connection.") continue raise SmaConnectionException( f"Server at {self._url} disconnected {max_retries + 1} times." ) from exc except ( client_exceptions.ClientError, asyncio.exceptions.TimeoutError, ) as exc: raise SmaConnectionException( f"Could not connect to SMA at {self._url}: {exc}" ) from exc return {} async def _get_json(self, url: str) -> dict: """Get json data for requests. Args: url (str): URL to do GET request to Returns: dict: json returned by device """ return await self._request_json(hdrs.METH_GET, url) async def _post_json(self, url: str, payload: dict | None = None) -> dict: """Post json data for requests. Args: url (str): URL to do POST request to payload (dict, optional): payload to send to device. Defaults to empty dict. Returns: dict: json returned by device """ if payload is None: payload = {} params: dict[str, Any] = { "data": json.dumps(payload), "headers": {"content-type": "application/json"}, } return await self._request_json(hdrs.METH_POST, url, **params) async def _read_l10n(self) -> dict: """Read language file. Returns cached value on subsequent calls. Returns: dict: Dictionary containing translation keys and their localized strings. """ if self._l10n is not None: return self._l10n # Try to load the requested language from package self._l10n = await asyncio.to_thread(self._load_l10n_from_package, self._lang) # Fallback to default language if requested not found or empty if not self._l10n and self._lang != DEFAULT_LANG: _LOG.warning( "Language '%s' not found in package, falling back to '%s'", self._lang, DEFAULT_LANG, ) self._l10n = await asyncio.to_thread( self._load_l10n_from_package, DEFAULT_LANG ) return self._l10n def _load_l10n_from_package(self, locale: str) -> dict: """Load JSON translation file from package resources. Args: locale (str): Locale code, e.g., "en-US" or "de-DE". Returns: dict: Dictionary containing translation keys and localized strings. Empty dict if file not found or cannot be loaded. """ data = pkgutil.get_data("pysma", f"l10n/{locale}.json") if data is None: return {} return json.loads(data) async def _read_body(self, url: str, payload: dict) -> dict: """Parse the json returned by the device and extract result. Args: url (str): URL to reqquest data from payload (dict): payload to send to device Raises: SmaReadException: The json returned by the device was in an unexpected format Returns: dict: json result """ if self._sid is None and self._new_session_data is not None: await self.new_session() body = await self._post_json(url, payload) # On the first error we close the session which will re-login err = body.get("err") if err is not None: _LOG.warning( "%s: error detected, closing session to force another login attempt, got: %s", self._url, body, ) await self.close_session() raise SmaReadException("Error detected while reading") if "result" not in body: _LOG.warning("No 'result' in reply from SMA, got: %s", body) raise SmaReadException("No 'result' in reply from SMA") if self._uid is None: # Get the unique ID self._uid = next(iter(body["result"].keys()), None) result_body = body["result"].pop(self._uid, None) if body != {"result": {}}: _LOG.warning( "Unexpected body %s, extracted %s", json.dumps(body), json.dumps(result_body), ) return result_body async def new_session(self) -> bool: """Establish a new session. Raises: SmaAuthenticationException: Authentication failed Returns: bool: authentication successful """ body = await self._post_json(URL_LOGIN, self._new_session_data) self._sid = jmespath.search("result.sid", body) if self._sid: _LOG.debug("New SID: %s", self._sid) return True err = body.pop("err", None) msg = "Could not start session: %s" if err: if err == 503: _LOG.error(msg, "Max amount of sessions reached") elif err == 404: if not self._url.startswith("https"): _LOG.error(msg, "Login URL not found, try using HTTPS") else: _LOG.error(msg, "Login URL not found") else: _LOG.error(msg, err) else: _LOG.error(msg, "Session ID expected [result.sid]") raise SmaAuthenticationException() async def close_session(self) -> None: """Close the session login.""" if self._sid is None: return try: await self._post_json(URL_LOGOUT) finally: self._sid = None async def read(self, sensors: Sensors) -> bool: """Read a set of keys. Args: sensors (Sensors): Sensors object containing Sensor objects to read Returns: bool: reading was successful """ if self._new_session_data is None: payload: dict[str, Any] = {"destDev": [], "keys": []} result_body = await self._read_body(URL_DASH_VALUES, payload) else: payload = { "destDev": [], "keys": list({s.key for s in sensors if s.enabled}), } result_body = await self._read_body(URL_VALUES, payload) notfound = [] l10n = await self._read_l10n() for sen in sensors: if sen.enabled: if sen.key in result_body: sen.extract_value(result_body, l10n) continue notfound.append(f"{sen.name} [{sen.key}]") if notfound: _LOG.info( "No values for sensors: %s. Response from inverter: %s", ",".join(notfound), result_body, ) return True async def read_dash_logger(self) -> dict: """Read the dash loggers. Returns: dict: Dictionary containing loggers returned by device. """ return await self._read_body(URL_DASH_LOGGER, {"destDev": [], "key": []}) async def read_logger(self, log_id: int, start: int, end: int) -> list: """Read a logging key and return the results. Args: log_id (int): The ID of the log to read. totWhOut5min: 28672 totWhOutDaily: 28704 GridMsTotWhOutDaily: 28752 GridMsTotWhInDaily: 28768 ObjLogBatCha: 28816 totWhIn5min: 28736 totWhInDaily: 28768 ObjLogBatChrg: 29344 ObjLogBatDsch: 29360 start (int): Start timestamp in seconds. end (int): End timestamp in seconds. Returns: list: The log entries returned by the device """ payload = { "destDev": [], "key": log_id, "tStart": start, "tEnd": end, } result_body = await self._read_body(URL_LOGGER, payload) if not isinstance(result_body, list): raise SmaReadException("List of log entries expected.") return result_body async def device_info(self) -> DeviceInfo: """Read device info and return the results. Returns: DeviceInfo: Object containing serial, name, type, manufacturer and sw_version """ await self.read(self._device_info_sensors) return DeviceInfo( serial=self._device_info_sensors["serial_number"].value, name=self._device_info_sensors["device_name"].value, type=self._device_info_sensors["device_type"].value, manufacturer=self._device_info_sensors["device_manufacturer"].value, sw_version=self._device_info_sensors["device_sw_version"].value, ) async def _read_all_sensors(self) -> dict: all_values = await self._read_body(URL_ALL_VALUES, {"destDev": []}) all_params = await self._read_body(URL_ALL_PARAMS, {"destDev": []}) return all_values | all_params async def get_sensors(self) -> Sensors: """Get the sensors that are present on the device. Returns: Sensors: Sensors object containing Sensor objects """ all_sensors = await self._read_all_sensors() sensor_keys = all_sensors.keys() device_sensors = Sensors() _LOG.debug("Matching generic sensors") for sensor in webconnect.sensor_map[GENERIC_SENSORS]: if sensor.key in sensor_keys: if isinstance(all_sensors[sensor.key], list): # SB1.5 multi value val_len = len(all_sensors[sensor.key]) elif "val" in all_sensors[sensor.key]: # SB1.5 single value val_len = 1 else: # All other types, single or multi value sensors_values = next(iter(all_sensors[sensor.key].values())) val_len = len(sensors_values) _LOG.debug("Found %s with %d value(s).", sensor.key, val_len) if sensor.key_idx < val_len: _LOG.debug( "Adding sensor %s (%s_%s)", sensor.name, sensor.key, sensor.key_idx, ) device_sensors.add(sensor) _LOG.debug("Checking if Energy Meter is present...") # Detect and add Energy Meter sensors em_sensor = copy.copy(webconnect.energy_meter) em_sensor.extract_value(all_sensors) if em_sensor.value: _LOG.debug( "Energy Meter with serial %s detected. Adding extra sensors.", em_sensor.value, ) device_sensors.add( [ sensor for sensor in webconnect.sensor_map[ENERGY_METER_VIA_INVERTER] if sensor not in device_sensors ] ) _LOG.debug("Finding connected optimizers...") # Detect and add Optimizer Sensors optimizers = all_sensors.get(webconnect.optimizer_serial.key) if optimizers: serials = optimizers.popitem()[1] for idx, serial in enumerate(serials or []): if serial["val"]: _LOG.debug( "Optimizer %s with serial %s detected. Adding extra sensors.", idx, serial, ) for sensor_definition in webconnect.sensor_map[ OPTIMIZERS_VIA_INVERTER ]: new_sensor = copy.copy(sensor_definition) new_sensor.key_idx = idx new_sensor.name = f"{sensor_definition.name}_{idx}" device_sensors.add(new_sensor) return device_sensors