Skip to content

Commit

Permalink
Merge pull request #47 from nielstron/dev
Browse files Browse the repository at this point in the history
Error handling and device_type - model mapping
  • Loading branch information
nielstron committed Sep 13, 2021
2 parents 6e3ca03 + 64137f6 commit b466331
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 65 deletions.
10 changes: 6 additions & 4 deletions example.py
Expand Up @@ -2,6 +2,7 @@
"""Basic usage example and testing of pyfronius."""
import asyncio
import logging
import json
import sys
import aiohttp

Expand All @@ -18,19 +19,20 @@ async def main(loop, host):
# NOTE: configuring the wrong devices may cause Exceptions to be thrown
res = await fronius.fetch(
active_device_info=True,
inverter_info=True,
logger_info=True,
power_flow=True,
system_meter=True,
system_inverter=True,
system_storage=True,
device_meter=frozenset([0]),
device_meter=["0"],
# storage is not necessarily supported by every fronius device
device_storage=frozenset([0]),
device_inverter=frozenset([1]),
device_storage=["0"],
device_inverter=["1"],
loop=loop,
)
for r in res:
print(r)
print(json.dumps(r, indent=4))


if __name__ == "__main__":
Expand Down
120 changes: 98 additions & 22 deletions pyfronius/__init__.py
Expand Up @@ -8,10 +8,14 @@
import asyncio
import enum
from html import unescape
import json
import logging
from typing import Any, Dict

import aiohttp

from .const import INVERTER_DEVICE_TYPE

_LOGGER = logging.getLogger(__name__)
DEGREE_CELSIUS = "°C"
WATT = "W"
Expand All @@ -36,7 +40,7 @@ class API_VERSION(enum.Enum):

API_BASEPATHS = {
API_VERSION.V0: "/solar_api/",
API_VERSION.V1: "/solar_api/v1",
API_VERSION.V1: "/solar_api/v1/",
}

URL_API_VERSION = "solar_api/GetAPIVersion.cgi"
Expand Down Expand Up @@ -90,13 +94,67 @@ class API_VERSION(enum.Enum):
API_VERSION.V1: "GetLoggerInfo.cgi",
}

HEADER_STATUS_CODES = {
0: "OKAY",
1: "NotImplemented",
2: "Uninitialized",
3: "Initialized",
4: "Running",
5: "Timeout",
6: "Argument Error",
7: "LNRequestError",
8: "LNRequestTimeout",
9: "LNParseError",
10: "ConfigIOError",
11: "NotSupported",
12: "DeviceNotAvailable",
255: "UnknownError",
}


class NotSupportedError(ValueError):
class FroniusError(Exception):
"""
An error to be raised if a specific feature is not supported by the specified device
A superclass that covers all errors occuring during the
connection to a Fronius device
"""

pass

class NotSupportedError(ValueError, FroniusError):
"""
An error to be raised if a specific feature
is not supported by the specified device
"""


class ConnectionError(ConnectionError, FroniusError):
"""
An error to be raised if the connection to the fronius device failed
"""


class InvalidAnswerError(ValueError, FroniusError):
"""
An error to be raised if the host Fronius device could not answer a request
"""


class BadStatusError(FroniusError):
"""A bad status code was returned."""
def __init__(
self,
endpoint: str,
code: int,
reason: str = None,
response: Dict[str, Any] = {},
) -> None:
"""Instantiate exception."""
self.response = response
message = (
f"BadStatusError at {endpoint}. "
f"Code: {code} - {HEADER_STATUS_CODES.get(code, 'unknown status code')}. "
f"Reason: {reason or 'unknown'}."
)
super().__init__(message)


class Fronius:
Expand Down Expand Up @@ -124,7 +182,7 @@ def __init__(
if not self.url.startswith("http"):
self.url = "http://{}".format(self.url)
self.api_version = api_version
self.base_url = API_BASEPATHS.get(API_VERSION)
self.base_url = API_BASEPATHS.get(api_version)

async def _fetch_json(self, url):
"""
Expand All @@ -141,8 +199,10 @@ async def _fetch_json(self, url):
raise ConnectionError(
"Connection to Fronius device failed at {}.".format(url)
)
except aiohttp.ContentTypeError:
raise ValueError("Host returned a non-JSON reply at {}.".format(url))
except (aiohttp.ContentTypeError, json.decoder.JSONDecodeError):
raise InvalidAnswerError(
"Host returned a non-JSON reply at {}.".format(url)
)
return result

async def fetch_api_version(self):
Expand All @@ -153,7 +213,7 @@ async def fetch_api_version(self):
try:
res = await self._fetch_json("{}/{}".format(self.url, URL_API_VERSION))
api_version, base_url = API_VERSION(res["APIVersion"]), res["BaseURL"]
except ValueError:
except InvalidAnswerError:
# Host returns 404 response if API version is 0
api_version, base_url = API_VERSION.V0, API_BASEPATHS[API_VERSION.V0]

Expand Down Expand Up @@ -185,12 +245,11 @@ async def _fetch_solar_api(self, spec, spec_name, *spec_formattings):
)
spec_url = spec.get(self.api_version)
if spec_url is None:
_LOGGER.warning(
raise NotSupportedError(
"API version {} does not support request of {} data".format(
self.api_version, spec_name
)
)
return None
if spec_formattings:
spec_url = spec_url.format(*spec_formattings)

Expand All @@ -207,10 +266,10 @@ async def fetch(
system_meter=True,
system_inverter=True,
system_storage=True,
device_meter=frozenset([0]),
device_meter=frozenset(["0"]),
# storage is not necessarily supported by every fronius device
device_storage=frozenset([0]),
device_inverter=frozenset([1]),
device_storage=frozenset(["0"]),
device_inverter=frozenset(["1"]),
loop=None,
):
requests = []
Expand All @@ -235,7 +294,15 @@ async def fetch(
for i in device_inverter:
requests.append(self.current_inverter_data(i))

responses = await asyncio.gather(*requests, loop=loop)
res = await asyncio.gather(*requests, loop=loop, return_exceptions=True)
responses = []
for result in res:
if isinstance(result, FroniusError):
_LOGGER.warning(result)
if isinstance(result, BadStatusError):
responses.append(result.response)
continue
responses.append(result)
return responses

@staticmethod
Expand Down Expand Up @@ -268,7 +335,7 @@ async def _current_data(self, fun, spec, spec_name, *spec_formattings):
sensor = {}
try:
res = await self._fetch_solar_api(spec, spec_name, *spec_formattings)
except ValueError:
except InvalidAnswerError:
# except if Host returns 404
raise NotSupportedError(
"Device type {} not supported by the fronius device".format(spec_name)
Expand All @@ -277,19 +344,23 @@ async def _current_data(self, fun, spec, spec_name, *spec_formattings):
try:
sensor.update(Fronius._status_data(res))
except (TypeError, KeyError):
# break if Data is empty
_LOGGER.info(
raise InvalidAnswerError(
"No header data returned from {} ({})".format(spec, spec_formattings)
)
else:
if sensor["status"]["Code"] != 0:
endpoint = spec[self.api_version]
code = sensor["status"]["Code"]
reason = sensor["status"]["Reason"]
raise BadStatusError(endpoint, code, reason=reason, response=sensor)
try:
sensor.update(fun(res["Body"]["Data"]))
except (TypeError, KeyError):
# LoggerInfo oddly deviates from the default scheme
try:
sensor.update(fun(res["Body"]["LoggerInfo"]))
except (TypeError, KeyError):
# break if Data is empty
_LOGGER.info(
raise InvalidAnswerError(
"No body data returned from {} ({})".format(spec, spec_formattings)
)
return sensor
Expand Down Expand Up @@ -321,15 +392,15 @@ async def current_system_inverter_data(self):
"current system inverter",
)

async def current_meter_data(self, device=0):
async def current_meter_data(self, device: str = "0") -> Dict[str, Any]:
"""
Get the current meter data for a device.
"""
return await self._current_data(
Fronius._device_meter_data, URL_DEVICE_METER, "current meter", device
)

async def current_storage_data(self, device=0):
async def current_storage_data(self, device: str = "0") -> Dict[str, Any]:
"""
Get the current storage data for a device.
Provides data about batteries.
Expand All @@ -347,7 +418,7 @@ async def current_system_storage_data(self):
Fronius._system_storage_data, URL_SYSTEM_STORAGE, "current system storage"
)

async def current_inverter_data(self, device=1):
async def current_inverter_data(self, device: str = "1") -> Dict[str, Any]:
"""
Get the current inverter data of one device.
"""
Expand Down Expand Up @@ -1029,6 +1100,11 @@ def _inverter_info(data):
"status_code": {"value": inverter_info["StatusCode"]},
"unique_id": {"value": inverter_info["UniqueID"]},
}
if inverter_info["DT"] in INVERTER_DEVICE_TYPE:
# add manufacturer and model if known
inverter["device_type"].update(
INVERTER_DEVICE_TYPE[inverter_info["DT"]]
)
# "CustomName" not available on API V0 so default to ""
# html escaped by V1 Snap-In, UTF-8 by V1 Gen24
if "CustomName" in inverter_info:
Expand Down

0 comments on commit b466331

Please sign in to comment.